From 4defc4d1e7c090fefa37d8bb2fe9473ddc3e71ea Mon Sep 17 00:00:00 2001 From: Cameron Pak Date: Sat, 7 Mar 2026 10:30:21 -0600 Subject: [PATCH] refactor(cli): use errore for typed error handling in run command Replace untyped catch blocks and scattered process.exit() with errore tagged errors and error-as-values pattern. Compile-time safety for error handling paths. Co-Authored-By: Claude Opus 4.6 --- PLAN.md | 90 +++++++++++++++++++ app/package.json | 1 + app/src/cli/commands/run/run.ts | 154 ++++++++++++++++++++++---------- bun.lock | 3 + 4 files changed, 202 insertions(+), 46 deletions(-) create mode 100644 PLAN.md diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 00000000..6af7a672 --- /dev/null +++ b/PLAN.md @@ -0,0 +1,90 @@ +# Proactive Error Handling with errore in `run.ts` + +Ref: https://errore.org/ + +## Goal + +Replace scattered `process.exit()`, untyped `catch (e: any)`, and inline error handling in `run.ts` with errore's error-as-values pattern. Compile-time exhaustive error handling via `matchError` in a single top-level `action()`. + +## Constraints + +- `makeAppFromEnv` and `makeConfigApp` are exported — **do not change their return types** +- `platform.ts` (`startServer`, `serveStatic`, `getConfigPath`) — **unchanged this PR** +- Keep it simple: errors defined in `run.ts`, not a separate file + +## Phase 1: Add errore dependency + +- `app/package.json`: add `"errore"` to dependencies +- Run install + +## Phase 2: Define tagged errors in `run.ts` + +```ts +import errore from "errore" + +class ConfigLoadError extends errore.createTaggedError({ + name: "ConfigLoadError", + message: "Failed to load config: $reason", +}) {} + +class NeedsBunError extends errore.createTaggedError({ + name: "NeedsBunError", + message: "Config requires Bun runtime", +}) {} + +class NeedsTypeStrippingError extends errore.createTaggedError({ + name: "NeedsTypeStrippingError", + message: "Node $version needs --experimental-strip-types for .ts config", +}) {} + +class ReexecFailedError extends errore.createTaggedError({ + name: "ReexecFailedError", + message: "Re-exec failed: $reason", +}) {} +``` + +## Phase 3: Refactor internal functions to return errors + +### `loadConfigFile` → `ConfigLoadError | NeedsBunError | NeedsTypeStrippingError | CliBkndConfig` +- Use `errore.tryAsync` to wrap `import()` +- On catch: detect Bun requirement → `NeedsBunError` +- On catch: other failure → `ConfigLoadError` +- Before import: check `needsTypeStripping()` → `NeedsTypeStrippingError` + +### `reexecWithTypeStripping` / `reexecUnderBun` → `ReexecFailedError | never` +- Success: `process.exit(0)` (unavoidable — must stop parent after child completes) +- Failure: return `ReexecFailedError` instead of `process.exit(1)` +- Caller decides how to handle failure + +### `makeAppFromEnv` internal flow (return type unchanged) +- Internally use error-as-values from `loadConfigFile` +- On `NeedsBunError` / `NeedsTypeStrippingError`: call re-exec, handle `ReexecFailedError` +- On `ConfigLoadError`: `console.error` + `process.exit(1)` +- Keep exported signature: `Promise` (exits on unrecoverable errors) + +### `action()` — cleaner top-level +- `makeAppFromEnv` handles its own error cases internally (since we can't change its return type) +- `action()` stays thin: call `makeAppFromEnv` → `startServer` + +## Phase 4: Verify + +- No behavior change for end users +- All `catch (e: any)` replaced with typed errore patterns +- `process.exit(1)` consolidated to fewer, explicit locations +- Re-exec failure paths return typed errors before exiting + +## Files Changed + +| File | Change | +|---|---| +| `app/package.json` | add `errore` dep | +| `app/src/cli/commands/run/run.ts` | tagged errors, refactored error flow | + +## Commit Message +``` +refactor(cli): use errore for typed error handling in run command + +Replace untyped catch blocks and scattered process.exit() with +errore tagged errors and error-as-values pattern. Compile-time +safety for error handling paths. +``` diff --git a/app/package.json b/app/package.json index 002d3709..10df1d70 100644 --- a/app/package.json +++ b/app/package.json @@ -108,6 +108,7 @@ "clsx": "^2.1.1", "commander": "^14.0.2", "dotenv": "^17.2.3", + "errore": "^0.14.0", "jotai": "^2.12.2", "jsdom": "^26.1.0", "kysely-generic-sqlite": "^1.2.1", diff --git a/app/src/cli/commands/run/run.ts b/app/src/cli/commands/run/run.ts index 460fb303..3dfc2ea6 100644 --- a/app/src/cli/commands/run/run.ts +++ b/app/src/cli/commands/run/run.ts @@ -4,6 +4,7 @@ import type { CliBkndConfig, CliCommand } from "cli/types"; import { Option } from "commander"; import { config, type App, type CreateAppConfig, type MaybePromise, registries } from "bknd"; import dotenv from "dotenv"; +import * as errore from "errore"; import c from "picocolors"; import { execFileSync } from "node:child_process"; import path from "node:path"; @@ -19,6 +20,26 @@ import { createRuntimeApp, makeConfig } from "bknd/adapter"; import { colorizeConsole, isBun } from "bknd/utils"; import { withConfigOptions, type WithConfigOptions } from "cli/utils/options"; +class ConfigLoadError extends errore.createTaggedError({ + name: "ConfigLoadError", + message: "Failed to load config: $reason", +}) {} + +class NeedsBunError extends errore.createTaggedError({ + name: "NeedsBunError", + message: "Config requires Bun runtime", +}) {} + +class NeedsTypeStrippingError extends errore.createTaggedError({ + name: "NeedsTypeStrippingError", + message: "Node $version needs --experimental-strip-types for .ts config", +}) {} + +class ReexecFailedError extends errore.createTaggedError({ + name: "ReexecFailedError", + message: "Re-exec failed: $reason", +}) {} + const env_files = [".env", ".dev.vars"]; dotenv.config({ path: env_files.map((file) => path.resolve(process.cwd(), file)), @@ -63,10 +84,11 @@ function needsTypeStripping(configFilePath: string): boolean { return major === 22 && minor! < 18 && minor! > 5; } -function reexecWithTypeStripping(): never { +function reexecWithTypeStripping(): ReexecFailedError | never { if (process.env.__BKND_REEXEC) { - console.error(c.red("TS config still failed after re-exec with --experimental-strip-types.")); - process.exit(1); + return new ReexecFailedError({ + reason: "TS config still failed after re-exec with --experimental-strip-types", + }); } const cliPath = path.resolve(process.argv[1]!); @@ -76,30 +98,54 @@ function reexecWithTypeStripping(): never { c.yellow("Node <22.18 detected, re-executing with --experimental-strip-types"), ); - try { - execFileSync(process.execPath, args, { - stdio: "inherit", - env: { ...process.env, __BKND_REEXEC: "1" }, - }); - process.exit(0); - } catch (e: any) { - if (e.status != null) process.exit(e.status); - console.error(c.red("Failed to re-exec with --experimental-strip-types.")); - process.exit(1); - } + const result = errore.try({ + try: () => + execFileSync(process.execPath, args, { + stdio: "inherit", + env: { ...process.env, __BKND_REEXEC: "1" }, + }), + catch: (e) => + new ReexecFailedError({ + reason: "Failed to re-exec with --experimental-strip-types", + cause: e, + }), + }); + + if (result instanceof Error) return result; + process.exit(0); } -async function loadConfigFile(configFilePath: string): Promise { +async function loadConfigFile( + configFilePath: string, +): Promise { if (needsTypeStripping(configFilePath) && !process.execArgv.includes("--experimental-strip-types")) { - reexecWithTypeStripping(); + return new NeedsTypeStrippingError({ + version: process.versions.node, + }); } - return (await import(configFilePath).then((m) => m.default)) as CliBkndConfig; + + const result = await errore.tryAsync({ + try: () => import(configFilePath).then((m) => m.default as CliBkndConfig), + catch: (e) => { + const needsBun = + (e instanceof ReferenceError && /\bBun\b.*not defined/.test(e.message)) || + (e instanceof Error && "code" in e && e.code === "ERR_UNSUPPORTED_ESM_URL_SCHEME" && /bun:/.test(e.message)); + if (needsBun) return new NeedsBunError(); + return new ConfigLoadError({ + reason: e instanceof Error ? e.message : String(e), + cause: e, + }); + }, + }); + + return result; } -function reexecUnderBun(): never { +function reexecUnderBun(): ReexecFailedError | never { if (process.env.__BKND_REEXEC) { - console.error(c.red("Config requires Bun but still failed under Bun runtime.")); - process.exit(1); + return new ReexecFailedError({ + reason: "Config requires Bun but still failed under Bun runtime", + }); } const bunPath = process.env.BUN_INSTALL @@ -114,22 +160,21 @@ function reexecUnderBun(): never { c.cyan(`bun ${args.join(" ")}`), ); - try { - execFileSync(bunPath, args, { - stdio: "inherit", - env: { ...process.env, __BKND_REEXEC: "1" }, - }); - process.exit(0); - } catch (e: any) { - if (e.status != null) { - process.exit(e.status); - } - console.error( - c.red("Could not re-exec under Bun."), - "Install Bun (https://bun.sh) or use bknd/adapter/node in your config.", - ); - process.exit(1); - } + const result = errore.try({ + try: () => + execFileSync(bunPath, args, { + stdio: "inherit", + env: { ...process.env, __BKND_REEXEC: "1" }, + }), + catch: (e) => + new ReexecFailedError({ + reason: "Could not re-exec under Bun. Install Bun (https://bun.sh) or use bknd/adapter/node in your config.", + cause: e, + }), + }); + + if (result instanceof Error) return result; + process.exit(0); } type MakeAppConfig = { @@ -176,16 +221,33 @@ export async function makeAppFromEnv(options: Partial = {}) { // check configuration file to be present } else if (configFilePath) { console.info("Using config from", c.cyan(configFilePath)); - try { - const config = await loadConfigFile(configFilePath); - app = await makeConfigApp(config, options.server); - } catch (e: any) { - const needsBun = - (e instanceof ReferenceError && /\bBun\b.*not defined/.test(e.message)) || - (e?.code === "ERR_UNSUPPORTED_ESM_URL_SCHEME" && /bun:/.test(e.message)); - if (needsBun) reexecUnderBun(); - console.error("Failed to load config:", e); - process.exit(1); + const configResult = await loadConfigFile(configFilePath); + + if (configResult instanceof Error) { + errore.matchError(configResult, { + NeedsTypeStrippingError: () => { + const reexecResult = reexecWithTypeStripping(); + // only reached on failure — success calls process.exit(0) + console.error(c.red(reexecResult.message)); + process.exit(1); + }, + NeedsBunError: () => { + const reexecResult = reexecUnderBun(); + // only reached on failure — success calls process.exit(0) + console.error(c.red(reexecResult.message)); + process.exit(1); + }, + ConfigLoadError: (e) => { + console.error(c.red("Failed to load config:"), e.reason); + process.exit(1); + }, + Error: (e) => { + console.error(c.red("Failed to load config:"), e.message); + process.exit(1); + }, + }); + } else { + app = await makeConfigApp(configResult, options.server); } // try to use an in-memory connection diff --git a/bun.lock b/bun.lock index 5e3bbf4b..681b1af8 100644 --- a/bun.lock +++ b/bun.lock @@ -79,6 +79,7 @@ "clsx": "^2.1.1", "commander": "^14.0.2", "dotenv": "^17.2.3", + "errore": "^0.14.0", "jotai": "^2.12.2", "jsdom": "^26.1.0", "kysely-generic-sqlite": "^1.2.1", @@ -1936,6 +1937,8 @@ "error-stack-parser-es": ["error-stack-parser-es@1.0.5", "", {}, "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA=="], + "errore": ["errore@0.14.0", "", { "bin": { "errore": "dist/cli.js" } }, "sha512-2RI7BGdxWlJe6yJ3DK0pIvLNzXEE4M41VmF2HrH7C2HldTjYiORQNJ+ufsvAvOUGW/4rRHPFOpooP3ebIRIEXw=="], + "es-abstract": ["es-abstract@1.23.9", "", { "dependencies": { "array-buffer-byte-length": "^1.0.2", "arraybuffer.prototype.slice": "^1.0.4", "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.3", "data-view-buffer": "^1.0.2", "data-view-byte-length": "^1.0.2", "data-view-byte-offset": "^1.0.1", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "es-set-tostringtag": "^2.1.0", "es-to-primitive": "^1.3.0", "function.prototype.name": "^1.1.8", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.0", "get-symbol-description": "^1.1.0", "globalthis": "^1.0.4", "gopd": "^1.2.0", "has-property-descriptors": "^1.0.2", "has-proto": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "internal-slot": "^1.1.0", "is-array-buffer": "^3.0.5", "is-callable": "^1.2.7", "is-data-view": "^1.0.2", "is-regex": "^1.2.1", "is-shared-array-buffer": "^1.0.4", "is-string": "^1.1.1", "is-typed-array": "^1.1.15", "is-weakref": "^1.1.0", "math-intrinsics": "^1.1.0", "object-inspect": "^1.13.3", "object-keys": "^1.1.1", "object.assign": "^4.1.7", "own-keys": "^1.0.1", "regexp.prototype.flags": "^1.5.3", "safe-array-concat": "^1.1.3", "safe-push-apply": "^1.0.0", "safe-regex-test": "^1.1.0", "set-proto": "^1.0.0", "string.prototype.trim": "^1.2.10", "string.prototype.trimend": "^1.0.9", "string.prototype.trimstart": "^1.0.8", "typed-array-buffer": "^1.0.3", "typed-array-byte-length": "^1.0.3", "typed-array-byte-offset": "^1.0.4", "typed-array-length": "^1.0.7", "unbox-primitive": "^1.1.0", "which-typed-array": "^1.1.18" } }, "sha512-py07lI0wjxAC/DcfK1S6G7iANonniZwTISvdPzk9hzeH0IZIshbuuFxLIU96OyF89Yb9hiqWn8M/bY83KY5vzA=="], "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],