1tube 0.1.41 → 0.1.42

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/deno.lock CHANGED
@@ -20,8 +20,8 @@
20
20
  "npm:es-module-lexer@2": "2.1.0",
21
21
  "npm:esbuild@~0.25.5": "0.25.12",
22
22
  "npm:esbuild@~0.28.1": "0.28.1",
23
- "npm:hono@4": "4.12.25",
24
- "npm:hono@^4.12.25": "4.12.25",
23
+ "npm:hono@4": "4.12.27",
24
+ "npm:hono@^4.12.27": "4.12.27",
25
25
  "npm:typescript@latest": "6.0.3",
26
26
  "npm:zod@^3.23.0": "3.25.76"
27
27
  },
@@ -515,8 +515,8 @@
515
515
  "resolve-pkg-maps"
516
516
  ]
517
517
  },
518
- "hono@4.12.25": {
519
- "integrity": "sha512-2NFaIyNVgJmBs/ecmtGzlmluTFs5cHEWGTdu0t1HBwYzoGXOL5nUQBRMXsXWla5i4KkG//QMzVP88m1+I3fdAQ=="
518
+ "hono@4.12.27": {
519
+ "integrity": "sha512-1yrb/+w6HWQJrUCLkJ2IF5jNIPvvFkblV5RNOYl6bV+OA6p9GLcMpHFFGTosSvHvcAUibuUukRqhlYI4z32C7Q=="
520
520
  },
521
521
  "jiti@2.6.1": {
522
522
  "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
@@ -555,7 +555,7 @@
555
555
  "dependencies": [
556
556
  "npm:drizzle-kit@1.0.0-rc.3",
557
557
  "npm:drizzle-orm@1.0.0-rc.3",
558
- "npm:hono@^4.12.25"
558
+ "npm:hono@^4.12.27"
559
559
  ]
560
560
  }
561
561
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "1tube",
3
- "version": "0.1.41",
3
+ "version": "0.1.42",
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",
@@ -62,7 +62,7 @@
62
62
  },
63
63
  "dependencies": {
64
64
  "drizzle-orm": "1.0.0-rc.3",
65
- "hono": "^4.12.25"
65
+ "hono": "^4.12.27"
66
66
  },
