1tube 0.1.46 → 0.1.47

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -62,25 +62,57 @@ npx 1tube package --functions supabase/functions --in dist --out fw.1tube --sign
62
62
  This replaces local-source invocations such as
63
63
  `deno run -A ../1tube/src/cli.ts ...` in downstream CI pipelines.
64
64
 
65
- ### Passing deno flags through the launcher
65
+ ### One launcher, one path
66
66
 
67
- Arguments before a standalone `--` are 1tube CLI args; everything **after** `--`
68
- is forwarded verbatim to `deno run` (inserted before the entrypoint). This lets
69
- the launcher fully replace a project's bespoke `deno run … src/server.ts`
70
- command — including project-level deno flags like `--config`, `--env-file`,
71
- `--node-modules-dir` and `--no-lock`:
67
+ There is a single launcher (`src/launch.ts`): the npm bin, the deno tasks, and
68
+ host projects all funnel through it, and `serve` **always** spawns the gateway
69
+ as a child `deno run`. That child process is what lets the launcher reconcile
70
+ the lockfile with your installed dependencies _before_ Deno resolves any npm
71
+ specifier — and fully reload it when a dependency changes (see below).
72
+
73
+ The launcher recognizes its own flags and forwards everything else to the
74
+ gateway, so it fully replaces a bespoke `deno run … src/server.ts` line — no
75
+ `--` split needed:
72
76
 
73
77
  ```bash
74
78
  npx 1tube serve --functions ./supabase/functions --dev --hmr \
75
- -- --no-lock --env-file=.env --config ./supabase/functions/deno.json --node-modules-dir=false
79
+ --config ./supabase/functions/deno.json --env-file=.env --node-modules-dir=false
76
80
  ```
77
81
 
78
- - If your passthrough includes its own `--config`, the launcher does **not** add
79
- 1tube's bundled `deno.json` — your import map wins (the gateway's own sources
80
- use fully-qualified `npm:`/`jsr:` specifiers, so they resolve regardless).
82
+ Launcher flags: `--lock <path>` (managed lockfile, default
83
+ `<cwd>/.1tube/deno.lock`), `--no-lock`, `--refresh-lock`, `--config <path>`,
84
+ `--env-file[=<path>]` (repeatable), `--minimum-dependency-age <n>`,
85
+ `--node-modules-dir <v>`, `--no-pin`, `--no-dep-watch`. Anything after a `--`
86
+ is forwarded verbatim to the gateway.
87
+
88
+ - If you don't pass `--config`, the launcher falls back to 1tube's bundled
89
+ `deno.json`; the gateway's own sources use fully-qualified `npm:`/`jsr:`
90
+ specifiers, so they resolve either way.
81
91
  - Prefer keeping the command line clean? Set the same flags in
82
- `1TUBE_DENO_ARGS` (space-separated) instead of after `--`. CLI passthrough
83
- takes precedence on any duplicate flag.
92
+ `1TUBE_DENO_ARGS` (space-separated); they're appended to the launcher args.
93
+
94
+ ### Dependency pinning & live reload
95
+
96
+ The edge runtime runs with `--node-modules-dir=false`, so Deno resolves npm
97
+ specifiers from its own cache + lockfile, not from `node_modules`. An import map
98
+ that maps a dependency with an **unconstrained** specifier (`npm:1tube/edge`,
99
+ i.e. `@*`) is satisfied by _any_ locked version — so bumping that dependency in
100
+ `package.json` would silently never reach the edge runtime, and the gateway
101
+ could end up a different version than the edge surface its functions import.
102
+
103
+ The launcher closes this gap: before spawning the gateway it rewrites every
104
+ unconstrained `npm:` specifier in your `--config` import map to the **exact
105
+ version installed in `node_modules`** (the source of truth your package manager
106
+ controls), writing the result to `<.1tube>/deno.gen.json`. Now the specifier's
107
+ version tracks the installed dependency, so Deno's native lockfile auto-update
108
+ recomputes just that entry — every already-pinned dependency keeps its locked
109
+ version (and the warm-boot speed a lockfile buys).
110
+
111
+ While serving, the launcher watches your deno config and each pinned
112
+ dependency's `package.json`. When an install or a config edit changes the
113
+ effective version set, it prints `dependency change detected — reloading
114
+ gateway` and fully restarts the gateway so Deno re-resolves. Disable with
115
+ `--no-pin` / `--no-dep-watch`.
84
116
 
85
117
  ### Minimum dependency age
86
118
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "1tube",
3
- "version": "0.1.46",
3
+ "version": "0.1.47",
4
4
  "private": false,
5
5
  "description": "Self-hosted Supabase Edge Functions gateway for Deno, plus an ESLint plugin for consumers",
6
6
  "license": "MIT",
@@ -55,7 +55,7 @@
55
55
  "serve": "deno task serve",
56
56
  "build:edge": "deno run -A npm:typescript@latest/tsc --target ES2022 --module ESNext --moduleResolution Bundler --declaration --outDir dist src/edge.ts src/edge-caller.ts src/warmup-client.ts",
57
57
  "prepack": "bun run build:edge",
58
- "typecheck": "deno check src/server.ts src/cli.ts src/edge.ts src/edge-caller.ts src/warmup-client.ts eslint-plugin.js",
58
+ "typecheck": "deno check src/server.ts src/cli.ts src/launch.ts src/edge.ts src/edge-caller.ts src/warmup-client.ts eslint-plugin.js",
59
59
  "check": "deno cache --allow-scripts=npm:protobufjs src/server.ts",
60
60
  "smoke:bin": "node ./scripts/npm-cli.js --version",
61
61
  "prepublishOnly": "bun run build:edge && bun run typecheck && bun run check && bun run smoke:bin"
@@ -1,113 +1,49 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ /**
4
+ * `1tube` npm bin — a tiny Node shim that locates Deno and runs the single
5
+ * launcher (`src/launch.ts`). All argument handling, the managed lockfile,
6
+ * dependency pinning, minimum-dependency-age, and live reload live in the
7
+ * launcher itself, so this shim stays trivial: find deno, forward argv, wire
8
+ * up signals + exit code.
9
+ *
10
+ * Machines using this need Deno on PATH (or set `DENO_BIN`). Arguments are
11
+ * forwarded verbatim — the launcher recognizes its own flags (`--lock`,
12
+ * `--config`, `--env-file`, `--node-modules-dir`, `--minimum-dependency-age`,
13
+ * `--no-lock`, `--no-pin`, `--no-dep-watch`, …) and forwards everything else
14
+ * to the gateway. `1TUBE_DENO_ARGS` (space-separated) is appended for projects
15
+ * that prefer to keep flags out of the command line.
16
+ */
17
+
3
18
  import { spawn } from "node:child_process";
4
- import { existsSync, mkdirSync, rmSync } from "node:fs";
19
+ import { existsSync } from "node:fs";
5
20
  import { delimiter, dirname, isAbsolute, join, resolve } from "node:path";
6
21
  import process from "node:process";
