Lazy-import keeper.ts so --help and --version skip nut.js load

keeper.ts statically imports @nut-tree-fork/nut-js, which dlopens a
sizeable native .node binary. That load dominated cold-start: ~1.2s
for 'move --version' immediately after install, vs ~125ms warm.
For a flag that just prints a string, that's all overhead.

Change: dynamic 'await import("./keeper.ts")' placed after the
--help and --version short-circuits. The dynamic import is wrapped
in try/catch so import-time failures (e.g., missing native binary,
unsupported architecture) route through the same failRuntime() path
as anything thrown by the loop.

Expected cold start for --help and --version drops to ~50-150ms
(Bun + reading a handful of small files, no native module load).
move with no flags pays the same load cost as today.

tsc --noEmit and the test suite remain clean.
This commit is contained in:
2026-06-17 21:57:42 -05:00
parent 10172f11b0
commit cc7a487aca
+24 -4
View File
@@ -12,13 +12,17 @@
* *
* Order of operations: * Order of operations:
* 1. Parse CLI args. Bad input -> stderr + usage hint, exit 2. * 1. Parse CLI args. Bad input -> stderr + usage hint, exit 2.
* 2. `--help` / `--version` short-circuit before any I/O or mouse work. * 2. `--help` / `--version` short-circuit before any I/O, config load, or
* mouse work. `keeper.ts` is also lazy-imported (see below) so these
* flags don't pay the cost of loading the nut.js native binary.
* 3. Load + validate the config file (default XDG path, or `--config * 3. Load + validate the config file (default XDG path, or `--config
* <path>` if supplied). Validation failures share the exit-2 path. * <path>` if supplied). Validation failures share the exit-2 path.
* 4. Resolve the full `Config` (CLI > file > DEFAULT_CONFIG) — verbose * 4. Resolve the full `Config` (CLI > file > DEFAULT_CONFIG) — verbose
* lives inside `Config` and is layered with the same precedence as * lives inside `Config` and is layered with the same precedence as
* the numeric fields. * the numeric fields.
* 5. Run keeper. Any unhandled rejection exits 1. * 5. Lazy-import `keeper.ts` (dynamic import keeps nut.js out of the
* `--help` / `--version` startup path) and run it. Any unhandled
* rejection — from the import itself or from the loop — exits 1.
* *
* Runtime: Bun (uses `@nut-tree-fork/nut-js` via `keeper.ts`). The shebang * Runtime: Bun (uses `@nut-tree-fork/nut-js` via `keeper.ts`). The shebang
* above lets this file run as a real CLI once linked via `bun link`. * above lets this file run as a real CLI once linked via `bun link`.
@@ -29,7 +33,13 @@ import type { ParsedCliArgs } from "./cli.ts";
import { resolveConfig } from "./config.ts"; import { resolveConfig } from "./config.ts";
import type { ConfigOverrides } from "./config.ts"; import type { ConfigOverrides } from "./config.ts";
import { loadConfigFile } from "./configFile.ts"; import { loadConfigFile } from "./configFile.ts";
import { runKeeper } from "./keeper.ts";
// `keeper.ts` is intentionally NOT statically imported here. It transitively
// pulls in `@nut-tree-fork/nut-js`, which in turn dlopens a sizeable native
// `.node` binary. On a cold first run that load dominates startup (~1 s on
// macOS). For `--help` and `--version` we never actually need nut.js, so we
// defer the import to the only branch that actually runs the keeper loop.
// See the dynamic `await import("./keeper.ts")` near the bottom of the file.
/** /**
* Print a user-error message and exit 2. Used for anything that comes from * Print a user-error message and exit 2. Used for anything that comes from
@@ -98,4 +108,14 @@ const cliOverrides: ConfigOverrides = {
const config = resolveConfig(fileOverrides, cliOverrides); const config = resolveConfig(fileOverrides, cliOverrides);
runKeeper(config).catch(failRuntime); // Dynamic import so nut.js and the rest of the keeper machinery aren't
// loaded for invocations that exit early (--help, --version, validation
// failures). The `try` covers import-time failures too (e.g., a missing
// nut.js native binary), routing them through the same runtime-failure
// path as anything raised by the loop itself.
try {
const { runKeeper } = await import("./keeper.ts");
runKeeper(config).catch(failRuntime);
} catch (err: unknown) {
failRuntime(err);
}