67
67
  "devDependencies": {
68
68
  "drizzle-kit": "1.0.0-rc.3"
@@ -63,8 +63,20 @@ export interface DenoWorkerHostOptions {
63
63
  supervisor: FunctionSupervisor;
64
64
  /** Debounce window in ms for batched fs change events. Defaults to 200ms. */
65
65
  debounceMs?: number;
66
- /** Max concurrent worker spawns at boot/reload. Defaults to 8. */
66
+ /**
67
+ * Max concurrent worker spawns at boot/reload. Defaults to the host's
68
+ * CPU count clamped to [8, 16] so multi-core dev machines boot a large
69
+ * function set faster, while smaller boxes keep the historical 8.
70
+ */
67
71
  concurrency?: number;
72
+ /**
73
+ * Grace window (ms) granted to a superseded worker on HMR reload so its
74
+ * in-flight requests can finish before it is terminated. The fresh
75
+ * worker already serves new traffic, so the reload never waits on this
76
+ * — it runs in the background. Defaults to 10000; 0 reverts to the
77
+ * legacy "terminate immediately" behaviour (drops in-flight responses).
78
+ */
79
+ reloadDrainMs?: number;
68
80
  /**
69
81
  * Parsed `imports` from the host project's deno.json. Forwarded to the
70
82
  * dep-graph so import-map aliases resolve to the right file URLs. Optional.
@@ -166,6 +178,44 @@ function spawnWorker(opts: SpawnOpts): Promise<InternalHandle> {
166
178
  const pending = new Map<number, PendingRequest>();
167
179
  let nextId = 1;
168
180
  let booted = false;
181
+ let terminated = false;
182
+
183
+ // Graceful-drain plumbing. `drainWaiters` are notified whenever a
184
+ // pending request settles (so a `terminate({ drainMs })` can finish
185
+ // the moment the worker goes idle) AND on hard termination (so an
186
+ // in-flight drain resolves instead of leaking timers). `drainTimers`
187
+ // tracks the drain's own timers so hard termination can cancel them.
188
+ const drainWaiters = new Set<() => void>();
189
+ const drainTimers = new Set<number>();
190
+
191
+ /** Fire drain waiters once the worker has no in-flight requests left. */
192
+ const notifyDrained = () => {
193
+ if (pending.size !== 0 || drainWaiters.size === 0) return;
194
+ for (const w of [...drainWaiters]) w();
195
+ };
196
+
197
+ /**
198
+ * Immediate teardown: kill the worker, reject everything still
199
+ * pending, and release any drain waiters. Idempotent — safe to call
200
+ * from the crash handler, a drain timeout, or host.stop() racing an
201
+ * in-flight drain.
202
+ */
203
+ const hardTerminate = () => {
204
+ if (terminated) return;
205
+ terminated = true;
206
+ try {
207
+ worker.terminate();
208
+ } catch { /* already dead */ }
209
+ for (const p of pending.values()) {
210
+ p.reject(new Error(`Worker for "${opts.name}" terminated`));
211
+ }
212
+ pending.clear();
213
+ for (const t of drainTimers) clearTimeout(t);
214
+ drainTimers.clear();
215
+ const waiters = [...drainWaiters];
216
+ drainWaiters.clear();
217
+ for (const w of waiters) w();
218
+ };
169
219
 
170
220
  const finishBoot = (handle: InternalHandle) => {
171
221
  booted = true;
@@ -190,9 +240,13 @@ function spawnWorker(opts: SpawnOpts): Promise<InternalHandle> {
190
240
  new Error(`Worker for "${opts.name}" failed to start: ${message}`),
191
241
  );
192
242
  } else {
193
- // Drain any in-flight callers — the worker is dead.
243
+ // Crashed after boot — reject in-flight callers with the real
244
+ // error, then mark the worker terminated so later dispatches
245
+ // fast-fail and any graceful drain in progress resolves instead
246
+ // of hanging on a dead worker.
194
247
  for (const p of pending.values()) p.reject(new Error(message));
195
248
  pending.clear();
249
+ hardTerminate();
196
250
  }
197
251
  e.preventDefault?.();
198
252
  };
@@ -216,6 +270,13 @@ function spawnWorker(opts: SpawnOpts): Promise<InternalHandle> {
216
270
  if (signal.aborted) {
217
271
  throw new DOMException("Aborted", "AbortError");
218
272
  }
273
+ if (terminated) {
274
+ // Worker already torn down (HMR drain grace expired, a
275
+ // crash, or shutdown). Fail fast with a clear signal rather
276
+ // than posting into a dead worker and stalling until the
277
+ // request timeout fires.
278
+ throw new Error(`Worker for "${opts.name}" terminated`);
279
+ }
219
280
  const id = nextId++;
220
281
  const headers: Array<[string, string]> = [];
221
282
  req.headers.forEach((v, k) => headers.push([k, v]));
@@ -231,6 +292,7 @@ function spawnWorker(opts: SpawnOpts): Promise<InternalHandle> {
231
292
  if (p) {
232
293
  pending.delete(id);
233
294
  p.reject(new DOMException("Aborted", "AbortError"));
295
+ notifyDrained();
234
296
  }
235
297
  };
236
298
  signal.addEventListener("abort", onAbort, { once: true });
@@ -251,6 +313,7 @@ function spawnWorker(opts: SpawnOpts): Promise<InternalHandle> {
251
313
  } catch (err) {
252
314
  pending.delete(id);
253
315
  signal.removeEventListener("abort", onAbort);
316
+ notifyDrained();
254
317
  throw err;
255
318
  }
256
319
  try {
@@ -259,15 +322,56 @@ function spawnWorker(opts: SpawnOpts): Promise<InternalHandle> {
259
322
  signal.removeEventListener("abort", onAbort);
260
323
  }
261
324
  },
262
- terminate() {
263
- try {
264
- worker.terminate();
265
- } catch { /* already dead */ }
266
- for (const p of pending.values()) {
267
- p.reject(new Error(`Worker for "${opts.name}" terminated`));
325
+ terminate(termOpts) {
326
+ const drainMs = termOpts?.drainMs ?? 0;
327
+ if (terminated) return Promise.resolve();
328
+ // No grace requested → tear down immediately.
329
+ if (drainMs <= 0) {
330
+ hardTerminate();
331
+ return Promise.resolve();
268
332
  }
269
- pending.clear();
270
- return Promise.resolve();
333
+ // Graceful handoff: keep serving the requests already in
334
+ // flight, then kill the worker once it goes idle (plus a
335
+ // short linger so a just-posted response body finishes
336
+ // flushing to the host) or the grace window expires —
337
+ // whichever comes first.
338
+ const lingerMs = Math.min(500, drainMs);
339
+ return new Promise<void>((resolveDrain) => {
340
+ let settled = false;
341
+ let lingerTimer: number | undefined;
342
+ const finish = () => {
343
+ if (settled) return;
344
+ settled = true;
345
+ drainWaiters.delete(waiter);
346
+ clearTimeout(hardCap);
347
+ drainTimers.delete(hardCap);
348
+ if (lingerTimer !== undefined) {
349
+ clearTimeout(lingerTimer);
350
+ drainTimers.delete(lingerTimer);
351
+ }
352
+ hardTerminate();
353
+ resolveDrain();
354
+ };
355
+ const armLinger = () => {
356
+ if (lingerTimer !== undefined || settled) return;
357
+ lingerTimer = setTimeout(finish, lingerMs) as unknown as number;
358
+ drainTimers.add(lingerTimer);
359
+ };
360
+ // Invoked both on each pending-request settle and on hard
361
+ // termination (where `terminated` is already true).
362
+ const waiter = () => {
363
+ if (terminated) {
364
+ finish();
365
+ return;
366
+ }
367
+ if (pending.size === 0) armLinger();
368
+ };
369
+ drainWaiters.add(waiter);
370
+ const hardCap = setTimeout(finish, drainMs) as unknown as number;
371
+ drainTimers.add(hardCap);
372
+ // Nothing in flight at swap time → go straight to linger.
373
+ if (pending.size === 0) armLinger();
374
+ });
271
375
  },
272
376
  };
273
377
  finishBoot(handle);
@@ -292,6 +396,10 @@ function spawnWorker(opts: SpawnOpts): Promise<InternalHandle> {
292
396
  const wantsBody = status >= 200 && status !== 204 && status !== 205 &&
293
397
  status !== 304;
294
398
  p.resolve(new Response(wantsBody ? body : null, { status, headers }));
399
+ // After resolve, never before: a drain may hard-terminate the
400
+ // worker here, and the response body is a stream we just handed
401
+ // to the caller — let it land first.
402
+ notifyDrained();
295
403
  return;
296
404
  }
297
405
 
@@ -303,6 +411,7 @@ function spawnWorker(opts: SpawnOpts): Promise<InternalHandle> {
303
411
  const err = new Error(String(m.message ?? "handler error"));
304
412
  if (m.stack) (err as Error).stack = String(m.stack);
305
413
  p.reject(err);
414
+ notifyDrained();
306
415
  return;
307
416
  }
308
417
 
@@ -412,6 +521,14 @@ export interface SpawnProgress {
412
521
  }
413
522
 
414
523
  export interface StartOptions {
524
+ /**
525
+ * Fired as functions are discovered on disk, BEFORE any worker spawns,
526
+ * with the running count. Drives the live "scanning" line so a project
527
+ * with a slow/large function tree doesn't look hung during the
528
+ * stat + manifest sweep. `phase` distinguishes the directory scan from
529
+ * the (shared-rewrite only) batch dep-graph build that follows it.
530
+ */
531
+ onScanProgress?: (p: { found: number; phase: "scan" | "graph" }) => void;
415
532
  /** Fired before each worker spawn begins. */
416
533
  onSpawnStart?: (p: { index: number; total: number; name: string }) => void;
417
534
  /** Fired after each worker either becomes ready or errors out. */
@@ -502,29 +619,68 @@ interface DiscoveredCandidate {
502
619
  manifest: FunctionManifest;
503
620
  }
504
621
 
622
+ /**
623
+ * Lightweight fs metadata is cheap individually but ruinous in series:
624
+ * a stat + manifest read per function, one after another, is the single
625
+ * biggest "boot looks stuck" contributor on large projects (Windows
626
+ * especially). 64 lanes saturate the filesystem without exhausting
627
+ * handles.
628
+ */
629
+ const DISCOVERY_CONCURRENCY = 64;
630
+
631
+ /** Run `fn` over `items` with at most `limit` in flight at once. */
632
+ async function forEachBounded<T>(
633
+ items: readonly T[],
634
+ limit: number,
635
+ fn: (item: T) => Promise<void>,
636
+ ): Promise<void> {
637
+ let cursor = 0;
638
+ const lanes = Array.from(
639
+ { length: Math.min(limit, items.length) },
640
+ async () => {
641
+ while (true) {
642
+ const i = cursor++;
643
+ if (i >= items.length) return;
644
+ await fn(items[i]);
645
+ }
646
+ },
647
+ );
648
+ await Promise.all(lanes);
649
+ }
650
+
505
651
  async function discoverCandidates(
506
- functionsDir: string,
652
+ resolved: string,
653
+ onFound?: (count: number) => void,
507
654
  ): Promise<DiscoveredCandidate[]> {
508
- const resolved = await Deno.realPath(functionsDir);
509
- const out: DiscoveredCandidate[] = [];
655
+ // Phase 1: cheap directory listing (single syscall stream).
656
+ const names: string[] = [];
510
657
  for await (const entry of Deno.readDir(resolved)) {
511
658
  if (!entry.isDirectory) continue;
512
659
  if (entry.name.startsWith("_") || entry.name.endsWith("_shared")) continue;
513
- const indexPath = join(resolved, entry.name, "index.ts");
660
+ names.push(entry.name);
661
+ }
662
+ // Phase 2: stat + manifest in parallel. Was sequential, which made a
663
+ // big function set crawl one fs round-trip at a time.
664
+ const out: DiscoveredCandidate[] = [];
665
+ let found = 0;
666
+ await forEachBounded(names, DISCOVERY_CONCURRENCY, async (name) => {
667
+ const indexPath = join(resolved, name, "index.ts");
514
668
  try {
515
- await Deno.stat(indexPath);
669
+ const st = await Deno.stat(indexPath);
670
+ if (!st.isFile) return;
516
671
  } catch {
517
- continue;
672
+ return;
518
673
  }
519
- const manifest = await loadManifest(resolved, entry.name).catch(() =>
674
+ const manifest = await loadManifest(resolved, name).catch(() =>
520
675
  defaultManifest()
521
676
  );
522
677
  out.push({
523
- name: entry.name,
678
+ name,
524
679
  entryUrl: pathToFileURL(indexPath).href,
525
680
  manifest,
526
681
  });
527
- }
682
+ onFound?.(++found);
683
+ });
528
684
  out.sort((a, b) => a.name.localeCompare(b.name));
529
685
  return out;
530
686
  }
@@ -536,10 +692,9 @@ async function discoverCandidates(
536
692
  * 100 stat + manifest reads (which are painfully slow on Windows).
537
693
  */
538
694
  async function discoverNamed(
539
- functionsDir: string,
695
+ resolved: string,
540
696
  names: ReadonlySet<string>,
541
697
  ): Promise<DiscoveredCandidate[]> {
542
- const resolved = await Deno.realPath(functionsDir);
543
698
  const out: DiscoveredCandidate[] = [];
544
699
  await Promise.all([...names].map(async (name) => {
545
700
  if (name.startsWith("_") || name.endsWith("_shared")) return;
@@ -576,11 +731,59 @@ export function createDenoWorkerHost(
576
731
  }
577
732
  : undefined,
578
733
  );
579
- const concurrency = Math.max(1, opts.concurrency ?? 8);
734
+ // Scale boot/reload spawn concurrency to the machine: a 32-function
735
+ // project boots far faster with 12-16 lanes on a modern dev box, while
736
+ // small machines keep the historical 8. Deno 2.9's much lower per-
737
+ // worker RSS makes the higher ceiling safe.
738
+ const defaultConcurrency = (() => {
739
+ const hc = Math.floor(globalThis.navigator?.hardwareConcurrency ?? 0);
740
+ return hc > 0 ? Math.max(8, Math.min(hc, 16)) : 8;
741
+ })();
742
+ const concurrency = Math.max(1, opts.concurrency ?? defaultConcurrency);
743
+ const reloadDrainMs = Math.max(0, opts.reloadDrainMs ?? 10_000);
580
744
 
581
745
  const sharedRuntime = opts.sharedRuntime;
582
746
  const rewriteCache = opts.rewriteCache;
583
747
 
748
+ // Canonical functions root, resolved once. Both discovery paths used
749
+ // to `Deno.realPath()` on every boot AND every HMR reload — needless
750
+ // syscalls (slow on Windows) since the directory never moves. Cache
751
+ // it, but don't memoize a transient failure so a missing dir can heal.
752
+ let functionsRootPromise: Promise<string> | null = null;
753
+ function functionsRoot(): Promise<string> {
754
+ if (!functionsRootPromise) {
755
+ functionsRootPromise = Deno.realPath(opts.functionsDir).catch((err) => {
756
+ functionsRootPromise = null;
757
+ throw err;
758
+ });
759
+ }
760
+ return functionsRootPromise;
761
+ }
762
+
763
+ // Workers superseded by an HMR reload but still draining their
764
+ // in-flight requests. Held so `stop()` can reap them promptly instead
765
+ // of waiting out each one's grace window.
766
+ const drainingHandles = new Set<InternalHandle>();
767
+
768
+ /**
769
+ * Retire a worker that has been replaced or removed. Hands it the
770
+ * configured drain grace so in-flight requests finish, and tracks it
771
+ * for shutdown. Fire-and-forget: the caller (reload/respawn) must not
772
+ * block on the drain.
773
+ */
774
+ function retireHandle(h: InternalHandle): void {
775
+ if (reloadDrainMs <= 0) {
776
+ void h.terminate().catch(() => {});
777
+ return;
778
+ }
779
+ drainingHandles.add(h);
780
+ void h.terminate({ drainMs: reloadDrainMs })
781
+ .catch(() => {})
782
+ .finally(() => {
783
+ drainingHandles.delete(h);
784
+ });
785
+ }
786
+
584
787
  // -------------------------------------------------------------------
585
788
  // Boot state machine (shared by eager + deferred boot and reload)
586
789
  // -------------------------------------------------------------------
@@ -845,7 +1048,12 @@ export function createDenoWorkerHost(
845
1048
  });
846
1049
  }
847
1050
  if (old) {
848
- await old.terminate().catch(() => {});
1051
+ // Graceful handoff: new requests already route to the fresh
1052
+ // worker (registry + handles swapped above), so let the previous
1053
+ // one finish whatever it had in flight before terminating. This
1054
+ // is the fix for HMR dropping in-flight responses under load —
1055
+ // and it runs in the background so the reload returns instantly.
1056
+ retireHandle(old);
849
1057
  }
850
1058
  // If the dep-graph was kicked off in parallel (no shared
851
1059
  // runtime case), make sure it lands before we return —
@@ -890,7 +1098,10 @@ export function createDenoWorkerHost(
890
1098
  }
891
1099
 
892
1100
  async function start(startOpts: StartOptions = {}) {
893
- const candidates = await discoverCandidates(opts.functionsDir);
1101
+ const candidates = await discoverCandidates(
1102
+ await functionsRoot(),
1103
+ (found) => startOpts.onScanProgress?.({ found, phase: "scan" }),
1104
+ );
894
1105
  const loaded: string[] = [];
895
1106
  const errors: Array<{ name: string; error: string }> = [];
896
1107
  const total = candidates.length;
@@ -900,6 +1111,7 @@ export function createDenoWorkerHost(
900
1111
  );
901
1112
 
902
1113
  if (useSharedRewrite && candidates.length > 0) {
1114
+ startOpts.onScanProgress?.({ found: total, phase: "graph" });
903
1115
  const graphStart = performance.now();
904
1116
  await depGraph.refreshMany(
905
1117
  candidates.map((c) => ({ name: c.name, entryFileUrl: c.entryUrl })),
@@ -1021,7 +1233,10 @@ export function createDenoWorkerHost(
1021
1233
  async function startDeferred(
1022
1234
  startOpts: StartOptions = {},
1023
1235
  ): Promise<DeferredStart> {
1024
- const candidates = await discoverCandidates(opts.functionsDir);
1236
+ const candidates = await discoverCandidates(
1237
+ await functionsRoot(),
1238
+ (found) => startOpts.onScanProgress?.({ found, phase: "scan" }),
1239
+ );
1025
1240
  // MRU-first warm-up: the background queue boots the functions the
1026
1241
  // developer was hitting most recently (seeded from the invocation
1027
1242
  // log store across restarts) before the ones nobody calls. Ties —
@@ -1107,11 +1322,12 @@ export function createDenoWorkerHost(
1107
1322
  reason = "fs change",
1108
1323
  ): Promise<ReloadSummary> {
1109
1324
  const start = performance.now();
1325
+ const root = await functionsRoot();
1110
1326
  // Targeted re-discovery: only stat/load the affected names. The
1111
1327
  // full-scan path is reserved for "all" (initial boot semantics).
1112
1328
  const candidates = names === "all"
1113
- ? await discoverCandidates(opts.functionsDir)
1114
- : await discoverNamed(opts.functionsDir, names);
1329
+ ? await discoverCandidates(root)
1330
+ : await discoverNamed(root, names);
1115
1331
  const candByName = new Map(candidates.map((c) => [c.name, c]));
1116
1332
 
1117
1333
  const targetNames = names === "all"
@@ -1133,7 +1349,9 @@ export function createDenoWorkerHost(
1133
1349
 
1134
1350
  for (const n of removed) {
1135
1351
  const h = handles.get(n);
1136
- if (h) await h.terminate().catch(() => {});
1352
+ // Drop the routing handle first so no new request reaches it, then
1353
+ // let it drain its in-flight requests in the background.
1354
+ if (h) retireHandle(h);
1137
1355
  handles.delete(n);
1138
1356
  opts.registry.clearWorkerHandle(n);
1139
1357
  // Deferred boot may have registered a candidate for this name;
@@ -1209,8 +1427,12 @@ export function createDenoWorkerHost(
1209
1427
  }
1210
1428
 
1211
1429
  async function stop() {
1212
- const all = [...handles.values()];
1430
+ // Include workers still draining from a recent reload. Hard-terminate
1431
+ // everything (no drain grace) so shutdown stays prompt; each handle's
1432
+ // in-flight graceful drain resolves the instant its worker is killed.
1433
+ const all = [...handles.values(), ...drainingHandles];
1213
1434
  handles.clear();
1435
+ drainingHandles.clear();
1214
1436
  await Promise.all(all.map((h) => h.terminate().catch(() => {})));
1215
1437
  }
1216
1438
 
@@ -129,3 +129,102 @@ export function createBootProgress(
129
129
  },
130
130
  };
131
131
  }
132
+
133
+ /**
134
+ * Live "scanning" indicator for the pre-spawn boot phase (directory
135
+ * discovery + the optional batch dep-graph build).
136
+ *
137
+ * Unlike {@link createBootProgress}, this DOES redraw a single line in
138
+ * place — and that's safe here precisely because it only runs before any
139
+ * function module is imported, so there are no foreign `console.log()`
140
+ * writers to corrupt the frame yet. {@link stop} clears the line so the
141
+ * append-only boot progress (or the final summary) starts on a clean
142
+ * row. On a non-TTY sink it renders nothing at all: a `\r`-spinner piped
143
+ * to a file or CI log is just noise.
144
+ */
145
+ export interface ScanProgress {
146
+ /** Begin rendering and arm the elapsed-time ticker. */
147
+ start(): void;
148
+ /** Update the discovered-function count (scan phase). */
149
+ setFound(found: number): void;
150
+ /** Switch the phase label (e.g. "scan" → "graph"). */
151
+ setPhase(phase: "scan" | "graph"): void;
152
+ /** Clear the line and stop ticking. Idempotent. */
153
+ stop(): void;
154
+ }
155
+
156
+ export interface ScanProgressOptions {
157
+ /** Redraw cadence in ms. Default 120 — smooth elapsed counter. */
158
+ tickMs?: number;
159
+ }
160
+
161
+ export function createScanProgress(
162
+ sink: Sink = Deno.stdout,
163
+ opts: ScanProgressOptions = {},
164
+ ): ScanProgress {
165
+ const enc = new TextEncoder();
166
+ const tickMs = opts.tickMs ?? 120;
167
+ const isTty = (() => {
168
+ try {
169
+ return sink.isTerminal?.() ?? false;
170
+ } catch {
171
+ return false;
172
+ }
173
+ })();
174
+
175
+ let found = 0;
176
+ let phase: "scan" | "graph" = "scan";
177
+ let startedAt = 0;
178
+ let timer: number | undefined;
179
+ let active = false;
180
+
181
+ const write = (s: string) => {
182
+ try {
183
+ sink.writeSync(enc.encode(s));
184
+ } catch {
185
+ // Closed pipe — never crash boot over a broken stdout.
186
+ }
187
+ };
188
+
189
+ const render = () => {
190
+ if (!isTty || !active) return;
191
+ const elapsed = ((performance.now() - startedAt) / 1000).toFixed(1);
192
+ const body = phase === "graph"
193
+ ? `Building dependency graph… ${
194
+ found > 0 ? `${found} function${found === 1 ? "" : "s"} ` : ""
195
+ }`
196
+ : `Scanning functions… ${found > 0 ? `found ${found} ` : ""}`;
197
+ // \r → column 0, \x1b[2K → clear the whole line, then repaint.
198
+ write(`\r\x1b[2K[1tube] ${body}\x1b[2m(${elapsed}s)\x1b[0m`);
199
+ };
200
+
201
+ return {
202
+ start() {
203
+ if (active) return;
204
+ active = true;
205
+ startedAt = performance.now();
206
+ render();
207
+ if (isTty && tickMs > 0) {
208
+ timer = setInterval(render, tickMs) as unknown as number;
209
+ }
210
+ },
211
+ setFound(n) {
212
+ found = n;
213
+ render();
214
+ },
215
+ setPhase(p) {
216
+ phase = p;
217
+ render();
218
+ },
219
+ stop() {
220
+ if (!active) return;
221
+ active = false;
222
+ if (timer !== undefined) {
223
+ clearInterval(timer);
224
+ timer = undefined;
225
+ }
226
+ // Wipe the line so the next writer starts clean.
227
+ if (isTty) write("\r\x1b[2K");
228
+ },
229
+ };
230
+ }
@@ -9,43 +9,119 @@
9
9
  * absence of the env var is reported and CORS is disabled (no
10
10
  * `Access-Control-*` headers are emitted, browsers will refuse cross-origin
11
11
  * requests).
12
+ *
13
+ * Everything else about the CORS response is configurable too, with the
14
+ * historical values as defaults (so an existing deployment that sets none
15
+ * of these behaves exactly as before):
16
+ * - `1TUBE_CORS_ALLOW_HEADERS` — replaces the default `Access-Control-
17
+ * Allow-Headers` allowlist.
18
+ * - `1TUBE_CORS_ALLOW_METHODS` — replaces the default methods list.
19
+ * - `1TUBE_CORS_EXPOSE_HEADERS` — ADDED to (never replaces) the internal
20
+ * `x-1tube-*` set, so operators can expose extra response headers
21
+ * without breaking the warming/stale overlay.
22
+ * - `1TUBE_CORS_MAX_AGE` — seconds for `Access-Control-Max-Age`
23
+ * (preflight cache). Unset = header omitted (unchanged default).
24
+ * - `1TUBE_CORS_ALLOW_CREDENTIALS` — force credentials on/off. Unset =
25
+ * the historical behaviour (on for an explicit allowlist, off for `*`).
12
26
  */
13
27
 
14
28
  import type { Context, Next } from "npm:hono@4";
15
29
 
16
- const ALLOWED_HEADERS =
30
+ const DEFAULT_ALLOWED_HEADERS =
17
31
  "authorization, x-client-info, apikey, content-type, x-application-name, user-agent";
18
- const ALLOWED_METHODS = "POST, GET, OPTIONS, PUT, PATCH, DELETE";
32
+ const DEFAULT_ALLOWED_METHODS = "POST, GET, OPTIONS, PUT, PATCH, DELETE";
19
33
  // Headers browser JS must be able to read on cross-origin responses.
20
34
  // `x-1tube-warming` drives the "backend is warming up" overlay;
21
35
  // `x-1tube-stale` marks a response served by a pre-edit worker while
22
36
  // its HMR respawn is still in flight; `retry-after` lets clients
23
- // honour the gateway's suggested backoff.
24
- const EXPOSED_HEADERS = "x-1tube-warming, x-1tube-stale, retry-after";
37
+ // honour the gateway's suggested backoff. These are always exposed —
38
+ // operator config only ADDS to them.
39
+ const INTERNAL_EXPOSED_HEADERS = "x-1tube-warming, x-1tube-stale, retry-after";
25
40
 
26
41
  interface CorsConfig {
27
42
  /** "*" means allow any. Empty array means CORS disabled. */
28
43
  allowAny: boolean;
29
44
  allowList: string[];
30
45
  wildcardList: string[];
46
+ allowHeaders: string;
47
+ allowMethods: string;
48
+ exposeHeaders: string;
49
+ /** Seconds for Access-Control-Max-Age, or null to omit the header. */
50
+ maxAge: string | null;
51
+ /** Explicit credentials override; null = derive from allowAny. */
52
+ allowCredentials: boolean | null;
31
53
  }
32
54
 
33
55
  let _config: CorsConfig | null = null;
34
56
 
57
+ /** Merge comma-lists, dedupe case-insensitively, keep first-seen order. */
58
+ function mergeHeaderList(base: string, extra: string): string {
59
+ const seen = new Set<string>();
60
+ const out: string[] = [];
61
+ for (
62
+ const part of `${base},${extra}`.split(",").map((s) => s.trim()).filter(
63
+ Boolean,
64
+ )
65
+ ) {
66
+ const key = part.toLowerCase();
67
+ if (seen.has(key)) continue;
68
+ seen.add(key);
69
+ out.push(part);
70
+ }
71
+ return out.join(", ");
72
+ }
73
+
74
+ function readBoolEnv(name: string): boolean | null {
75
+ const v = Deno.env.get(name);
76
+ if (v === undefined) return null;
77
+ const t = v.trim().toLowerCase();
78
+ if (t === "1" || t === "true") return true;
79
+ if (t === "0" || t === "false") return false;
80
+ return null;
81
+ }
82
+
83
+ /** Headers/methods/etc. are independent of the origin allowlist branch. */
84
+ function loadResponseShape(): Pick<
85
+ CorsConfig,
86
+ "allowHeaders" | "allowMethods" | "exposeHeaders" | "maxAge" | "allowCredentials"
87
+ > {
88
+ const allowHeaders =
89
+ (Deno.env.get("1TUBE_CORS_ALLOW_HEADERS") || DEFAULT_ALLOWED_HEADERS).trim();
90
+ const allowMethods =
91
+ (Deno.env.get("1TUBE_CORS_ALLOW_METHODS") || DEFAULT_ALLOWED_METHODS).trim();
92
+ const exposeHeaders = mergeHeaderList(
93
+ INTERNAL_EXPOSED_HEADERS,
94
+ (Deno.env.get("1TUBE_CORS_EXPOSE_HEADERS") || "").trim(),
95
+ );
96
+ const maxAgeRaw = (Deno.env.get("1TUBE_CORS_MAX_AGE") || "").trim();
97
+ const maxAgeNum = parseInt(maxAgeRaw, 10);
98
+ const maxAge = maxAgeRaw !== "" && Number.isFinite(maxAgeNum) && maxAgeNum >= 0
99
+ ? String(maxAgeNum)
100
+ : null;
101
+ return {
102
+ allowHeaders,
103
+ allowMethods,
104
+ exposeHeaders,
105
+ maxAge,
106
+ allowCredentials: readBoolEnv("1TUBE_CORS_ALLOW_CREDENTIALS"),
107
+ };
108
+ }
109
+
35
110
  function loadConfig(): CorsConfig {
36
111
  if (_config) return _config;
37
112
  const raw = (Deno.env.get("1TUBE_CORS_ORIGIN") || "").trim();
38
113
  const isDev = Deno.env.get("1TUBE_DEV") === "1";
114
+ const shape = loadResponseShape();
39
115
 
40
116
  if (!raw) {
41
117
  if (isDev) {
42
- _config = { allowAny: true, allowList: [], wildcardList: [] };
118
+ _config = { allowAny: true, allowList: [], wildcardList: [], ...shape };
43
119
  } else {
44
120
  console.warn(
45
121
  "[1tube] 1TUBE_CORS_ORIGIN not set — CORS disabled. Set it to '*' or a " +
46
122
  "comma-separated domain allowlist (e.g. app.example.com,*.example.com).",
47
123
  );
48
- _config = { allowAny: false, allowList: [], wildcardList: [] };
124
+ _config = { allowAny: false, allowList: [], wildcardList: [], ...shape };
49
125
  }
50
126
  return _config;
51
127
  }
@@ -54,7 +130,7 @@ function loadConfig(): CorsConfig {
54
130
  const wildcardList: string[] = [];
55
131
  for (const entry of raw.split(",").map((s) => s.trim()).filter(Boolean)) {
56
132
  if (entry === "*") {
57
- _config = { allowAny: true, allowList: [], wildcardList: [] };
133
+ _config = { allowAny: true, allowList: [], wildcardList: [], ...shape };
58
134
  return _config;
59
135
  }
60
136
  const domain = normalizeDomainPattern(entry);
@@ -67,6 +143,7 @@ function loadConfig(): CorsConfig {
67
143
  allowAny: false,
68
144
  allowList,
69
145
  wildcardList,
146
+ ...shape,
70
147
  };
71
148
  return _config;
72
149
  }
@@ -118,14 +195,21 @@ function buildCorsHeaders(reqOrigin: string | null): Record<string, string> | nu
118
195
 
119
196
  const headers: Record<string, string> = {
120
197
  "Access-Control-Allow-Origin": allowed,
121
- "Access-Control-Allow-Headers": ALLOWED_HEADERS,
122
- "Access-Control-Allow-Methods": ALLOWED_METHODS,
123
- "Access-Control-Expose-Headers": EXPOSED_HEADERS,
198
+ "Access-Control-Allow-Headers": cfg.allowHeaders,
199
+ "Access-Control-Allow-Methods": cfg.allowMethods,
200
+ "Access-Control-Expose-Headers": cfg.exposeHeaders,
124
201
  };
125
202
  if (allowed !== "*") {
126
203
  headers["Vary"] = "Origin";
127
204
  }
128
- if (!cfg.allowAny) {
205
+ if (cfg.maxAge !== null) {
206
+ headers["Access-Control-Max-Age"] = cfg.maxAge;
207
+ }
208
+ // Default (override unset): credentials on for an explicit allowlist,
209
+ // off in `*` mode. An explicit override wins — but never claim
210
+ // credentials alongside a literal `*` origin, which browsers reject.
211
+ const wantCredentials = cfg.allowCredentials ?? !cfg.allowAny;
212
+ if (wantCredentials && allowed !== "*") {
129
213
  headers["Access-Control-Allow-Credentials"] = "true";
130
214
  }
131
215
  return headers;
@@ -11,6 +11,7 @@
11
11
 
12
12
  import type { Context, Next } from "npm:hono@4";
13
13
  import { logError, logInfo } from "../log-buffer.ts";
14
+ import { getRoutePrefix, routeRemainder } from "./route-prefix.ts";
14
15
  import { uuidv7 } from "../logs/id.ts";
15
16
  import type {
16
17
  InvocationErrorKind,
@@ -95,7 +96,7 @@ export function setInvocationError(c: Context, info: InvocationErrorInfo): void
95
96
  export async function loggingMiddleware(c: Context, next: Next) {
96
97
  const start = performance.now();
97
98
  const startedAtMs = Date.now();
98
- const fnName = safeFnName(c.req.path.replace("/functions/v1/", "") || "unknown");
99
+ const fnName = safeFnName(routeRemainder(c.req.path) || "unknown");
99
100
  // UUIDv7: time-ordered, so the persisted id doubles as a stable sort
100
101
  // tiebreaker. Assigned for every request — even when persistence is
101
102
  // off the header is useful for support tickets / client-side traces.
@@ -157,7 +158,7 @@ export async function loggingMiddleware(c: Context, next: Next) {
157
158
 
158
159
  // Note: only method, function name, status, duration, and a truncated user
159
160
  // id are logged. No headers (esp. Authorization), no body, no query string.
160
- const line = `\x1b[36m[1tube]${reset} ${c.req.method} /functions/v1/${fnName} → ${statusColor}${status}${reset} ${dim}(${durationMs}ms)${reset}${userTag}`;
161
+ const line = `\x1b[36m[1tube]${reset} ${c.req.method} ${getRoutePrefix()}/${fnName} → ${statusColor}${status}${reset} ${dim}(${durationMs}ms)${reset}${userTag}`;
161
162
 
162
163
  if (isError) {
163
164
  logError(line);
@@ -11,6 +11,7 @@
11
11
  import type { Context, Next } from "npm:hono@4";
12
12
  import { getConnInfo } from "npm:hono@4/deno";
13
13
  import type { FunctionRegistry } from "../registry.ts";
14
+ import { routeRemainder } from "./route-prefix.ts";
14
15
 
15
16
  interface Bucket {
16
17
  tokens: number;
@@ -131,14 +132,11 @@ export function createRateLimiter(config: Partial<RateLimitConfig> = {}) {
131
132
  _activeMaxBuckets = cfg.maxBuckets;
132
133
 
133
134
  return async (c: Context, next: Next) => {
134
- // Middleware is mounted on `/functions/v1/*`, so c.req.param() won't see
135
- // the trailing segments. Parse the function name out of the path
136
- // ourselves — first segment after the prefix, before any nested route.
137
- const path = c.req.path;
138
- const after = path.startsWith("/functions/v1/")
139
- ? path.slice("/functions/v1/".length)
140
- : "";
141
- const fnName = after.split("/", 1)[0] || "";
135
+ // Middleware is mounted on the function route wildcard, so
136
+ // c.req.param() won't see the trailing segments. Parse the function
137
+ // name out of the path ourselves — first segment after the
138
+ // (configurable) prefix, before any nested route.
139
+ const fnName = routeRemainder(c.req.path).split("/", 1)[0] || "";
142
140
 
143
141
  const manifestRpm = fnName ? cfg.registry?.manifestFor(fnName)?.rpm : undefined;
144
142
  const rpm = manifestRpm ?? ((fnName && cfg.overrides[fnName]) || cfg.defaultRpm);
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Configurable function route prefix.
3
+ *
4
+ * The gateway has always served functions under Supabase's
5
+ * `/functions/v1/<name>` convention. That prefix is now configurable
6
+ * (`--route-prefix` / `1TUBE_ROUTE_PREFIX`) so the gateway can sit
7
+ * behind a different path convention (e.g. `/api`, `/edge/v2`) without a
8
+ * reverse-proxy rewrite. The default is unchanged, so existing
9
+ * deployments keep working with zero config.
10
+ *
11
+ * The prefix is process-global module state, set once at boot BEFORE any
12
+ * route is mounted. It lives here (rather than threaded through every
13
+ * call site) because the logging and rate-limit middleware � whose
14
+ * public signatures we don't want to break � must strip the exact same
15
+ * prefix the router mounts on. There is one gateway per process, so a
16
+ * single shared value is correct; tests that need a custom prefix call
17
+ * {@link setRoutePrefix} and reset it afterwards (or re-import the
18
+ * module fresh).
19
+ */
20
+
21
+ /** Supabase Edge Runtime convention � the historical, unchanged default. */
22
+ export const DEFAULT_ROUTE_PREFIX = "/functions/v1";
23
+
24
+ let _prefix = DEFAULT_ROUTE_PREFIX;
25
+
26
+ /**
27
+ * Canonicalize a raw prefix: guarantee exactly one leading slash, no
28
+ * trailing slash, and no duplicate inner slashes. Empty / root-only
29
+ * inputs fall back to the default � the router can't mount on bare `/`
30
+ * without swallowing `/health`, `/1tube/*`, and friends.
31
+ */
32
+ export function normalizeRoutePrefix(raw: string | undefined | null): string {
33
+ let v = (raw ?? "").trim();
34
+ if (!v) return DEFAULT_ROUTE_PREFIX;
35
+ if (!v.startsWith("/")) v = "/" + v;
36
+ v = v.replace(/\/{2,}/g, "/").replace(/\/+$/, "");
37
+ return v === "" ? DEFAULT_ROUTE_PREFIX : v;
38
+ }
39
+
40
+ /** Set the process-wide route prefix. Returns the normalized value used. */
41
+ export function setRoutePrefix(raw: string | undefined | null): string {
42
+ _prefix = normalizeRoutePrefix(raw);
43
+ return _prefix;
44
+ }
45
+
46
+ /** Current normalized prefix (leading slash, no trailing slash). */
47
+ export function getRoutePrefix(): string {
48
+ return _prefix;
49
+ }
50
+
51
+ /** Hono wildcard mount pattern for middleware, e.g. `/functions/v1/*`. */
52
+ export function routeWildcard(): string {
53
+ return `${_prefix}/*`;
54
+ }
55
+
56
+ /** Hono dispatch pattern capturing the function name + nested path. */
57
+ export function routeDispatchPattern(): string {
58
+ return `${_prefix}/:name{.+}`;
59
+ }
60
+
61
+ /**
62
+ * Function name + nested path that follows `${prefix}/`. Returns "" when
63
+ * the path isn't under the prefix. Used by logging + rate-limit to pull
64
+ * the function name out of the request path (the middleware is mounted on
65
+ * the wildcard, so `c.req.param()` can't see it).
66
+ */
67
+ export function routeRemainder(path: string): string {
68
+ if (path.startsWith(_prefix + "/")) return path.slice(_prefix.length + 1);
69
+ return "";
70
+ }
71
+
72
+ /**
73
+ * Strip the prefix off a full pathname, KEEPING the leading slash of the
74
+ * remainder (`/functions/v1/hello/world` ? `/hello/world`,
75
+ * `/functions/v1` ? ""). This is the URL the function/worker should see,
76
+ * mirroring Supabase Edge Runtime behaviour. Callers fall back to "/" for
77
+ * the empty result.
78
+ */
79
+ export function stripRoutePrefixFromPathname(pathname: string): string {
80
+ if (pathname === _prefix) return "";
81
+ if (pathname.startsWith(_prefix + "/")) return pathname.slice(_prefix.length);
82
+ return pathname;
83
+ }
package/src/registry.ts CHANGED
@@ -104,7 +104,13 @@ export interface WorkerFunctionHandle {
104
104
  */
105
105
  invocationId?: string,
106
106
  ): Promise<Response>;
107
- terminate(): Promise<void>;
107
+ /**
108
+ * Tear down the worker. With `drainMs > 0`, in-flight requests are
109
+ * allowed to finish (up to `drainMs`) before the worker is killed —
110
+ * the graceful HMR handoff path. Omitting it (or passing 0) hard-
111
+ * terminates immediately, rejecting anything still pending.
112
+ */
113
+ terminate(opts?: { drainMs?: number }): Promise<void>;
108
114
  }
109
115
 
110
116
  export class FunctionRegistry {
package/src/server.ts CHANGED
@@ -35,6 +35,14 @@ import { createLogQuery } from "./logs/query.ts";
35
35
  import { registerLogRoutes } from "./logs/api.ts";
36
36
  import { parseMarkedConsoleLine } from "./logs/console-marker.ts";
37
37
  import { createRateLimiter } from "./gateway/rate-limit.ts";
38
+ import {
39
+ getRoutePrefix,
40
+ normalizeRoutePrefix,
41
+ routeDispatchPattern,
42
+ routeWildcard,
43
+ setRoutePrefix,
44
+ stripRoutePrefixFromPathname,
45
+ } from "./gateway/route-prefix.ts";
38
46
  import { createHealthHandler, createMetricsHandler } from "./health.ts";
39
47
  import { FunctionSupervisor } from "./supervisor.ts";
40
48
  import { installEnvScope } from "./env-scope.ts";
@@ -66,7 +74,7 @@ import {
66
74
  createRewriteCache,
67
75
  type RewriteCache,
68
76
  } from "./backends/deno/source-rewriter.ts";
69
- import { createBootProgress } from "./boot-progress.ts";
77
+ import { createBootProgress, createScanProgress } from "./boot-progress.ts";
70
78
  import {
71
79
  createWorkerdWatchdog,
72
80
  recommendedBudgetBytes,
@@ -114,6 +122,13 @@ interface CliOpts {
114
122
  port: number;
115
123
  host: string;
116
124
  functionsPath: string;
125
+ /**
126
+ * URL prefix every function is served under. Defaults to Supabase's
127
+ * `/functions/v1`. Override with `--route-prefix <p>` /
128
+ * `1TUBE_ROUTE_PREFIX` to mount functions at e.g. `/api` or `/edge/v2`.
129
+ * Normalized to a single leading slash, no trailing slash.
130
+ */
131
+ routePrefix: string;
117
132
  defaultTimeoutMs: number;
118
133
  bodyLimitBytes: number;
119
134
  /**
@@ -179,6 +194,13 @@ interface CliOpts {
179
194
  workerdShared?: readonly string[];
180
195
  /** Max concurrent Deno Worker spawns during eager boot/reload. */
181
196
  denoWorkerConcurrency?: number;
197
+ /**
198
+ * Grace window (ms) an HMR-superseded Deno worker keeps serving its
199
+ * in-flight requests before it is terminated. Prevents a reload under
200
+ * load from dropping responses. `1TUBE_HMR_DRAIN_MS`, default 10000;
201
+ * 0 reverts to terminate-immediately. Deno backend only.
202
+ */
203
+ denoReloadDrainMs?: number;
182
204
  /**
183
205
  * First loopback port workerd may use for per-function sockets.
184
206
  * The backend reserves a second generation range at +500 during
@@ -306,6 +328,12 @@ function parseArgs(): CliOpts {
306
328
  const n = parseInt(v, 10);
307
329
  return Number.isFinite(n) && n > 0 ? n : undefined;
308
330
  })();
331
+ let denoReloadDrainMs: number | undefined = (() => {
332
+ const v = Deno.env.get("1TUBE_HMR_DRAIN_MS");
333
+ if (!v) return undefined;
334
+ const n = parseInt(v, 10);
335
+ return Number.isFinite(n) && n >= 0 ? n : undefined;
336
+ })();
309
337
  let workerdBasePort: number | undefined = (() => {
310
338
  const v = Deno.env.get("1TUBE_WORKERD_BASE_PORT");
311
339
  if (!v) return undefined;
@@ -373,6 +401,9 @@ function parseArgs(): CliOpts {
373
401
  let port = parseInt(Deno.env.get("PORT") || "3100", 10);
374
402
  let host = (Deno.env.get("1TUBE_HOST") || "127.0.0.1").trim();
375
403
  let functionsPath = Deno.env.get("FUNCTIONS_PATH") || "./supabase/functions";
404
+ // Normalized at the end via setRoutePrefix(); kept raw here so an
405
+ // explicit CLI flag can still override the env var.
406
+ let routePrefix = Deno.env.get("1TUBE_ROUTE_PREFIX") || "/functions/v1";
376
407
  let defaultTimeoutMs = parseInt(
377
408
  Deno.env.get("FUNCTION_TIMEOUT_MS") || "150000",
378
409
  10,
@@ -395,6 +426,10 @@ function parseArgs(): CliOpts {
395
426
  host = args[++i];
396
427
  } else if (a === "--functions" && args[i + 1]) {
397
428
  functionsPath = args[++i];
429
+ } else if (a === "--route-prefix" && args[i + 1]) {
430
+ routePrefix = args[++i];
431
+ } else if (a.startsWith("--route-prefix=")) {
432
+ routePrefix = a.slice("--route-prefix=".length);
398
433
  } else if (a === "--timeout" && args[i + 1]) {
399
434
  defaultTimeoutMs = parseInt(args[++i], 10);
400
435
  } else if (a === "--dev") {
@@ -459,6 +494,12 @@ function parseArgs(): CliOpts {
459
494
  } else if (a.startsWith("--deno-worker-concurrency=")) {
460
495
  const n = parseInt(a.slice("--deno-worker-concurrency=".length), 10);
461
496
  if (Number.isFinite(n) && n > 0) denoWorkerConcurrency = n;
497
+ } else if (a === "--hmr-drain-ms" && args[i + 1]) {
498
+ const n = parseInt(args[++i], 10);
499
+ if (Number.isFinite(n) && n >= 0) denoReloadDrainMs = n;
500
+ } else if (a.startsWith("--hmr-drain-ms=")) {
501
+ const n = parseInt(a.slice("--hmr-drain-ms=".length), 10);
502
+ if (Number.isFinite(n) && n >= 0) denoReloadDrainMs = n;
462
503
  } else if (a === "--workerd-base-port" && args[i + 1]) {
463
504
  const n = parseInt(args[++i], 10);
464
505
  if (Number.isFinite(n) && n > 0) workerdBasePort = n;
@@ -574,6 +615,7 @@ function parseArgs(): CliOpts {
574
615
  port,
575
616
  host,
576
617
  functionsPath,
618
+ routePrefix: normalizeRoutePrefix(routePrefix),
577
619
  defaultTimeoutMs,
578
620
  bodyLimitBytes: Math.max(0, Math.floor(bodyLimitMb * 1024 * 1024)),
579
621
  bodyReadIdleMs: Math.max(0, bodyReadIdleMs),
@@ -589,6 +631,7 @@ function parseArgs(): CliOpts {
589
631
  workerdEnv,
590
632
  workerdShared,
591
633
  denoWorkerConcurrency,
634
+ denoReloadDrainMs,
592
635
  workerdBasePort,
593
636
  workerdInspector,
594
637
  workerdMaxHeapMB,
@@ -1360,6 +1403,9 @@ if (opts.backend === "workerd") {
1360
1403
  ...(opts.denoWorkerConcurrency
1361
1404
  ? { concurrency: opts.denoWorkerConcurrency }
1362
1405
  : {}),
1406
+ ...(opts.denoReloadDrainMs !== undefined
1407
+ ? { reloadDrainMs: opts.denoReloadDrainMs }
1408
+ : {}),
1363
1409
  ...(denoImportMapOptions ?? {}),
1364
1410
  ...(denoSharedRuntime ? { sharedRuntime: denoSharedRuntime } : {}),
1365
1411
  ...(denoRewriteCache ? { rewriteCache: denoRewriteCache } : {}),
@@ -1375,6 +1421,11 @@ if (opts.backend === "workerd") {
1375
1421
  // already enough signal.
1376
1422
  const bootStart = performance.now();
1377
1423
  const progress = createBootProgress(Deno.stdout, { pulseMs: 2000 });
1424
+ // Live in-place "scanning" line for the pre-spawn phase (directory
1425
+ // discovery + batch dep-graph). Safe to redraw in place here because no
1426
+ // function module has been imported yet, so nothing else is writing to
1427
+ // the TTY. Cleared the instant the first worker spawn line prints.
1428
+ const scanProgress = createScanProgress(Deno.stdout);
1378
1429
  const bootProfile = Deno.env.get("1TUBE_BOOT_PROFILE") === "1" ||
1379
1430
  Deno.env.get("1TUBE_BOOT_PROFILE")?.toLowerCase() === "true";
1380
1431
  let started = false;
@@ -1387,6 +1438,10 @@ if (opts.backend === "workerd") {
1387
1438
  // duration; the only thing that changes is the column we display.
1388
1439
  let printOrder = 0;
1389
1440
  const startCallbacks = {
1441
+ onScanProgress: (p: { found: number; phase: "scan" | "graph" }) => {
1442
+ scanProgress.setPhase(p.phase);
1443
+ scanProgress.setFound(p.found);
1444
+ },
1390
1445
  onBatchGraph: (p: { total: number; durationMs: number }) => {
1391
1446
  if (bootProfile) {
1392
1447
  console.log(
@@ -1397,6 +1452,9 @@ if (opts.backend === "workerd") {
1397
1452
  }
1398
1453
  },
1399
1454
  onSpawnStart: (p: { index: number; total: number; name: string }) => {
1455
+ // Clear the scanning line before the first append-only spawn line
1456
+ // lands, so the two never collide on the same TTY row.
1457
+ scanProgress.stop();
1400
1458
  if (!started) {
1401
1459
  progress.start(p.total);
1402
1460
  started = true;
@@ -1440,6 +1498,7 @@ if (opts.backend === "workerd") {
1440
1498
  }
1441
1499
  };
1442
1500
 
1501
+ scanProgress.start();
1443
1502
  if (opts.deferBoot) {
1444
1503
  // Deferred boot: the gateway starts serving right away; Workers spawn
1445
1504
  // in the background. A request for a not-yet-ready function bumps it
@@ -1447,6 +1506,9 @@ if (opts.backend === "workerd") {
1447
1506
  const { discovered, done } = await denoWorkerHost.startDeferred(
1448
1507
  startCallbacks,
1449
1508
  );
1509
+ // Scan is done by the time startDeferred resolves (spawns continue in
1510
+ // the background); clear the line before the deferred-boot message.
1511
+ scanProgress.stop();
1450
1512
  console.log(
1451
1513
  `[1tube] Deferred boot: ${discovered.length} function(s) warming in ` +
1452
1514
  `the background — gateway is accepting requests now ` +
@@ -1458,6 +1520,7 @@ if (opts.backend === "workerd") {
1458
1520
  });
1459
1521
  } else {
1460
1522
  const { loaded, errors } = await denoWorkerHost.start(startCallbacks);
1523
+ scanProgress.stop();
1461
1524
  progress.stop();
1462
1525
  logBootSummary(loaded, errors, "Loaded");
1463
1526
  }
@@ -1506,6 +1569,16 @@ const rateLimiter = createRateLimiter({
1506
1569
  ...(rpmOverride !== null ? { defaultRpm: rpmOverride } : {}),
1507
1570
  });
1508
1571
 
1572
+ // Lock in the (configurable) function route prefix BEFORE mounting any
1573
+ // route — the logging + rate-limit middleware read the same global to
1574
+ // strip it back off. `fnRouteWildcard` is the Hono mount pattern shared
1575
+ // by every middleware below (`<prefix>/*`).
1576
+ setRoutePrefix(opts.routePrefix);
1577
+ const fnRouteWildcard = routeWildcard();
1578
+ if (opts.routePrefix !== "/functions/v1") {
1579
+ console.log(`[1tube] Function route prefix: ${opts.routePrefix}`);
1580
+ }
1581
+
1509
1582
  // Middleware order matters:
1510
1583
  // 1. CORS (handle preflight before anything else can short-circuit)
1511
1584
  // 2. Fast-fail unknown function names (no auth/rate-limit cost for scans)
@@ -1514,7 +1587,7 @@ const rateLimiter = createRateLimiter({
1514
1587
  // 5. JWT auth probe (sets c.userId when token is valid; does NOT enforce
1515
1588
  // auth here — the dispatcher decides per function based on `isPublic`)
1516
1589
  // 6. Rate limiter (now sees a real userId for per-user keying)
1517
- app.use("/functions/v1/*", corsMiddleware);
1590
+ app.use(fnRouteWildcard, corsMiddleware);
1518
1591
  // Backend-aware function-existence probe. On the workerd backend the
1519
1592
  // bundled-set captured at boot is the source; on the deno backend the
1520
1593
  // registry's worker-handle map is. Both expose the same Map-lookup-cost
@@ -1522,12 +1595,12 @@ app.use("/functions/v1/*", corsMiddleware);
1522
1595
  const functionExists = (name: string): boolean =>
1523
1596
  opts.backend === "workerd" ? workerdNames.has(name) : registry.has(name);
1524
1597
 
1525
- app.use("/functions/v1/*", async (c, next) => {
1598
+ app.use(fnRouteWildcard, async (c, next) => {
1526
1599
  // Fast-fail unknown function names before paying for logging, auth, body
1527
1600
  // limit, and rate-limit. Without this, a scanner hammering random names
1528
1601
  // exhausts rate-limit buckets keyed by IP and pollutes the log stream.
1529
1602
  const path = c.req.path;
1530
- const prefix = "/functions/v1/";
1603
+ const prefix = getRoutePrefix() + "/";
1531
1604
  if (path.length > prefix.length) {
1532
1605
  const after = path.slice(prefix.length);
1533
1606
  const slash = after.indexOf("/");
@@ -1538,9 +1611,9 @@ app.use("/functions/v1/*", async (c, next) => {
1538
1611
  }
1539
1612
  await next();
1540
1613
  });
1541
- app.use("/functions/v1/*", loggingMiddleware);
1614
+ app.use(fnRouteWildcard, loggingMiddleware);
1542
1615
  app.use(
1543
- "/functions/v1/*",
1616
+ fnRouteWildcard,
1544
1617
  bodyLimit({
1545
1618
  maxSize: opts.bodyLimitBytes,
1546
1619
  onError: (c) =>
@@ -1553,7 +1626,7 @@ app.use(
1553
1626
  ),
1554
1627
  }),
1555
1628
  );
1556
- app.use("/functions/v1/*", async (c, next) => {
1629
+ app.use(fnRouteWildcard, async (c, next) => {
1557
1630
  // Probe-only auth: validate the bearer token if present, but don't reject
1558
1631
  // unauthenticated requests here. Public functions need to pass through and
1559
1632
  // rate-limit by IP; protected functions are gated below in the dispatcher.
@@ -1568,13 +1641,15 @@ app.use("/functions/v1/*", async (c, next) => {
1568
1641
  }
1569
1642
  await next();
1570
1643
  });
1571
- app.use("/functions/v1/*", rateLimiter);
1644
+ app.use(fnRouteWildcard, rateLimiter);
1572
1645
 
1573
- // Function dispatch
1574
- app.all("/functions/v1/:name{.+}", async (c) => {
1646
+ // Function dispatch. The pattern is built from the configurable prefix, so
1647
+ // Hono can't statically infer the `:name` param — hence the `?? ""` guard
1648
+ // (the `{.+}` matcher guarantees a non-empty value at runtime anyway).
1649
+ app.all(routeDispatchPattern(), async (c) => {
1575
1650
  // c.req.param("name") greedily captures the trailing path with the {.+}
1576
1651
  // matcher; restrict it to the first segment.
1577
- const rawName = c.req.param("name");
1652
+ const rawName = c.req.param("name") ?? "";
1578
1653
  const name = rawName.split("/", 1)[0];
1579
1654
 
1580
1655
  // Workerd backend dispatch path. Same gateway pipeline (CORS, auth
@@ -1618,12 +1693,12 @@ app.all("/functions/v1/:name{.+}", async (c) => {
1618
1693
  }).body
1619
1694
  : null;
1620
1695
  // Mirror the Deno-path URL rewrite: user code expects to see the
1621
- // pathname without the `/functions/v1` prefix, matching Supabase
1696
+ // pathname without the gateway route prefix, matching Supabase
1622
1697
  // Edge Runtime behaviour. Without this strip, hello/echo see the
1623
1698
  // gateway prefix in their `new URL(req.url).pathname`.
1624
1699
  const originalUrl = new URL(c.req.raw.url);
1625
1700
  const rewrittenPath =
1626
- originalUrl.pathname.replace(/^\/functions\/v1/, "") || "/";
1701
+ stripRoutePrefixFromPathname(originalUrl.pathname) || "/";
1627
1702
  const rewrittenUrl = new URL(
1628
1703
  rewrittenPath + originalUrl.search,
1629
1704
  originalUrl.origin,
@@ -1831,9 +1906,9 @@ app.all("/functions/v1/:name{.+}", async (c) => {
1831
1906
  const timeoutMs = handle.timeoutMs ?? handle.manifest.timeoutMs ??
1832
1907
  opts.defaultTimeoutMs;
1833
1908
 
1834
- // Strip /functions/v1 prefix to match Supabase Edge Runtime behavior.
1909
+ // Strip the gateway route prefix to match Supabase Edge Runtime behavior.
1835
1910
  const originalUrl = new URL(c.req.raw.url);
1836
- const rewrittenPath = originalUrl.pathname.replace(/^\/functions\/v1/, "") ||
1911
+ const rewrittenPath = stripRoutePrefixFromPathname(originalUrl.pathname) ||
1837
1912
  "/";
1838
1913
  const rewrittenUrl = new URL(
1839
1914
  rewrittenPath + originalUrl.search,