7
22
  import { fileURLToPath } from "node:url";
8
23
 
9
24
  const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
10
- const denoConfig = resolve(packageRoot, "deno.json");
11
- const cliEntrypoint = resolve(packageRoot, "src", "cli.ts");
25
+ const launcherEntrypoint = resolve(packageRoot, "src", "launch.ts");
12
26
  const denoBin = process.env.DENO_BIN || "deno";
13
27
 
14
- // Arg model
15
- // ---------
16
- // Everything BEFORE a standalone `--` is a 1tube CLI argument (forwarded
17
- // after the entrypoint). Everything AFTER `--` is a raw `deno run` flag,
18
- // inserted BEFORE the entrypoint — so this launcher can fully replace a
19
- // hand-rolled `deno run … src/server.ts` line:
20
- //
21
- // 1tube serve --functions ./supabase/functions --dev --hmr \
22
- // -- --no-lock --env-file=.env --config ./supabase/functions/deno.json --node-modules-dir=false
23
- //
24
- // Projects that would rather not thread flags through the command line
25
- // can set `1TUBE_DENO_ARGS` instead (same flags, space-separated).
26
- const rawArgs = process.argv.slice(2);
27
- const sepIndex = rawArgs.indexOf("--");
28
- const tubeArgs = sepIndex === -1 ? rawArgs : rawArgs.slice(0, sepIndex);
29
- const cliDenoArgs = sepIndex === -1 ? [] : rawArgs.slice(sepIndex + 1);
30
-
31
- // CLI passthrough is appended last so it wins on any duplicate flag
32
- // (Deno applies last-one-wins for repeated flags).
33
- const denoPassthrough = [
28
+ // The launcher needs no import map or lockfile of its own (it imports only
29
+ // node: builtins + relative source), and it owns the gateway child's lock,
30
+ // config and npm-config — so the outer `deno run` stays minimal and fast.
31
+ const denoArgs = [
32
+ "run",
33
+ "--quiet",
34
+ "-A",
35
+ "--node-modules-dir=false",
36
+ launcherEntrypoint,
34
37
  ...tokenizeArgs(
35
38
  process.env.ONETUBE_DENO_ARGS ?? process.env["1TUBE_DENO_ARGS"] ?? "",
36
39
  ),
37
- ...cliDenoArgs,
40
+ ...process.argv.slice(2),
38
41
  ];
39
42
 
40
- // Default to --help only when the user gave us nothing at all; if they
41
- // supplied deno flags but no 1tube subcommand, let cli.ts fall through to
42
- // its default `serve` path.
43
- const forwardedTubeArgs = tubeArgs.length === 0 && denoPassthrough.length === 0
44
- ? ["--help"]
45
- : tubeArgs;
46
-
47
- // 1tube ships its own deno.json (import map for the gateway's jsr:/npm:
48
- // dependencies). Only inject it when the project didn't supply its own
49
- // --config — a Supabase project points --config at
50
- // supabase/functions/deno.json so its function workers resolve the
51
- // project's bare specifiers. The gateway's own sources use fully
52
- // qualified npm:/jsr: specifiers, so they resolve without our config.
53
- const userSuppliedConfig = denoPassthrough.some((a) =>
54
- a === "--config" || a === "-c" ||
55
- a.startsWith("--config=") || a.startsWith("-c=")
56
- );
57
-
58
- // Managed lockfile
59
- // ----------------
60
- // A lockfile is what makes a big functions project boot fast: without one
61
- // (`--no-lock`) Deno re-solves the entire npm/jsr version graph from
62
- // scratch on every start, and a 100-function project pays that as a
63
- // multi-second floor every boot. People reach for `--no-lock` to dodge
64
- // "the lock went stale when I bumped a dep" pain — but in dev Deno's
65
- // lockfile is ADDITIVE and non-frozen: a changed dependency is appended
66
- // without error. So the right default is a lockfile we own, not none.
67
- //
68
- // We point `--lock` at `.1tube/deno.lock` — a gateway-owned scratch path
69
- // (already gitignored alongside `.1tube/logs.db`) so it never adds git
70
- // churn and never collides with a project's own committed `deno.lock`.
71
- // We never pass `--frozen`, so dep changes self-heal. `1TUBE_REFRESH_LOCK=1`
72
- // drops the managed lock for a clean rebuild. Any explicit `--lock`,
73
- // `--no-lock`, or `--frozen` the user passes wins — we don't second-guess.
74
- const managedLock = resolveManagedLock(denoPassthrough);
75
-
76
- const denoArgs = ["run", "--quiet", "-A"];
77
- if (!userSuppliedConfig) denoArgs.push("--config", denoConfig);
78
- if (managedLock) denoArgs.push("--lock", managedLock);
79
- denoArgs.push(...denoPassthrough, cliEntrypoint, ...forwardedTubeArgs);
80
-
81
- // Deno 2.9 enables a 24h "minimum dependency age" by default, which makes
82
- // a function that pins a freshly published npm version fail to resolve in
83
- // local dev ("…newer than the specified minimum dependency date"). This
84
- // setting cannot be changed from inside the process (Deno snapshots npm
85
- // config at startup), so the launcher is the place to neutralize it.
86
- //
87
- // NPM_CONFIG_MIN_RELEASE_AGE is the LOWEST explicit tier in Deno's
88
- // precedence chain (CLI flag > deno.json > .npmrc > this env var > the
89
- // 24h built-in default). Setting it to 0 cancels ONLY the built-in
90
- // default — any .npmrc `min-release-age`, deno.json `minimumDependencyAge`,
91
- // or `--minimum-dependency-age` the project sets still wins. We never
92
- // override a value the user already exported.
93
- const childEnv = { ...process.env };
94
- if (
95
- childEnv.NPM_CONFIG_MIN_RELEASE_AGE === undefined &&
96
- childEnv.npm_config_min_release_age === undefined
97
- ) {
98
- childEnv.NPM_CONFIG_MIN_RELEASE_AGE = "0";
99
- }
100
- // Let the gateway print which lockfile is in effect (it can't read its own
101
- // process's `--lock` flag back out of Deno).
102
- if (managedLock) childEnv.ONETUBE_LOCK = managedLock;
103
-
104
43
  // Resolve the real deno binary up front. Node ≥18.20 refuses to spawn a
105
- // `.cmd`/`.bat` shim without a shell, but spawning a real `.exe` (or a
106
- // bare binary on POSIX) with `shell:false` lets Node escape arguments
107
- // properly — important now that we forward arbitrary deno flags, some of
108
- // which carry paths with spaces (`--config "C:\My Project\deno.json"`).
109
- // We only fall back to a shell when we must (a `.cmd`/`.bat` shim, or we
110
- // couldn't locate the binary on a Windows PATH at all).
44
+ // `.cmd`/`.bat` shim without a shell, but spawning a real `.exe` (or a bare
45
+ // binary on POSIX) with `shell:false` lets Node escape arguments properly —
46
+ // important since we forward arbitrary flags, some carrying paths with spaces.
111
47
  const resolvedDeno = resolveExecutable(denoBin);
112
48
  const needsShell = resolvedDeno
113
49
  ? /\.(cmd|bat)$/i.test(resolvedDeno)
@@ -116,26 +52,17 @@ const spawnBin = resolvedDeno ?? denoBin;
116
52
 
117
53
  let child;
118
54
  if (needsShell) {
119
- // A `.cmd`/`.bat` shim (the npm-installed deno) — or an unresolved name
120
- // on Windows — must go through a shell. We pass a single, manually
121
- // quoted command string instead of an args array so that (a) paths
122
- // with spaces survive and (b) we sidestep Node's DEP0190 "args are not
123
- // escaped" deprecation, which fires only for the shell-plus-array form.
124
55
  const command = [spawnBin, ...denoArgs].map(quoteForCmd).join(" ");
125
56
  child = spawn(command, {
126
57
  stdio: "inherit",
127
58
  shell: true,
128
59
  windowsHide: false,
129
- env: childEnv,
130
60
  });
131
61
  } else {
132
- // Real executable (deno.exe / a POSIX binary): no shell, so Node
133
- // escapes each argument for us.
134
62
  child = spawn(spawnBin, denoArgs, {
135
63
  stdio: "inherit",
136
64
  shell: false,
137
65
  windowsHide: false,
138
- env: childEnv,
139
66
  });
140
67
  }
141
68
 
@@ -164,64 +91,10 @@ child.on("exit", (code, signal) => {
164
91
  process.exit(code ?? 1);
165
92
  });
166
93
 
167
- /**
168
- * Decide whether to inject a 1tube-managed lockfile, and where.
169
- *
170
- * Returns the absolute lock path to pass to `--lock`, or null when the
171
- * caller already made an explicit locking choice (`--lock`, `--no-lock`,
172
- * or `--frozen`) — in which case we stay out of the way entirely.
173
- *
174
- * The managed lock lives at `<cwd>/.1tube/deno.lock`: a scratch path that
175
- * 1tube already owns and gitignores, so enabling it adds no git churn and
176
- * never clashes with a project's own committed `deno.lock`.
177
- *
178
- * @param {string[]} passthrough Raw deno flags being forwarded to `deno run`.
179
- * @returns {string | null}
180
- */
181
- function resolveManagedLock(passthrough) {
182
- const explicit = passthrough.some((a) =>
183
- a === "--lock" || a.startsWith("--lock=") ||
184
- a === "--no-lock" || a.startsWith("--no-lock=") ||
185
- a === "--frozen" || a.startsWith("--frozen=")
186
- );
187
- if (explicit) return null;
188
-
189
- const dir = join(process.cwd(), ".1tube");
190
- const lockPath = join(dir, "deno.lock");
191
- try {
192
- mkdirSync(dir, { recursive: true });
193
- } catch {
194
- // If we can't create the scratch dir, fall back to no managed lock
195
- // rather than crash the launcher.
196
- return null;
197
- }
198
-
199
- // `1TUBE_REFRESH_LOCK=1` forces a clean rebuild by dropping the managed
200
- // lock; Deno then re-derives it from the current deps on this boot.
201
- if (truthy(process.env.ONETUBE_REFRESH_LOCK ?? process.env["1TUBE_REFRESH_LOCK"])) {
202
- try {
203
- rmSync(lockPath, { force: true });
204
- } catch {
205
- // Best-effort.
206
- }
207
- }
208
- return lockPath;
209
- }
210
-
211
- /**
212
- * Loose truthiness for env-var flags: "1", "true", "yes", "on" (any case).
213
- *
214
- * @param {string | undefined} v
215
- * @returns {boolean}
216
- */
217
- function truthy(v) {
218
- return v !== undefined && /^(1|true|yes|on)$/i.test(v.trim());
219
- }
220
-
221
94
  /**
222
95
  * Split a flag string into argv tokens, honoring single/double quotes so
223
- * values with spaces (`--env-file="my env.env"`) survive. Good enough for
224
- * a dev-time convenience var — not a full shell parser.
96
+ * values with spaces (`--env-file="my env.env"`) survive. Good enough for a
97
+ * dev-time convenience var — not a full shell parser.
225
98
  *
226
99
  * @param {string} s
227
100
  * @returns {string[]}
@@ -238,14 +111,13 @@ function tokenizeArgs(s) {
238
111
 
239
112
  /**
240
113
  * Locate an executable on PATH (honoring PATHEXT on Windows). Returns an
241
- * absolute path, or null when the name can't be resolved — in which case
242
- * the caller hands the bare name to spawn and lets the OS/shell try.
114
+ * absolute path, or null when the name can't be resolved — in which case the
115
+ * caller hands the bare name to spawn and lets the OS/shell try.
243
116
  *
244
117
  * @param {string} bin
245
118
  * @returns {string | null}
246
119
  */
247
120
  function resolveExecutable(bin) {
248
- // An explicit path (absolute or containing a separator) is used as-is.
249
121
  if (isAbsolute(bin) || bin.includes("/") || bin.includes("\\")) {
250
122
  return existsSync(bin) ? bin : null;
251
123
  }
@@ -264,9 +136,9 @@ function resolveExecutable(bin) {
264
136
  }
265
137
 
266
138
  /**
267
- * Quote a single argument for cmd.exe. Leaves simple tokens untouched to
268
- * avoid over-quoting; wraps anything with whitespace or shell
269
- * metacharacters in double quotes, doubling any embedded quotes.
139
+ * Quote a single argument for cmd.exe. Leaves simple tokens untouched to avoid
140
+ * over-quoting; wraps anything with whitespace or shell metacharacters in
141
+ * double quotes, doubling any embedded quotes.
270
142
  *
271
143
  * @param {string} arg
272
144
  * @returns {string}
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Dependency pinning for the Deno edge runtime.
3
+ *
4
+ * The problem this solves
5
+ * =======================
6
+ * 1tube's edge functions run under Deno with `--node-modules-dir=false`, so
7
+ * Deno resolves npm specifiers from its OWN cache + lockfile, NOT from the
8
+ * host project's `node_modules`. The import map a Supabase-style project ships
9
+ * commonly maps 1tube's surface with an *unconstrained* specifier:
10
+ *
11
+ * "1tube/edge": "npm:1tube/edge" // === npm:1tube@*
12
+ *
13
+ * `@*` is satisfied by ANY version. So once the lockfile pins, say,
14
+ * `1tube@0.1.43`, bumping the npm dependency (and `node_modules/1tube`) to
15
+ * `0.1.46` changes nothing for the edge runtime: the old pin still satisfies
16
+ * `*`, Deno never re-resolves, and the gateway (loaded from `node_modules`,
17
+ * now 0.1.46) ends up serving a *different* version of the edge surface than
18
+ * the functions import (still 0.1.43) — e.g. a missing export at boot.
19
+ *
20
+ * Deno's native lockfile auto-update is correct and fast, but it only triggers
21
+ * when a specifier's *version requirement* actually changes. Unconstrained
22
+ * specifiers never change, so they "stick" forever.
23
+ *
24
+ * The fix
25
+ * =======
26
+ * Before launching the gateway, rewrite every unconstrained `npm:` specifier
27
+ * in the import map to the EXACT version installed in the host's
28
+ * `node_modules` (the single source of truth the user controls via their
29
+ * package manager). `npm:1tube/edge` → `npm:1tube@0.1.46/edge`. Now the
30
+ * specifier version tracks the installed dependency, so bumping the dep makes
31
+ * Deno's native lock auto-update recompute that one entry — while every
32
+ * already-pinned dependency is left untouched, preserving lockfile
33
+ * reproducibility and warm-boot performance.
34
+ *
35
+ * This module is the pure, IO-free core (parsing + reconciliation) so it can
36
+ * be unit-tested; the file IO + process spawn live in `src/launch.ts`.
37
+ */
38
+
39
+ /** A parsed `npm:` import-map specifier. */
40
+ export interface NpmSpecifier {
41
+ /** Package name, including scope (e.g. `1tube`, `@supabase/server`). */
42
+ name: string;
43
+ /**
44
+ * Version requirement as written (e.g. `0.1.46`, `^1`, `4.4.3`), or
45
+ * `undefined` when the specifier is unconstrained (`npm:name` / `@*`).
46
+ */
47
+ version?: string;
48
+ /** Subpath including the leading slash (e.g. `/edge`), or `""`. */
49
+ subpath: string;
50
+ }
51
+
52
+ // `npm:` body grammar: (@scope/)?name (@version)? (/subpath)?
53
+ // group 1 — name: scoped `@scope/name` or bare `name` (no `@` or `/` inside)
54
+ // group 2 — `@version` (everything up to the next `/`)
55
+ // group 3 — `/subpath`
56
+ const NPM_BODY_RE = /^(@[^/@]+\/[^/@]+|[^/@]+)(@[^/]+)?(\/.*)?$/;
57
+
58
+ /**
59
+ * Parse an import-map value into its npm parts, or `null` when it is not an
60
+ * `npm:` specifier (e.g. `jsr:`, `https:`, `./local.ts`).
61
+ */
62
+ export function parseNpmSpecifier(value: string): NpmSpecifier | null {
63
+ if (!value.startsWith("npm:")) return null;
64
+ const body = value.slice(4);
65
+ const m = NPM_BODY_RE.exec(body);
66
+ if (!m) return null;
67
+ return {
68
+ name: m[1],
69
+ version: m[2] ? m[2].slice(1) : undefined,
70
+ subpath: m[3] ?? "",
71
+ };
72
+ }
73
+
74
+ /** A single applied pin, for human-facing logging. */
75
+ export interface AppliedPin {
76
+ name: string;
77
+ version: string;
78
+ }
79
+
80
+ export interface ReconcileResult {
81
+ /** The rewritten import map (a new object; input is not mutated). */
82
+ imports: Record<string, string>;
83
+ /** Distinct packages that were pinned, in first-seen order. */
84
+ pins: AppliedPin[];
85
+ /** True when at least one entry was rewritten. */
86
+ changed: boolean;
87
+ }
88
+
89
+ /**
90
+ * Rewrite every UNCONSTRAINED `npm:` specifier to the installed version
91
+ * reported by `resolveVersion`. Constrained specifiers (those that already
92
+ * carry a version/range) are left exactly as-is — Deno's native lock handling
93
+ * already tracks those correctly. Non-npm specifiers pass through untouched.
94
+ *
95
+ * `resolveVersion` returns `undefined` when the package is not installed (or
96
+ * has no readable version), in which case the specifier is left unchanged
97
+ * rather than guessed.
98
+ */
99
+ export function reconcileImports(
100
+ imports: Record<string, string>,
101
+ resolveVersion: (pkg: string) => string | undefined,
102
+ ): ReconcileResult {
103
+ const out: Record<string, string> = {};
104
+ const pins: AppliedPin[] = [];
105
+ const seen = new Set<string>();
106
+ let changed = false;
107
+
108
+ for (const [key, value] of Object.entries(imports)) {
109
+ const spec = parseNpmSpecifier(value);
110
+ if (spec && spec.version === undefined) {
111
+ const version = resolveVersion(spec.name);
112
+ if (version) {
113
+ out[key] = `npm:${spec.name}@${version}${spec.subpath}`;
114
+ changed = true;
115
+ if (!seen.has(spec.name)) {
116
+ seen.add(spec.name);
117
+ pins.push({ name: spec.name, version });
118
+ }
119
+ continue;
120
+ }
121
+ }
122
+ out[key] = value;
123
+ }
124
+
125
+ return { imports: out, pins, changed };
126
+ }
package/src/cli.ts CHANGED
@@ -1,51 +1,12 @@
1
1
  /**
2
- * 1tube CLI entry point.
2
+ * 1tube CLI entry point (compatibility alias).
3
3
  *
4
- * Dispatches between the lightweight subcommands (`build`, `--help`,
5
- * `--version`) and the default `serve` path (which loads the full
6
- * gateway machinery via dynamic import).
7
- *
8
- * Subcommand modules are imported lazily so a CI box running
9
- * `1tube build` never pays the gateway's static-import cost (Hono,
10
- * registry, supervisor, every middleware, …) — and conversely, the
11
- * serve hot path doesn't drag in esbuild's typings just because the
12
- * build command exists.
13
- *
14
- * `Deno.args` is readonly so we cannot strip a leading `serve` token
15
- * to forward only the tail. Instead, the gateway's `parseArgs` already
16
- * silently ignores unknown positional tokens — so a literal `serve`
17
- * passed in the args list just no-ops, which is exactly what we want.
4
+ * The real dispatcher + serve orchestrator lives in `./launch.ts` — this file
5
+ * exists only so existing references to `src/cli.ts` (downstream CI, the npm
6
+ * bin's historical entrypoint, docs) keep working. There is a single launcher
7
+ * implementation; this just forwards to it.
18
8
  */
19
9
 
20
- const arg0 = Deno.args[0];
21
-
22
- if (arg0 === "build") {
23
- const { runBuild } = await import("./cli/build.ts");
24
- const code = await runBuild(Deno.args.slice(1));
25
- Deno.exit(code);
26
- } else if (arg0 === "package") {
27
- const { runPackage } = await import("./cli/package.ts");
28
- const code = await runPackage(Deno.args.slice(1));
29
- Deno.exit(code);
30
- } else if (arg0 === "--version" || arg0 === "-v") {
31
- const { VERSION } = await import("./version.ts");
32
- console.log(VERSION);
33
- Deno.exit(0);
34
- } else if (arg0 === "--help" || arg0 === "-h") {
35
- console.log(`Usage: 1tube <command> [options]
36
-
37
- Commands:
38
- serve Run the gateway (default when no command given)
39
- build Bundle functions for a target (--target workerd|vercel)
40
- package Build and/or wrap dist/ into a signed .1tube firmware payload
41
- --version Print version and exit
42
- --help Print this help
10
+ import { run } from "./launch.ts";
43
11
 
44
- Run \`1tube <command> --help\` for command-specific options.
45
- `);
46
- Deno.exit(0);
47
- } else {
48
- // Default path — load the gateway. Dynamic import so the heavy
49
- // graph is only resolved after the dispatch decision above.
50
- await import("./server.ts");
51
- }
12
+ await run(Deno.args);
package/src/launch.ts ADDED
@@ -0,0 +1,677 @@
1
+ /**
2
+ * 1tube launcher — the single entry point for running and building 1tube.
3
+ *
4
+ * This is the one and only launcher: the npm `1tube` bin, the repo's deno
5
+ * tasks, and host projects all funnel through here. There is no second
6
+ * "in-process" serve path — `serve` always spawns the gateway as a child
7
+ * `deno run`, which is what lets the launcher reconcile the lockfile with the
8
+ * host's installed dependencies BEFORE the runtime resolves any npm specifier,
9
+ * and fully reload it when a dependency changes while running.
10
+ *
11
+ * Dispatch
12
+ * ========
13
+ * build → cli/build.ts (bundle for workerd/vercel)
14
+ * package → cli/package.ts (sign a firmware payload)
15
+ * --version → print version
16
+ * --help → usage
17
+ * <else> → serve (the gateway) ← default, may be spelled `serve`
18
+ *
19
+ * Why a spawning launcher (the dependency story)
20
+ * ==============================================
21
+ * The edge runtime uses `--node-modules-dir=false`, so Deno resolves npm
22
+ * specifiers from its own cache + lockfile, never from `node_modules`. A host
23
+ * import map commonly maps 1tube with an UNCONSTRAINED specifier
24
+ * (`npm:1tube/edge`, i.e. `@*`), and `@*` is satisfied by ANY version — so
25
+ * once the lock pins, say, `1tube@0.1.43`, bumping the dependency in
26
+ * `package.json` + `node_modules` silently changes nothing for the edge
27
+ * runtime, and the gateway (loaded from `node_modules`) can end up a different
28
+ * version than the edge surface its functions import.
29
+ *
30
+ * The launcher fixes this by rewriting every unconstrained `npm:` specifier to
31
+ * the EXACT version installed in `node_modules` (the source of truth the user
32
+ * controls via their package manager) and pointing the gateway at the
33
+ * resulting generated config. Because the specifier's version now tracks the
34
+ * installed dependency, Deno's NATIVE lockfile auto-update recomputes just
35
+ * that entry on the next run — every already-pinned dependency is left alone,
36
+ * so the lock keeps its reproducibility and warm-boot speed.
37
+ *
38
+ * Live reload
39
+ * ===========
40
+ * While serving, the launcher watches the host's deno config and the
41
+ * `package.json` of every pinned dependency. When an install or a config edit
42
+ * changes the effective dependency set, it prints a notice and fully reloads
43
+ * the gateway (kill + respawn) so Deno re-resolves against the new versions.
44
+ */
45
+
46
+ import { dirname, isAbsolute, join, resolve } from "node:path";
47
+ import { fileURLToPath, pathToFileURL } from "node:url";
48
+ import { type AppliedPin, reconcileImports } from "./cli/dep-pin.ts";
49
+
50
+ // ---------------------------------------------------------------------------
51
+ // Top-level dispatch
52
+ // ---------------------------------------------------------------------------
53
+
54
+ export async function run(argv: readonly string[]): Promise<void> {
55
+ const arg0 = argv[0];
56
+ if (arg0 === "build") {
57
+ const { runBuild } = await import("./cli/build.ts");
58
+ Deno.exit(await runBuild(argv.slice(1)));
59
+ }
60
+ if (arg0 === "package") {
61
+ const { runPackage } = await import("./cli/package.ts");
62
+ Deno.exit(await runPackage(argv.slice(1)));
63
+ }
64
+ if (arg0 === "--version" || arg0 === "-v") {
65
+ const { VERSION } = await import("./version.ts");
66
+ console.log(VERSION);
67
+ Deno.exit(0);
68
+ }
69
+ if (arg0 === "--help" || arg0 === "-h") {
70
+ printHelp();
71
+ Deno.exit(0);
72
+ }
73
+ // Default: serve. A literal `serve` token is accepted and dropped.
74
+ const serveArgv = arg0 === "serve" ? argv.slice(1) : argv;
75
+ await runServe(parseServeArgs(serveArgv));
76
+ }
77
+
78
+ function printHelp(): void {
79
+ console.log(`Usage: 1tube <command> [options]
80
+
81
+ Commands:
82
+ serve Run the gateway (default when no command given)
83
+ build Bundle functions for a target (--target workerd|vercel)
84
+ package Build and/or wrap dist/ into a signed .1tube firmware payload
85
+ --version Print version and exit
86
+ --help Print this help
87
+
88
+ Serve options (everything else is forwarded to the gateway):
89
+ --lock <path> Managed lockfile (default <cwd>/.1tube/deno.lock)
90
+ --no-lock Run without a lockfile
91
+ --refresh-lock Drop the managed lock for a clean re-resolve
92
+ --config <path> Host deno.json[c] (its import map is reconciled)
93
+ --env-file[=<path>] Env file forwarded to the gateway (repeatable)
94
+ --minimum-dependency-age <n> Forwarded to the gateway's deno run
95
+ --node-modules-dir <v> Forwarded to the gateway (default false)
96
+ --no-pin Don't pin unconstrained npm specifiers
97
+ --no-dep-watch Don't live-reload on dependency changes
98
+ `);
99
+ }
100
+
101
+ // ---------------------------------------------------------------------------
102
+ // Serve argument parsing
103
+ // ---------------------------------------------------------------------------
104
+
105
+ interface ServeArgs {
106
+ lock?: string;
107
+ noLock: boolean;
108
+ refreshLock: boolean;
109
+ config?: string;
110
+ envFiles: string[];
111
+ minimumDependencyAge?: string;
112
+ nodeModulesDir: string;
113
+ serverOverride?: string;
114
+ pin: boolean;
115
+ depWatch: boolean;
116
+ forwarded: string[];
117
+ }
118
+
119
+ const VALUE_FLAGS = new Set([
120
+ "--lock",
121
+ "--config",
122
+ "--env-file",
123
+ "--minimum-dependency-age",
124
+ "--node-modules-dir",
125
+ "--server",
126
+ ]);
127
+
128
+ function parseServeArgs(argv: readonly string[]): ServeArgs {
129
+ const out: ServeArgs = {
130
+ noLock: false,
131
+ refreshLock: false,
132
+ envFiles: [],
133
+ nodeModulesDir: "false",
134
+ pin: true,
135
+ depWatch: true,
136
+ forwarded: [],
137
+ };
138
+ let passthroughOnly = false;
139
+ for (let i = 0; i < argv.length; i++) {
140
+ const tok = argv[i];
141
+ if (passthroughOnly) {
142
+ out.forwarded.push(tok);
143
+ continue;
144
+ }
145
+ if (tok === "--") {
146
+ passthroughOnly = true;
147
+ continue;
148
+ }
149
+ if (tok === "--no-lock") {
150
+ out.noLock = true;
151
+ continue;
152
+ }
153
+ if (tok === "--refresh-lock") {
154
+ out.refreshLock = true;
155
+ continue;
156
+ }
157
+ if (tok === "--no-pin") {
158
+ out.pin = false;
159
+ continue;
160
+ }
161
+ if (tok === "--no-dep-watch") {
162
+ out.depWatch = false;
163
+ continue;
164
+ }
165
+ const eq = tok.indexOf("=");
166
+ const flag = tok.startsWith("--") && eq !== -1 ? tok.slice(0, eq) : tok;
167
+ if (VALUE_FLAGS.has(flag)) {
168
+ const value = eq !== -1 ? tok.slice(eq + 1) : argv[++i];
169
+ if (value === undefined) continue;
170
+ switch (flag) {
171
+ case "--lock":
172
+ out.lock = value;
173
+ break;
174
+ case "--config":
175
+ out.config = value;
176
+ break;
177
+ case "--env-file":
178
+ out.envFiles.push(value);
179
+ break;
180
+ case "--minimum-dependency-age":
181
+ out.minimumDependencyAge = value;
182
+ break;
183
+ case "--node-modules-dir":
184
+ out.nodeModulesDir = value;
185
+ break;
186
+ case "--server":
187
+ out.serverOverride = value;
188
+ break;
189
+ }
190
+ continue;
191
+ }
192
+ out.forwarded.push(tok);
193
+ }
194
+ return out;
195
+ }
196
+
197
+ // ---------------------------------------------------------------------------
198
+ // JSONC (deno.jsonc) tolerant parse — mirrors the gateway's own stripper.
199
+ // ---------------------------------------------------------------------------
200
+
201
+ function stripJsonc(text: string): string {
202
+ let out = "";
203
+ let inString = false;
204
+ let quote = "";
205
+ let escaped = false;
206
+ for (let i = 0; i < text.length; i++) {
207
+ const ch = text[i];
208
+ const next = text[i + 1];
209
+ if (inString) {
210
+ out += ch;
211
+ if (escaped) escaped = false;
212
+ else if (ch === "\\") escaped = true;
213
+ else if (ch === quote) inString = false;
214
+ continue;
215
+ }
216
+ if (ch === '"' || ch === "'") {
217
+ inString = true;
218
+ quote = ch;
219
+ out += ch;
220
+ continue;
221
+ }
222
+ if (ch === "/" && next === "/") {
223
+ while (i < text.length && text[i] !== "\n") i++;
224
+ out += "\n";
225
+ continue;
226
+ }
227
+ if (ch === "/" && next === "*") {
228
+ i += 2;
229
+ while (i < text.length && !(text[i] === "*" && text[i + 1] === "/")) i++;
230
+ i++;
231
+ continue;
232
+ }
233
+ out += ch;
234
+ }
235
+ return out.replace(/,\s*([}\]])/g, "$1");
236
+ }
237
+
238
+ // ---------------------------------------------------------------------------
239
+ // Installed-version resolution (the package manager is the source of truth)
240
+ // ---------------------------------------------------------------------------
241
+
242
+ /**
243
+ * Locate the `package.json` of `pkg` in the nearest `node_modules`, walking up
244
+ * from each start dir. Returns the path so the watcher can subscribe to it.
245
+ */
246
+ function findInstalledPackageJson(
247
+ pkg: string,
248
+ startDirs: readonly string[],
249
+ ): string | undefined {
250
+ const segments = pkg.split("/");
251
+ const tried = new Set<string>();
252
+ for (const start of startDirs) {
253
+ let dir = start;
254
+ while (true) {
255
+ const pkgJson = join(dir, "node_modules", ...segments, "package.json");
256
+ if (!tried.has(pkgJson)) {
257
+ tried.add(pkgJson);
258
+ try {
259
+ if (Deno.statSync(pkgJson).isFile) return pkgJson;
260
+ } catch {
261
+ // not here — keep walking up
262
+ }
263
+ }
264
+ const parent = dirname(dir);
265
+ if (parent === dir) break;
266
+ dir = parent;
267
+ }
268
+ }
269
+ return undefined;
270
+ }
271
+
272
+ function readPackageVersion(pkgJsonPath: string): string | undefined {
273
+ try {
274
+ const version = JSON.parse(Deno.readTextFileSync(pkgJsonPath))?.version;
275
+ if (typeof version === "string" && /^[0-9]/.test(version)) return version;
276
+ } catch {
277
+ // unreadable / non-standard version — leave unpinned
278
+ }
279
+ return undefined;
280
+ }
281
+
282
+ // ---------------------------------------------------------------------------
283
+ // Config reconciliation
284
+ // ---------------------------------------------------------------------------
285
+
286
+ function absolutizeTarget(value: string, baseFileUrl: URL): string {
287
+ if (value.startsWith("./") || value.startsWith("../")) {
288
+ return new URL(value, baseFileUrl).href;
289
+ }
290
+ return value;
291
+ }
292
+
293
+ interface ReconciledConfig {
294
+ /** Absolute path the inner runtime should load as `--config`. */
295
+ configPath: string;
296
+ /**
297
+ * The exact bytes the gateway will resolve against — the reload
298
+ * fingerprint. For the generated config this is its content; with no
299
+ * reconciliation needed it is the user config's own bytes.
300
+ */
301
+ fingerprint: string;
302
+ /** Packages pinned this pass (for logging). */
303
+ pins: AppliedPin[];
304
+ /** Files to watch for live dependency changes. */
305
+ watchPaths: string[];
306
+ /** Generated config bytes to persist, or null when using the user config. */
307
+ generated: string | null;
308
+ /** Where the generated config should be written. */
309
+ generatedPath: string;
310
+ }
311
+
312
+ /**
313
+ * Read the host deno config, pin unconstrained npm specifiers to the installed
314
+ * versions, and decide which config the gateway should load. Pure compute +
315
+ * reads; the caller persists the generated file.
316
+ */
317
+ function reconcileConfig(
318
+ userConfigPath: string,
319
+ lockDir: string,
320
+ pin: boolean,
321
+ ): ReconciledConfig {
322
+ const userBytes = Deno.readTextFileSync(userConfigPath);
323
+ const parsed = JSON.parse(stripJsonc(userBytes)) as {
324
+ imports?: Record<string, string>;
325
+ scopes?: Record<string, Record<string, string>>;
326
+ [k: string]: unknown;
327
+ };
328
+
329
+ const baseFileUrl = pathToFileURL(userConfigPath);
330
+ const startDirs = [Deno.cwd(), dirname(fileURLToPath(import.meta.url))];
331
+ const watchPaths = new Set<string>([userConfigPath]);
332
+ const resolveVersion = (p: string): string | undefined => {
333
+ const pkgJson = findInstalledPackageJson(p, startDirs);
334
+ if (!pkgJson) return undefined;
335
+ const version = readPackageVersion(pkgJson);
336
+ if (version) watchPaths.add(pkgJson);
337
+ return version;
338
+ };
339
+
340
+ let changed = false;
341
+ const next: typeof parsed = { ...parsed };
342
+ const pins: AppliedPin[] = [];
343
+ const generatedPath = join(lockDir, "deno.gen.json");
344
+
345
+ if (pin && parsed.imports && typeof parsed.imports === "object") {
346
+ const reconciled = reconcileImports(parsed.imports, resolveVersion);
347
+ const withAbs: Record<string, string> = {};
348
+ for (const [k, v] of Object.entries(reconciled.imports)) {
349
+ const abs = absolutizeTarget(v, baseFileUrl);
350
+ if (abs !== v) changed = true;
351
+ withAbs[k] = abs;
352
+ }
353
+ if (reconciled.changed) changed = true;
354
+ pins.push(...reconciled.pins);
355
+ next.imports = withAbs;
356
+ }
357
+
358
+ if (pin && parsed.scopes && typeof parsed.scopes === "object") {
359
+ const outScopes: Record<string, Record<string, string>> = {};
360
+ for (const [scopeKey, map] of Object.entries(parsed.scopes)) {
361
+ const r = reconcileImports(map, resolveVersion);
362
+ if (r.changed) changed = true;
363
+ const withAbs: Record<string, string> = {};
364
+ for (const [k, v] of Object.entries(r.imports)) {
365
+ const abs = absolutizeTarget(v, baseFileUrl);
366
+ if (abs !== v) changed = true;
367
+ withAbs[k] = abs;
368
+ }
369
+ const newKey = absolutizeTarget(scopeKey, baseFileUrl);
370
+ if (newKey !== scopeKey) changed = true;
371
+ outScopes[newKey] = withAbs;
372
+ }
373
+ next.scopes = outScopes;
374
+ }
375
+
376
+ if (!changed) {
377
+ // The user's config is already correct — load it directly, watch it.
378
+ return {
379
+ configPath: userConfigPath,
380
+ fingerprint: userBytes,
381
+ pins: [],
382
+ watchPaths: [...watchPaths],
383
+ generated: null,
384
+ generatedPath,
385
+ };
386
+ }
387
+
388
+ const serialized = JSON.stringify(next, null, 2) + "\n";
389
+ return {
390
+ configPath: generatedPath,
391
+ fingerprint: serialized,
392
+ pins,
393
+ watchPaths: [...watchPaths],
394
+ generated: serialized,
395
+ generatedPath,
396
+ };
397
+ }
398
+
399
+ /** Persist the generated config when its content differs from disk. */
400
+ function persistGenerated(rc: ReconciledConfig, lockDir: string): void {
401
+ if (rc.generated === null) return;
402
+ let previous: string | undefined;
403
+ try {
404
+ previous = Deno.readTextFileSync(rc.generatedPath);
405
+ } catch {
406
+ // first run
407
+ }
408
+ if (previous !== rc.generated) {
409
+ try {
410
+ Deno.mkdirSync(lockDir, { recursive: true });
411
+ } catch {
412
+ // dir already exists / not creatable — write will surface a real error
413
+ }
414
+ Deno.writeTextFileSync(rc.generatedPath, rc.generated);
415
+ }
416
+ }
417
+
418
+ // ---------------------------------------------------------------------------
419
+ // Serve orchestrator
420
+ // ---------------------------------------------------------------------------
421
+
422
+ function resolveToAbs(p: string): string {
423
+ return isAbsolute(p) ? p : resolve(Deno.cwd(), p);
424
+ }
425
+
426
+ async function runServe(args: ServeArgs): Promise<never> {
427
+ const serverPath = args.serverOverride
428
+ ? resolveToAbs(args.serverOverride)
429
+ : fileURLToPath(new URL("./server.ts", import.meta.url));
430
+
431
+ // Managed lockfile: default to the gateway-owned scratch path unless the
432
+ // caller opted out. Never frozen, so dependency changes self-heal.
433
+ let lockAbs: string | undefined;
434
+ if (!args.noLock) {
435
+ lockAbs = resolveToAbs(args.lock ?? join(".1tube", "deno.lock"));
436
+ try {
437
+ Deno.mkdirSync(dirname(lockAbs), { recursive: true });
438
+ } catch {
439
+ // best-effort
440
+ }
441
+ if (args.refreshLock) {
442
+ try {
443
+ Deno.removeSync(lockAbs);
444
+ } catch {
445
+ // already absent
446
+ }
447
+ }
448
+ }
449
+ const lockDir = lockAbs ? dirname(lockAbs) : resolveToAbs(".1tube");
450
+
451
+ const userConfigAbs = args.config ? resolveToAbs(args.config) : undefined;
452
+
453
+ // When the host doesn't supply its own import map, fall back to 1tube's
454
+ // bundled deno.json (packageRoot/deno.json). The gateway's own sources use
455
+ // fully-qualified npm:/jsr: specifiers, so this only matters for hosts whose
456
+ // functions resolve bare aliases — and it never gets reconciled (its map is
457
+ // 1tube's own, fully constrained).
458
+ let bundledConfigAbs: string | undefined;
459
+ if (!userConfigAbs) {
460
+ const candidate = fileURLToPath(new URL("../deno.json", import.meta.url));
461
+ try {
462
+ if (Deno.statSync(candidate).isFile) bundledConfigAbs = candidate;
463
+ } catch {
464
+ // no bundled config (unbundled checkout) — run without one
465
+ }
466
+ }
467
+
468
+ // Child env additions (merged onto the inherited environment).
469
+ const childEnv: Record<string, string> = {};
470
+ if (lockAbs) childEnv.ONETUBE_LOCK = lockAbs;
471
+ // Deno 2.9's 24h minimum-dependency-age default makes freshly published
472
+ // pins fail to resolve; it must be set before the gateway's Deno starts.
473
+ if (
474
+ !Deno.env.get("NPM_CONFIG_MIN_RELEASE_AGE") &&
475
+ !Deno.env.get("npm_config_min_release_age")
476
+ ) {
477
+ childEnv.NPM_CONFIG_MIN_RELEASE_AGE = "0";
478
+ }
479
+
480
+ const buildAndPersist = (reason: "boot" | "reload"): ReconciledConfig | undefined => {
481
+ if (!userConfigAbs) return undefined;
482
+ let rc: ReconciledConfig;
483
+ try {
484
+ rc = reconcileConfig(userConfigAbs, lockDir, args.pin);
485
+ } catch (err) {
486
+ console.warn(
487
+ `[1tube] launch: could not reconcile ${userConfigAbs} (${
488
+ err instanceof Error ? err.message : String(err)
489
+ }) — using it as-is`,
490
+ );
491
+ return undefined;
492
+ }
493
+ persistGenerated(rc, lockDir);
494
+ if (rc.pins.length > 0) {
495
+ const summary = rc.pins.map((p) => `${p.name}@${p.version}`).join(", ");
496
+ const verb = reason === "boot" ? "pinned" : "re-pinned";
497
+ console.log(`[1tube] launch: ${verb} npm specifier(s) to installed: ${summary}`);
498
+ }
499
+ return rc;
500
+ };
501
+
502
+ const buildDenoArgs = (configPath: string | undefined): string[] => {
503
+ const denoArgs: string[] = ["run", "-q", "--allow-all"];
504
+ denoArgs.push(`--node-modules-dir=${args.nodeModulesDir}`);
505
+ if (lockAbs) denoArgs.push("--lock", lockAbs);
506
+ if (args.minimumDependencyAge !== undefined) {
507
+ denoArgs.push(`--minimum-dependency-age=${args.minimumDependencyAge}`);
508
+ }
509
+ for (const envFile of args.envFiles) {
510
+ denoArgs.push(`--env-file=${resolveToAbs(envFile)}`);
511
+ }
512
+ if (configPath) denoArgs.push("--config", configPath);
513
+ denoArgs.push(serverPath);
514
+ denoArgs.push(...args.forwarded);
515
+ return denoArgs;
516
+ };
517
+
518
+ // --- supervise loop: spawn the gateway, reload on dependency change ---
519
+
520
+ let stopping = false;
521
+ let currentChild: Deno.ChildProcess | undefined;
522
+
523
+ const stop = (sig: Deno.Signal) => {
524
+ stopping = true;
525
+ try {
526
+ currentChild?.kill(sig);
527
+ } catch {
528
+ // already gone
529
+ }
530
+ };
531
+ const onSigint = () => stop("SIGINT");
532
+ Deno.addSignalListener("SIGINT", onSigint);
533
+ let onSigterm: (() => void) | undefined;
534
+ if (Deno.build.os !== "windows") {
535
+ onSigterm = () => stop("SIGTERM");
536
+ Deno.addSignalListener("SIGTERM", onSigterm);
537
+ }
538
+
539
+ let lastFingerprint: string | undefined;
540
+
541
+ while (!stopping) {
542
+ const rc = buildAndPersist(lastFingerprint === undefined ? "boot" : "reload");
543
+ lastFingerprint = rc?.fingerprint ?? "";
544
+ const configPath = rc?.configPath ?? userConfigAbs ?? bundledConfigAbs;
545
+
546
+ const child = new Deno.Command(Deno.execPath(), {
547
+ args: buildDenoArgs(configPath),
548
+ stdin: "inherit",
549
+ stdout: "inherit",
550
+ stderr: "inherit",
551
+ env: childEnv,
552
+ }).spawn();
553
+ currentChild = child;
554
+
555
+ let reloadRequested = false;
556
+ const watcher = args.depWatch && rc
557
+ ? startDepWatcher(rc.watchPaths, () => {
558
+ // Recompute the effective config; reload only on a real change.
559
+ let nextRc: ReconciledConfig;
560
+ try {
561
+ nextRc = reconcileConfig(userConfigAbs!, lockDir, args.pin);
562
+ } catch {
563
+ return;
564
+ }
565
+ if (nextRc.fingerprint === lastFingerprint) return;
566
+ console.log(
567
+ "[1tube] launch: dependency change detected — reloading gateway",
568
+ );
569
+ reloadRequested = true;
570
+ try {
571
+ child.kill(Deno.build.os === "windows" ? "SIGKILL" : "SIGTERM");
572
+ } catch {
573
+ // already exiting
574
+ }
575
+ })
576
+ : undefined;
577
+
578
+ const status = await child.status;
579
+ currentChild = undefined;
580
+ watcher?.close();
581
+
582
+ if (stopping) {
583
+ cleanupSignals(onSigint, onSigterm);
584
+ Deno.exit(status.code);
585
+ }
586
+ if (reloadRequested) {
587
+ continue; // respawn with the freshly reconciled config
588
+ }
589
+ // The gateway exited on its own — propagate its code.
590
+ cleanupSignals(onSigint, onSigterm);
591
+ Deno.exit(status.code);
592
+ }
593
+
594
+ cleanupSignals(onSigint, onSigterm);
595
+ Deno.exit(0);
596
+ }
597
+
598
+ function cleanupSignals(
599
+ onSigint: () => void,
600
+ onSigterm: (() => void) | undefined,
601
+ ): void {
602
+ try {
603
+ Deno.removeSignalListener("SIGINT", onSigint);
604
+ } catch {
605
+ // ignore
606
+ }
607
+ if (onSigterm) {
608
+ try {
609
+ Deno.removeSignalListener("SIGTERM", onSigterm);
610
+ } catch {
611
+ // ignore
612
+ }
613
+ }
614
+ }
615
+
616
+ // ---------------------------------------------------------------------------
617
+ // Dependency watcher
618
+ // ---------------------------------------------------------------------------
619
+
620
+ interface DepWatcher {
621
+ close(): void;
622
+ }
623
+
624
+ /**
625
+ * Watch the host config + each pinned dependency's package.json (its parent
626
+ * dir, so atomic-rename installs are still observed) and fire `onChange`
627
+ * (debounced) on any filesystem event.
628
+ */
629
+ function startDepWatcher(paths: readonly string[], onChange: () => void): DepWatcher {
630
+ // Watch parent directories: editors and package managers frequently replace
631
+ // files via rename, which drops a file-level watch.
632
+ const dirs = [...new Set(paths.map((p) => dirname(p)))].filter((d) => {
633
+ try {
634
+ return Deno.statSync(d).isDirectory;
635
+ } catch {
636
+ return false;
637
+ }
638
+ });
639
+ if (dirs.length === 0) return { close() {} };
640
+
641
+ const targets = new Set(paths);
642
+ let watcher: Deno.FsWatcher;
643
+ try {
644
+ watcher = Deno.watchFs(dirs, { recursive: false });
645
+ } catch {
646
+ return { close() {} };
647
+ }
648
+
649
+ let timer: ReturnType<typeof setTimeout> | undefined;
650
+ let closed = false;
651
+ (async () => {
652
+ for await (const event of watcher) {
653
+ if (closed) break;
654
+ // Only react to the specific files we care about (the dirs may host
655
+ // many unrelated files, e.g. all of node_modules/<pkg>/).
656
+ if (!event.paths.some((p) => targets.has(p))) continue;
657
+ if (timer !== undefined) clearTimeout(timer);
658
+ timer = setTimeout(onChange, 250);
659
+ }
660
+ })();
661
+
662
+ return {
663
+ close() {
664
+ closed = true;
665
+ if (timer !== undefined) clearTimeout(timer);
666
+ try {
667
+ watcher.close();
668
+ } catch {
669
+ // already closed
670
+ }
671
+ },
672
+ };
673
+ }
674
+
675
+ if (import.meta.main) {
676
+ await run(Deno.args);
677
+ }