mirror of
https://github.com/bknd-io/bknd/
synced 2026-08-02 08:06:00 +00:00
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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<App>` (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.
|
||||||
|
```
|
||||||
@@ -108,6 +108,7 @@
|
|||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"commander": "^14.0.2",
|
"commander": "^14.0.2",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
|
"errore": "^0.14.0",
|
||||||
"jotai": "^2.12.2",
|
"jotai": "^2.12.2",
|
||||||
"jsdom": "^26.1.0",
|
"jsdom": "^26.1.0",
|
||||||
"kysely-generic-sqlite": "^1.2.1",
|
"kysely-generic-sqlite": "^1.2.1",
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { CliBkndConfig, CliCommand } from "cli/types";
|
|||||||
import { Option } from "commander";
|
import { Option } from "commander";
|
||||||
import { config, type App, type CreateAppConfig, type MaybePromise, registries } from "bknd";
|
import { config, type App, type CreateAppConfig, type MaybePromise, registries } from "bknd";
|
||||||
import dotenv from "dotenv";
|
import dotenv from "dotenv";
|
||||||
|
import * as errore from "errore";
|
||||||
import c from "picocolors";
|
import c from "picocolors";
|
||||||
import { execFileSync } from "node:child_process";
|
import { execFileSync } from "node:child_process";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
@@ -19,6 +20,26 @@ import { createRuntimeApp, makeConfig } from "bknd/adapter";
|
|||||||
import { colorizeConsole, isBun } from "bknd/utils";
|
import { colorizeConsole, isBun } from "bknd/utils";
|
||||||
import { withConfigOptions, type WithConfigOptions } from "cli/utils/options";
|
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"];
|
const env_files = [".env", ".dev.vars"];
|
||||||
dotenv.config({
|
dotenv.config({
|
||||||
path: env_files.map((file) => path.resolve(process.cwd(), file)),
|
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;
|
return major === 22 && minor! < 18 && minor! > 5;
|
||||||
}
|
}
|
||||||
|
|
||||||
function reexecWithTypeStripping(): never {
|
function reexecWithTypeStripping(): ReexecFailedError | never {
|
||||||
if (process.env.__BKND_REEXEC) {
|
if (process.env.__BKND_REEXEC) {
|
||||||
console.error(c.red("TS config still failed after re-exec with --experimental-strip-types."));
|
return new ReexecFailedError({
|
||||||
process.exit(1);
|
reason: "TS config still failed after re-exec with --experimental-strip-types",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const cliPath = path.resolve(process.argv[1]!);
|
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"),
|
c.yellow("Node <22.18 detected, re-executing with --experimental-strip-types"),
|
||||||
);
|
);
|
||||||
|
|
||||||
try {
|
const result = errore.try({
|
||||||
|
try: () =>
|
||||||
execFileSync(process.execPath, args, {
|
execFileSync(process.execPath, args, {
|
||||||
stdio: "inherit",
|
stdio: "inherit",
|
||||||
env: { ...process.env, __BKND_REEXEC: "1" },
|
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);
|
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadConfigFile(configFilePath: string): Promise<CliBkndConfig> {
|
async function loadConfigFile(
|
||||||
|
configFilePath: string,
|
||||||
|
): Promise<ConfigLoadError | NeedsBunError | NeedsTypeStrippingError | CliBkndConfig> {
|
||||||
if (needsTypeStripping(configFilePath) && !process.execArgv.includes("--experimental-strip-types")) {
|
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;
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function reexecUnderBun(): never {
|
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(): ReexecFailedError | never {
|
||||||
if (process.env.__BKND_REEXEC) {
|
if (process.env.__BKND_REEXEC) {
|
||||||
console.error(c.red("Config requires Bun but still failed under Bun runtime."));
|
return new ReexecFailedError({
|
||||||
process.exit(1);
|
reason: "Config requires Bun but still failed under Bun runtime",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const bunPath = process.env.BUN_INSTALL
|
const bunPath = process.env.BUN_INSTALL
|
||||||
@@ -114,22 +160,21 @@ function reexecUnderBun(): never {
|
|||||||
c.cyan(`bun ${args.join(" ")}`),
|
c.cyan(`bun ${args.join(" ")}`),
|
||||||
);
|
);
|
||||||
|
|
||||||
try {
|
const result = errore.try({
|
||||||
|
try: () =>
|
||||||
execFileSync(bunPath, args, {
|
execFileSync(bunPath, args, {
|
||||||
stdio: "inherit",
|
stdio: "inherit",
|
||||||
env: { ...process.env, __BKND_REEXEC: "1" },
|
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);
|
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type MakeAppConfig = {
|
type MakeAppConfig = {
|
||||||
@@ -176,16 +221,33 @@ export async function makeAppFromEnv(options: Partial<RunOptions> = {}) {
|
|||||||
// check configuration file to be present
|
// check configuration file to be present
|
||||||
} else if (configFilePath) {
|
} else if (configFilePath) {
|
||||||
console.info("Using config from", c.cyan(configFilePath));
|
console.info("Using config from", c.cyan(configFilePath));
|
||||||
try {
|
const configResult = await loadConfigFile(configFilePath);
|
||||||
const config = await loadConfigFile(configFilePath);
|
|
||||||
app = await makeConfigApp(config, options.server);
|
if (configResult instanceof Error) {
|
||||||
} catch (e: any) {
|
errore.matchError(configResult, {
|
||||||
const needsBun =
|
NeedsTypeStrippingError: () => {
|
||||||
(e instanceof ReferenceError && /\bBun\b.*not defined/.test(e.message)) ||
|
const reexecResult = reexecWithTypeStripping();
|
||||||
(e?.code === "ERR_UNSUPPORTED_ESM_URL_SCHEME" && /bun:/.test(e.message));
|
// only reached on failure — success calls process.exit(0)
|
||||||
if (needsBun) reexecUnderBun();
|
console.error(c.red(reexecResult.message));
|
||||||
console.error("Failed to load config:", e);
|
|
||||||
process.exit(1);
|
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
|
// try to use an in-memory connection
|
||||||
|
|||||||
@@ -79,6 +79,7 @@
|
|||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"commander": "^14.0.2",
|
"commander": "^14.0.2",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
|
"errore": "^0.14.0",
|
||||||
"jotai": "^2.12.2",
|
"jotai": "^2.12.2",
|
||||||
"jsdom": "^26.1.0",
|
"jsdom": "^26.1.0",
|
||||||
"kysely-generic-sqlite": "^1.2.1",
|
"kysely-generic-sqlite": "^1.2.1",
|
||||||
@@ -1936,6 +1937,8 @@
|
|||||||
|
|
||||||
"error-stack-parser-es": ["error-stack-parser-es@1.0.5", "", {}, "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA=="],
|
"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-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=="],
|
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
|
||||||
|
|||||||
Reference in New Issue
Block a user