1tube 0.1.21 → 0.1.22

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/src/server.ts CHANGED
@@ -16,12 +16,9 @@
16
16
  // cleanly when launched from a host project's node_modules — bare
17
17
  // specifiers would otherwise require the host's deno.json to mirror our
18
18
  // import map. Same convention applies to every src/ file.
19
- import { type Context, Hono } from "npm:hono@4";
19
+ import { Hono, type Context } from "npm:hono@4";
20
20
  import { bodyLimit } from "npm:hono@4/body-limit";
21
- import { relative, sep as SEPARATOR } from "node:path";
22
21
  import { currentRequestStorage, FunctionRegistry } from "./registry.ts";
23
- import { discoverAndLoad, type DiscoveryProgress } from "./discovery.ts";
24
- import { createBootProgress } from "./boot-progress.ts";
25
22
  import { validateRequest } from "./gateway/auth.ts";
26
23
  import { watchdogBody } from "./gateway/body-watchdog.ts";
27
24
  import { corsMiddleware } from "./gateway/cors.ts";
@@ -29,18 +26,16 @@ import { loggingMiddleware } from "./gateway/logging.ts";
29
26
  import { createRateLimiter } from "./gateway/rate-limit.ts";
30
27
  import { createHealthHandler, createMetricsHandler } from "./health.ts";
31
28
  import { FunctionSupervisor } from "./supervisor.ts";
32
- import { installEnvScope, runWithEnvScope } from "./env-scope.ts";
29
+ import { installEnvScope } from "./env-scope.ts";
33
30
  import { flushLogs } from "./log-buffer.ts";
34
31
  import { VERSION } from "./version.ts";
35
- import { createDenoHmrSnapshot } from "./deno-hmr-snapshot.ts";
32
+ import { createWorkerdBackend, type WorkerdBackend } from "./backends/workerd/backend.ts";
33
+ import { createWorkerdHotReloader, type WorkerdHotReloader } from "./backends/workerd/hot-reloader.ts";
34
+ import { createDenoWorkerHost, type DenoWorkerHost } from "./backends/deno/worker-host.ts";
36
35
  import {
37
- createWorkerdBackend,
38
- type WorkerdBackend,
39
- } from "./backends/workerd/backend.ts";
40
- import {
41
- createWorkerdHotReloader,
42
- type WorkerdHotReloader,
43
- } from "./backends/workerd/hot-reloader.ts";
36
+ createDenoHotReloader,
37
+ type DenoHotReloader,
38
+ } from "./backends/deno/hot-reloader.ts";
44
39
  import {
45
40
  createWorkerdWatchdog,
46
41
  recommendedBudgetBytes,
@@ -189,15 +184,10 @@ function parseArgs(): CliOpts {
189
184
  // --lazy or 1TUBE_LAZY=1 when boot speed matters more than first-hit
190
185
  // latency (e.g. sites with hundreds of rarely-called functions).
191
186
  const lazyEnv = Deno.env.get("1TUBE_LAZY");
192
- let lazy = lazyEnv === undefined
193
- ? false
194
- : (lazyEnv === "1" || lazyEnv === "true");
187
+ let lazy = lazyEnv === undefined ? false : (lazyEnv === "1" || lazyEnv === "true");
195
188
  const backendEnv = (Deno.env.get("1TUBE_BACKEND") ?? "").trim().toLowerCase();
196
- let backend: "deno" | "workerd" = backendEnv === "workerd"
197
- ? "workerd"
198
- : "deno";
199
- let workerdBin: string | undefined = Deno.env.get("1TUBE_WORKERD_BIN") ||
200
- undefined;
189
+ let backend: "deno" | "workerd" = backendEnv === "workerd" ? "workerd" : "deno";
190
+ let workerdBin: string | undefined = Deno.env.get("1TUBE_WORKERD_BIN") || undefined;
201
191
  // Operator can either use --workerd-env=A,B,C on the CLI or set
202
192
  // 1TUBE_WORKERD_ENV. The CLI flag wins; otherwise the backend reads
203
193
  // the env var itself so a missing flag still picks up the var.
@@ -223,8 +213,7 @@ function parseArgs(): CliOpts {
223
213
  if (v === "1" || v.toLowerCase() === "true") return DEFAULT_INSPECTOR_ADDR;
224
214
  return v;
225
215
  })();
226
- let prebuiltDir: string | undefined = Deno.env.get("1TUBE_PREBUILT") ||
227
- undefined;
216
+ let prebuiltDir: string | undefined = Deno.env.get("1TUBE_PREBUILT") || undefined;
228
217
  // Auto-kill leftover workerd processes if the boot preflight finds
229
218
  // port conflicts. CLI flag overrides env var; both default to off.
230
219
  let killStaleWorkerd: boolean = (() => {
@@ -238,30 +227,19 @@ function parseArgs(): CliOpts {
238
227
  const n = parseInt(v, 10);
239
228
  return Number.isFinite(n) && n > 0 ? n : undefined;
240
229
  })();
241
- let workerdCompatibilityDate: string | undefined =
242
- Deno.env.get("1TUBE_WORKERD_COMPAT_DATE") || undefined;
243
- const workerdCompatibilityFlags: string[] =
244
- (Deno.env.get("1TUBE_WORKERD_COMPAT_FLAGS") ?? "")
245
- .split(",")
246
- .map((s) => s.trim())
247
- .filter((s) => s.length > 0);
230
+ let workerdCompatibilityDate: string | undefined = Deno.env.get("1TUBE_WORKERD_COMPAT_DATE") || undefined;
231
+ const workerdCompatibilityFlags: string[] = (Deno.env.get("1TUBE_WORKERD_COMPAT_FLAGS") ?? "")
232
+ .split(",")
233
+ .map((s) => s.trim())
234
+ .filter((s) => s.length > 0);
248
235
  let workerdExperimental = envFlag("1TUBE_WORKERD_EXPERIMENTAL");
249
236
  let port = parseInt(Deno.env.get("PORT") || "3100", 10);
250
237
  let host = (Deno.env.get("1TUBE_HOST") || "127.0.0.1").trim();
251
238
  let functionsPath = Deno.env.get("FUNCTIONS_PATH") || "./supabase/functions";
252
- let defaultTimeoutMs = parseInt(
253
- Deno.env.get("FUNCTION_TIMEOUT_MS") || "150000",
254
- 10,
255
- );
239
+ let defaultTimeoutMs = parseInt(Deno.env.get("FUNCTION_TIMEOUT_MS") || "150000", 10);
256
240
  const bodyLimitMb = parseFloat(Deno.env.get("1TUBE_BODY_LIMIT_MB") || "30");
257
- const bodyReadIdleMs = parseInt(
258
- Deno.env.get("1TUBE_BODY_READ_MS") || "30000",
259
- 10,
260
- );
261
- const shutdownGraceMs = parseInt(
262
- Deno.env.get("1TUBE_SHUTDOWN_GRACE_MS") || "10000",
263
- 10,
264
- );
241
+ const bodyReadIdleMs = parseInt(Deno.env.get("1TUBE_BODY_READ_MS") || "30000", 10);
242
+ const shutdownGraceMs = parseInt(Deno.env.get("1TUBE_SHUTDOWN_GRACE_MS") || "10000", 10);
265
243
 
266
244
  for (let i = 0; i < args.length; i++) {
267
245
  const a = args[i];
@@ -284,22 +262,14 @@ function parseArgs(): CliOpts {
284
262
  } else if (a === "--backend" && args[i + 1]) {
285
263
  const v = args[++i].toLowerCase();
286
264
  if (v !== "deno" && v !== "workerd") {
287
- console.error(
288
- `[1tube] FATAL: --backend must be 'deno' or 'workerd', got ${
289
- JSON.stringify(v)
290
- }`,
291
- );
265
+ console.error(`[1tube] FATAL: --backend must be 'deno' or 'workerd', got ${JSON.stringify(v)}`);
292
266
  Deno.exit(EXIT_CODES.USAGE);
293
267
  }
294
268
  backend = v;
295
269
  } else if (a.startsWith("--backend=")) {
296
270
  const v = a.slice("--backend=".length).toLowerCase();
297
271
  if (v !== "deno" && v !== "workerd") {
298
- console.error(
299
- `[1tube] FATAL: --backend must be 'deno' or 'workerd', got ${
300
- JSON.stringify(v)
301
- }`,
302
- );
272
+ console.error(`[1tube] FATAL: --backend must be 'deno' or 'workerd', got ${JSON.stringify(v)}`);
303
273
  Deno.exit(EXIT_CODES.USAGE);
304
274
  }
305
275
  backend = v;
@@ -354,26 +324,18 @@ function parseArgs(): CliOpts {
354
324
  workerdCompatibilityFlags.push(args[++i]);
355
325
  } else if (a.startsWith("--compat-flag=")) {
356
326
  workerdCompatibilityFlags.push(a.slice("--compat-flag=".length));
357
- } else if (
358
- a === "--workerd-experimental" || a === "--workerd-experimental=true"
359
- ) {
327
+ } else if (a === "--workerd-experimental" || a === "--workerd-experimental=true") {
360
328
  workerdExperimental = true;
361
- } else if (
362
- a === "--workerd-experimental=false" || a === "--no-workerd-experimental"
363
- ) {
329
+ } else if (a === "--workerd-experimental=false" || a === "--no-workerd-experimental") {
364
330
  workerdExperimental = false;
365
- } else if (
366
- a === "--kill-stale-workerd" || a === "--kill-stale-workerd=true"
367
- ) {
331
+ } else if (a === "--kill-stale-workerd" || a === "--kill-stale-workerd=true") {
368
332
  // Boolean flag — accept both bare-flag and explicit `=true` forms
369
333
  // so we don't surprise scripts that programmatically generate
370
334
  // CLI strings. `--no-kill-stale-workerd` is the explicit override
371
335
  // for cases where the env var is set globally but a particular
372
336
  // run wants the safer behavior.
373
337
  killStaleWorkerd = true;
374
- } else if (
375
- a === "--kill-stale-workerd=false" || a === "--no-kill-stale-workerd"
376
- ) {
338
+ } else if (a === "--kill-stale-workerd=false" || a === "--no-kill-stale-workerd") {
377
339
  killStaleWorkerd = false;
378
340
  }
379
341
  }
@@ -461,9 +423,7 @@ async function applyDevDefaults() {
461
423
  if (Deno.env.get(target)) continue;
462
424
  const value = sources
463
425
  .map((source) => statusEnv[source])
464
- .find((candidate) =>
465
- typeof candidate === "string" && candidate.length > 0
466
- );
426
+ .find((candidate) => typeof candidate === "string" && candidate.length > 0);
467
427
  if (!value) continue;
468
428
  Deno.env.set(target, value);
469
429
  applied++;
@@ -568,9 +528,7 @@ function isInternalRequest(req: Request): boolean {
568
528
  }
569
529
 
570
530
  function requireInternal(c: Context): Response | null {
571
- return isInternalRequest(c.req.raw)
572
- ? null
573
- : c.json({ error: "Forbidden" }, 403);
531
+ return isInternalRequest(c.req.raw) ? null : c.json({ error: "Forbidden" }, 403);
574
532
  }
575
533
 
576
534
  if (opts.dev) {
@@ -579,24 +537,17 @@ if (opts.dev) {
579
537
  enforceProdSecrets();
580
538
  }
581
539
 
582
- const denoVersion =
583
- `${Deno.version.deno} (V8 ${Deno.version.v8}, TS ${Deno.version.typescript})`;
540
+ const denoVersion = `${Deno.version.deno} (V8 ${Deno.version.v8}, TS ${Deno.version.typescript})`;
584
541
  const bannerText = ` 1tube v${VERSION} · Deno ${denoVersion} `;
585
542
  const bannerLine = "─".repeat(bannerText.length);
586
543
  console.log(`\x1b[36m┌${bannerLine}┐\x1b[0m`);
587
- console.log(
588
- `\x1b[36m│\x1b[0m \x1b[1m1tube\x1b[0m v${VERSION} \x1b[2m·\x1b[0m Deno ${denoVersion} \x1b[36m│\x1b[0m`,
589
- );
544
+ console.log(`\x1b[36m│\x1b[0m \x1b[1m1tube\x1b[0m v${VERSION} \x1b[2m·\x1b[0m Deno ${denoVersion} \x1b[36m│\x1b[0m`);
590
545
  console.log(`\x1b[36m└${bannerLine}┘\x1b[0m`);
591
546
  console.log(
592
547
  `[1tube] mode=${opts.dev ? "dev" : "prod"} hmr=${opts.hmr ? "on" : "off"} ` +
593
548
  `lazy=${opts.lazy ? "on" : "off"} backend=${opts.backend} ` +
594
- `host=${opts.host} bodyLimit=${
595
- (opts.bodyLimitBytes / 1024 / 1024).toFixed(1)
596
- }MB ` +
597
- `bodyReadIdle=${
598
- opts.bodyReadIdleMs > 0 ? opts.bodyReadIdleMs + "ms" : "off"
599
- }`,
549
+ `host=${opts.host} bodyLimit=${(opts.bodyLimitBytes / 1024 / 1024).toFixed(1)}MB ` +
550
+ `bodyReadIdle=${opts.bodyReadIdleMs > 0 ? opts.bodyReadIdleMs + "ms" : "off"}`,
600
551
  );
601
552
 
602
553
  const registry = new FunctionRegistry();
@@ -659,202 +610,37 @@ if (enforceManifest && !allowAll) {
659
610
  // the dispatcher consults). Empty string is a sentinel for "no
660
611
  // functions dir"; the only caller that uses this path is the HMR
661
612
  // watcher, and that's gated off when prebuilt is on.
662
- const resolvedFunctionsPath = opts.prebuiltDir ? "" : await (async () => {
663
- try {
664
- return await Deno.realPath(opts.functionsPath);
665
- } catch (err) {
666
- const msg = err instanceof Error ? err.message : String(err);
667
- console.error(
668
- `[1tube] FATAL: functions directory not found: ${opts.functionsPath} (${msg})`,
669
- );
670
- flushLogs();
671
- Deno.exit(EXIT_CODES.CONFIG);
672
- }
673
- })();
674
- let denoHmrGeneration = 0;
675
-
676
- /**
677
- * Map a changed filesystem path to either a function name or `null` ("shared",
678
- * meaning the change is outside any function dir / under `_shared` / under any
679
- * dir starting with `_`, so it could affect any function — reload all).
680
- */
681
- function classifyChangedPath(absPath: string): string | null {
682
- const rel = relative(resolvedFunctionsPath, absPath);
683
- if (
684
- !rel || rel.startsWith("..") || rel.startsWith(SEPARATOR + "..") ||
685
- rel === "."
686
- ) {
687
- return null;
688
- }
689
- const first = rel.split(/[\\/]/, 1)[0];
690
- if (!first) return null;
691
- if (first.startsWith("_") || first.endsWith("_shared")) return null;
692
- return first;
693
- }
694
-
695
- function progressLine(p: DiscoveryProgress): string {
696
- const pad = String(p.total).length;
697
- const idx = String(p.index).padStart(pad, " ");
698
- const status = p.ok ? "\x1b[32m✓\x1b[0m" : "\x1b[31m✗\x1b[0m";
699
- const dur = `${p.durationMs.toFixed(0)}ms`;
700
- const tail = p.ok ? "" : ` — ${p.error ?? "error"}`;
701
- return `[1tube] [${idx}/${p.total}] ${status} ${p.name} \x1b[2m(${dur})\x1b[0m${tail}`;
702
- }
703
-
704
- async function reloadFunctions(
705
- reason: string,
706
- changedFunctionNames: Set<string> | "all",
707
- ) {
708
- const isFullReload = changedFunctionNames === "all";
709
- const isInitialBoot = reason === "initial boot";
710
- const start = performance.now();
711
- let importRoot: string | undefined;
712
-
713
- if (isFullReload) {
714
- console.log(`[1tube] Reloading all functions (${reason})...`);
715
- } else {
716
- const names = [...changedFunctionNames].sort();
717
- if (names.length === 0) return;
718
- console.log(
719
- `[1tube] Reloading ${names.length} function(s) (${reason}): ${
720
- names.join(", ")
721
- }`,
722
- );
723
- }
724
-
725
- if (!isInitialBoot) {
726
- const snapshot = await createDenoHmrSnapshot({
727
- functionsDir: opts.functionsPath,
728
- changed: changedFunctionNames,
729
- generation: ++denoHmrGeneration,
730
- });
731
- importRoot = snapshot.functionsDir;
732
- }
733
-
734
- if (isFullReload) {
735
- registry.clear();
736
- }
737
-
738
- // Cache-bust only when re-importing modules that may already be in Deno's
739
- // module cache — i.e. any reload after the initial boot.
740
- const cacheBust = isInitialBoot
741
- ? undefined
742
- : `${Date.now()}-${crypto.randomUUID()}`;
743
-
744
- // HMR reloads always import eagerly so the change is observable on the
745
- // very next request — even when boot-time discovery was lazy.
746
- const useLazy = opts.lazy && isInitialBoot;
747
-
748
- // Append-only "still working" heartbeat. Does not redraw, does not race
749
- // with foreign console.log() from imported modules — see
750
- // src/boot-progress.ts for the design rationale. Heartbeat is silent for
751
- // tiny HMR reloads (≤2 functions) where the per-completion line is
752
- // already enough signal.
753
- const isTinyReload = changedFunctionNames instanceof Set &&
754
- changedFunctionNames.size <= 2;
755
- const progress = createBootProgress(Deno.stdout, {
756
- pulseMs: isTinyReload ? 0 : 2000,
757
- });
758
-
759
- let result;
760
- try {
761
- result = await discoverAndLoad(
762
- opts.functionsPath,
763
- registry,
764
- {
765
- cacheBust,
766
- importRoot,
767
- only: isFullReload ? undefined : changedFunctionNames,
768
- onStart: (p) => {
769
- // Late-bind the total on the first start event — by then we
770
- // know the real work-list size after `only`/exists filtering.
771
- if (p.index === 1) progress.start(p.total);
772
- progress.onStart(p.name);
773
- },
774
- onProgress: (p) => progress.onFinish(progressLine(p), p.name),
775
- lazy: useLazy,
776
- },
777
- );
778
- } finally {
779
- progress.stop();
780
- }
781
- const { loaded, skipped, errors, removed, deferred } = result;
782
-
783
- for (const name of removed) {
784
- if (registry.delete(name)) {
785
- supervisor.forget(name);
786
- console.log(`[1tube] Removed "${name}" (index.ts gone)`);
787
- }
788
- }
789
-
790
- for (const name of loaded) {
791
- const reg = registry.get(name);
792
- if (reg) supervisor.setManifest(name, reg.manifest);
793
- // On reload, drop accumulated stats so the breaker doesn't immediately
794
- // re-trip on stale counts after the operator fixed the function.
795
- if (!isFullReload) supervisor.reset(name);
796
- }
797
- // Lazy candidates also get a manifest so the supervisor can rate-limit /
798
- // circuit-break BEFORE the first import — protects against startup-amplified
799
- // attacks.
800
- for (const name of deferred) {
801
- const cand = registry.candidate(name);
802
- if (cand) supervisor.setManifest(name, cand.manifest);
803
- }
804
- if (isFullReload) {
805
- // Full reloads are explicit operator action — clear all stats.
806
- for (const name of registry.list()) {
807
- supervisor.setManifest(name, registry.get(name)!.manifest);
808
- }
809
- }
810
-
811
- const totalMs = performance.now() - start;
812
- if (isFullReload) {
813
- const deferTag = deferred.length > 0 ? `, deferred ${deferred.length}` : "";
814
- console.log(
815
- `[1tube] Loaded ${loaded.length} function(s)${deferTag}, ` +
816
- `skipped ${skipped.length} dir(s) in ${totalMs.toFixed(0)}ms`,
817
- );
818
- const all = registry.knownNames();
819
- if (all.length > 0) {
820
- console.log(`[1tube] Functions: ${all.join(", ")}`);
821
- }
822
- } else {
823
- console.log(
824
- `[1tube] Reloaded ${loaded.length} function(s) in ${
825
- totalMs.toFixed(0)
826
- }ms ` +
827
- `(${registry.size} total registered)`,
828
- );
829
- }
830
-
831
- if (errors.length > 0) {
832
- console.error(
833
- `[1tube] ${errors.length} function(s) failed to load (check .env for missing vars):`,
834
- );
835
- for (const e of errors) {
836
- console.error(` ${e.name}: ${e.error}`);
613
+ const resolvedFunctionsPath = opts.prebuiltDir
614
+ ? ""
615
+ : await (async () => {
616
+ try {
617
+ return await Deno.realPath(opts.functionsPath);
618
+ } catch (err) {
619
+ const msg = err instanceof Error ? err.message : String(err);
620
+ console.error(`[1tube] FATAL: functions directory not found: ${opts.functionsPath} (${msg})`);
621
+ flushLogs();
622
+ Deno.exit(EXIT_CODES.CONFIG);
837
623
  }
838
- }
839
- }
624
+ })();
840
625
 
841
- // Backend selection. The workerd path skips Deno-side discovery + HMR
842
- // because functions run inside an isolated workerd subprocess, with
843
- // their own bundling pipeline; the gateway becomes a thin auth/rate-
844
- // limit/proxy layer in that mode. Default stays Deno so existing
845
- // deployments are unaffected.
626
+ // Backend selection. The workerd path runs functions inside an isolated
627
+ // workerd subprocess. The deno path runs each function in a Web Worker
628
+ // so HMR is a clean `worker.terminate()` + new Worker, and so functions
629
+ // don't share an isolate (each has its own module cache and globals).
630
+ // In both cases the gateway is a thin auth/rate-limit/proxy layer.
846
631
  let workerdBackend: WorkerdBackend | null = null;
847
632
  let workerdHotReloader: WorkerdHotReloader | null = null;
848
633
  let workerdWatchdog: WorkerdWatchdog | null = null;
849
634
  const workerdNames = new Set<string>();
635
+ let denoWorkerHost: DenoWorkerHost | null = null;
636
+ let denoHotReloader: DenoHotReloader | null = null;
850
637
 
851
638
  if (opts.backend === "workerd") {
852
639
  if (opts.workerdInspector) {
853
640
  // Surface the security implication BEFORE we bring workerd up so
854
641
  // an operator who typo'd `--inspector` on a public host has a
855
642
  // chance to ctrl-C before any code runs.
856
- const isLoopback =
857
- /^(127\.0\.0\.1|localhost|\[::1\])(:|$)/.test(opts.workerdInspector) ||
643
+ const isLoopback = /^(127\.0\.0\.1|localhost|\[::1\])(:|$)/.test(opts.workerdInspector) ||
858
644
  /^(127\.0\.0\.1|localhost):/.test(opts.workerdInspector) ||
859
645
  /^\d+$/.test(opts.workerdInspector);
860
646
  if (!isLoopback) {
@@ -884,23 +670,14 @@ if (opts.backend === "workerd") {
884
670
  `[1tube] auto-kill leftover workerd: ON (--kill-stale-workerd)`,
885
671
  );
886
672
  }
887
- if (
888
- opts.workerdCompatibilityDate ||
889
- (opts.workerdCompatibilityFlags?.length ?? 0) > 0
890
- ) {
673
+ if (opts.workerdCompatibilityDate || (opts.workerdCompatibilityFlags?.length ?? 0) > 0) {
891
674
  console.log(
892
- `[1tube] workerd compatibility: date=${
893
- opts.workerdCompatibilityDate ?? "default"
894
- } ` +
895
- `flags=${
896
- (opts.workerdCompatibilityFlags ?? []).join(",") || "default"
897
- }`,
675
+ `[1tube] workerd compatibility: date=${opts.workerdCompatibilityDate ?? "default"} ` +
676
+ `flags=${(opts.workerdCompatibilityFlags ?? []).join(",") || "default"}`,
898
677
  );
899
678
  }
900
679
  if (opts.workerdExperimental) {
901
- console.log(
902
- `[1tube] workerd process experimental mode: ON (--experimental)`,
903
- );
680
+ console.log(`[1tube] workerd process experimental mode: ON (--experimental)`);
904
681
  }
905
682
  workerdBackend = createWorkerdBackend({
906
683
  functionsDir: opts.functionsPath,
@@ -915,9 +692,7 @@ if (opts.backend === "workerd") {
915
692
  ...(opts.workerdBasePort ? { basePort: opts.workerdBasePort } : {}),
916
693
  ...(opts.workerdInspector ? { inspectorAddr: opts.workerdInspector } : {}),
917
694
  ...(opts.workerdMaxHeapMB ? { maxHeapMB: opts.workerdMaxHeapMB } : {}),
918
- ...(opts.workerdCompatibilityDate
919
- ? { compatibilityDate: opts.workerdCompatibilityDate }
920
- : {}),
695
+ ...(opts.workerdCompatibilityDate ? { compatibilityDate: opts.workerdCompatibilityDate } : {}),
921
696
  ...((opts.workerdCompatibilityFlags?.length ?? 0) > 0
922
697
  ? { compatibilityFlags: opts.workerdCompatibilityFlags }
923
698
  : {}),
@@ -928,9 +703,7 @@ if (opts.backend === "workerd") {
928
703
  logLineSink: (line) => {
929
704
  // Surface workerd's own logs through stderr with a tag so they
930
705
  // interleave with [1tube] output cleanly.
931
- try {
932
- Deno.stderr.writeSync(new TextEncoder().encode(`[workerd] ${line}\n`));
933
- } catch { /* */ }
706
+ try { Deno.stderr.writeSync(new TextEncoder().encode(`[workerd] ${line}\n`)); } catch { /* */ }
934
707
  },
935
708
  onUnexpectedExit: ({ crashCount, expectedRetry }) => {
936
709
  if (!expectedRetry) {
@@ -982,9 +755,7 @@ if (opts.backend === "workerd") {
982
755
  }
983
756
  const wdMs = performance.now() - wdStart;
984
757
  console.log(
985
- `[1tube] workerd backend ready (v${
986
- workerdBackend.workerdVersion ?? "?"
987
- }) ` +
758
+ `[1tube] workerd backend ready (v${workerdBackend.workerdVersion ?? "?"}) ` +
988
759
  `· ${workerdNames.size} function(s) in ${wdMs.toFixed(0)}ms`,
989
760
  );
990
761
  if (workerdNames.size > 0) {
@@ -997,10 +768,7 @@ if (opts.backend === "workerd") {
997
768
  // yields nothing (no manifest declares a memory hint), the
998
769
  // watchdog stays off — silently, so existing deployments aren't
999
770
  // surprised by sudden recycle behaviour.
1000
- const explicitCap = parseInt(
1001
- Deno.env.get("1TUBE_WORKERD_MAX_RSS_MB") || "",
1002
- 10,
1003
- );
771
+ const explicitCap = parseInt(Deno.env.get("1TUBE_WORKERD_MAX_RSS_MB") || "", 10);
1004
772
  const explicitBudget = Number.isFinite(explicitCap) && explicitCap > 0
1005
773
  ? explicitCap * 1024 * 1024
1006
774
  : null;
@@ -1009,16 +777,11 @@ if (opts.backend === "workerd") {
1009
777
  // boot log so operators know what the recommendation was based on.
1010
778
  let declaredSum = 0;
1011
779
  for (const m of workerdBackend.manifests.values()) {
1012
- if (typeof m.memoryMB === "number" && m.memoryMB > 0) {
1013
- declaredSum += m.memoryMB;
1014
- }
780
+ if (typeof m.memoryMB === "number" && m.memoryMB > 0) declaredSum += m.memoryMB;
1015
781
  }
1016
782
  const budget = explicitBudget ?? recommended;
1017
783
  if (budget !== null) {
1018
- const intervalMs = parseInt(
1019
- Deno.env.get("1TUBE_WORKERD_RSS_INTERVAL_MS") || "",
1020
- 10,
1021
- );
784
+ const intervalMs = parseInt(Deno.env.get("1TUBE_WORKERD_RSS_INTERVAL_MS") || "", 10);
1022
785
  workerdWatchdog = createWorkerdWatchdog({
1023
786
  backend: workerdBackend,
1024
787
  budgetBytes: budget,
@@ -1031,9 +794,7 @@ if (opts.backend === "workerd") {
1031
794
  ? "1TUBE_WORKERD_MAX_RSS_MB"
1032
795
  : `manifest sum (${declaredSum}MB × 1.5 + 64MB overhead)`;
1033
796
  console.log(
1034
- `[1tube] workerd memory watchdog ON: budget=${
1035
- (budget / 1024 / 1024).toFixed(0)
1036
- }MB (source: ${src})`,
797
+ `[1tube] workerd memory watchdog ON: budget=${(budget / 1024 / 1024).toFixed(0)}MB (source: ${src})`,
1037
798
  );
1038
799
  } else {
1039
800
  console.log(
@@ -1060,77 +821,64 @@ if (opts.backend === "workerd") {
1060
821
  }
1061
822
  }
1062
823
  } else {
824
+ // Deno backend: each function runs in its own Web Worker so HMR is
825
+ // a clean `worker.terminate()` + new Worker (fresh module cache for
826
+ // the entire dep graph). The dep-graph keeps the affected-set
827
+ // precise — touching a file under `_shared/` only restarts the
828
+ // workers that actually transitively import it.
829
+ if (opts.lazy) {
830
+ console.warn(
831
+ "[1tube] --lazy is ignored on the Deno backend (Workers are spawned eagerly at boot).",
832
+ );
833
+ }
1063
834
  console.log(`[1tube] Loading functions from: ${opts.functionsPath}`);
1064
- await reloadFunctions("initial boot", "all");
1065
- }
1066
-
1067
- let reloadTimer: number | undefined;
1068
- let isReloading = false;
1069
- const pendingChanges = new Set<string>();
1070
- let pendingSharedChange = false;
1071
-
1072
- function scheduleReload(paths: string[]) {
1073
- for (const p of paths) {
1074
- const name = classifyChangedPath(p);
1075
- if (name === null) {
1076
- pendingSharedChange = true;
1077
- } else {
1078
- pendingChanges.add(name);
835
+ // Pull the deno.json import map (if any) so the dep-graph can
836
+ // resolve bare specifiers to their actual file URLs. Best-effort:
837
+ // missing or malformed JSON degrades to "no import map", which is
838
+ // fine for projects that use only relative imports.
839
+ const denoConfigPath = `${Deno.cwd()}/deno.json`;
840
+ let importMap: Record<string, string> | undefined;
841
+ try {
842
+ const raw = await Deno.readTextFile(denoConfigPath);
843
+ const parsed = JSON.parse(raw) as { imports?: Record<string, string> };
844
+ if (parsed.imports && typeof parsed.imports === "object") {
845
+ importMap = parsed.imports;
1079
846
  }
847
+ } catch {
848
+ // No host deno.json or it isn't JSON; proceed without an import map.
1080
849
  }
1081
- if (!pendingSharedChange && pendingChanges.size === 0) return;
1082
850
 
1083
- if (reloadTimer !== undefined) {
1084
- clearTimeout(reloadTimer);
851
+ denoWorkerHost = createDenoWorkerHost({
852
+ functionsDir: opts.functionsPath,
853
+ registry,
854
+ supervisor,
855
+ ...(importMap ? { importMap, importMapBase: denoConfigPath } : {}),
856
+ });
857
+ const bootStart = performance.now();
858
+ const { loaded, errors } = await denoWorkerHost.start();
859
+ const bootMs = performance.now() - bootStart;
860
+ console.log(
861
+ `[1tube] Loaded ${loaded.length} function(s) in ${bootMs.toFixed(0)}ms`,
862
+ );
863
+ if (loaded.length > 0) {
864
+ console.log(`[1tube] Functions: ${[...loaded].sort().join(", ")}`);
1085
865
  }
1086
- reloadTimer = setTimeout(async () => {
1087
- reloadTimer = undefined;
1088
-
1089
- if (isReloading) return;
1090
-
1091
- const sharedChanged = pendingSharedChange;
1092
- const names = new Set(pendingChanges);
1093
- pendingSharedChange = false;
1094
- pendingChanges.clear();
1095
-
1096
- isReloading = true;
1097
- try {
1098
- const reason = sharedChanged
1099
- ? `shared change (+${names.size} function(s))`
1100
- : `${names.size} function(s) changed`;
1101
- await reloadFunctions(reason, sharedChanged ? "all" : names);
1102
- } catch (err) {
1103
- console.error("[1tube] Reload failed:", err);
1104
- } finally {
1105
- isReloading = false;
1106
- if (pendingSharedChange || pendingChanges.size > 0) {
1107
- scheduleReload([]);
1108
- }
1109
- }
1110
- }, 200);
1111
- }
1112
-
1113
- let watcherAbort: AbortController | undefined;
1114
-
1115
- async function watchFunctions() {
1116
- watcherAbort = new AbortController();
1117
- try {
1118
- const watcher = Deno.watchFs(opts.functionsPath, { recursive: true });
1119
- console.log(`[1tube] HMR watching: ${opts.functionsPath}`);
1120
- for await (const event of watcher) {
1121
- if (watcherAbort.signal.aborted) break;
1122
- if (event.paths.length === 0) continue;
1123
- scheduleReload(event.paths);
1124
- }
1125
- } catch (err) {
1126
- console.warn("[1tube] HMR watcher disabled:", err);
866
+ if (errors.length > 0) {
867
+ console.error(
868
+ `[1tube] ${errors.length} function(s) failed to load (check .env for missing vars):`,
869
+ );
870
+ for (const e of errors) console.error(` ${e.name}: ${e.error}`);
1127
871
  }
1128
- }
1129
872
 
1130
- if (opts.hmr && opts.backend === "deno") {
1131
- watchFunctions();
1132
- } else if (opts.backend === "deno") {
1133
- console.log("[1tube] HMR disabled (set 1TUBE_HMR=1 or pass --hmr to enable)");
873
+ if (opts.hmr) {
874
+ denoHotReloader = createDenoHotReloader({
875
+ host: denoWorkerHost,
876
+ functionsDir: opts.functionsPath,
877
+ });
878
+ await denoHotReloader.start();
879
+ } else {
880
+ console.log("[1tube] HMR disabled (set 1TUBE_HMR=1 or pass --hmr to enable)");
881
+ }
1134
882
  }
1135
883
 
1136
884
  // ---------------------------------------------------------------------------
@@ -1153,9 +901,7 @@ const rpmOverride = Number.isFinite(defaultRpmEnv) && defaultRpmEnv > 0
1153
901
  ? defaultRpmEnv
1154
902
  : null;
1155
903
  if (rpmOverride !== null) {
1156
- console.log(
1157
- `[1tube] default rpm overridden via 1TUBE_DEFAULT_RPM=${rpmOverride}`,
1158
- );
904
+ console.log(`[1tube] default rpm overridden via 1TUBE_DEFAULT_RPM=${rpmOverride}`);
1159
905
  }
1160
906
  const rateLimiter = createRateLimiter({
1161
907
  registry,
@@ -1171,9 +917,10 @@ const rateLimiter = createRateLimiter({
1171
917
  // auth here — the dispatcher decides per function based on `isPublic`)
1172
918
  // 6. Rate limiter (now sees a real userId for per-user keying)
1173
919
  app.use("/functions/v1/*", corsMiddleware);
1174
- // Backend-aware function-existence probe. On Deno backend, the registry
1175
- // owns the truth; on workerd backend, the bundled-set captured at boot
1176
- // is the source. Both expose the same Map-lookup-cost contract.
920
+ // Backend-aware function-existence probe. On the workerd backend the
921
+ // bundled-set captured at boot is the source; on the deno backend the
922
+ // registry's worker-handle map is. Both expose the same Map-lookup-cost
923
+ // contract.
1177
924
  const functionExists = (name: string): boolean =>
1178
925
  opts.backend === "workerd" ? workerdNames.has(name) : registry.has(name);
1179
926
 
@@ -1249,9 +996,7 @@ app.all("/functions/v1/:name{.+}", async (c) => {
1249
996
  const decision = supervisor.admit(name);
1250
997
  if (!decision.ok) {
1251
998
  const headers: Record<string, string> = {};
1252
- if (decision.retryAfter) {
1253
- headers["Retry-After"] = String(decision.retryAfter);
1254
- }
999
+ if (decision.retryAfter) headers["Retry-After"] = String(decision.retryAfter);
1255
1000
  return c.json(
1256
1001
  { error: "Service temporarily unavailable", reason: decision.reason },
1257
1002
  decision.status as 503,
@@ -1263,22 +1008,18 @@ app.all("/functions/v1/:name{.+}", async (c) => {
1263
1008
  const rawBody = c.req.raw.body;
1264
1009
  const wrappedBody = rawBody !== null
1265
1010
  ? watchdogBody(rawBody, opts.bodyReadIdleMs, abort, (idleMs) => {
1266
- console.warn(
1267
- `[1tube] Body read stalled in "${name}" (no progress in ${idleMs}ms) — aborting request.`,
1268
- );
1269
- }).body
1011
+ console.warn(
1012
+ `[1tube] Body read stalled in "${name}" (no progress in ${idleMs}ms) — aborting request.`,
1013
+ );
1014
+ }).body
1270
1015
  : null;
1271
1016
  // Mirror the Deno-path URL rewrite: user code expects to see the
1272
1017
  // pathname without the `/functions/v1` prefix, matching Supabase
1273
1018
  // Edge Runtime behaviour. Without this strip, hello/echo see the
1274
1019
  // gateway prefix in their `new URL(req.url).pathname`.
1275
1020
  const originalUrl = new URL(c.req.raw.url);
1276
- const rewrittenPath =
1277
- originalUrl.pathname.replace(/^\/functions\/v1/, "") || "/";
1278
- const rewrittenUrl = new URL(
1279
- rewrittenPath + originalUrl.search,
1280
- originalUrl.origin,
1281
- );
1021
+ const rewrittenPath = originalUrl.pathname.replace(/^\/functions\/v1/, "") || "/";
1022
+ const rewrittenUrl = new URL(rewrittenPath + originalUrl.search, originalUrl.origin);
1282
1023
  const proxyReq = new Request(rewrittenUrl.toString(), {
1283
1024
  method: c.req.raw.method,
1284
1025
  headers: c.req.raw.headers,
@@ -1293,36 +1034,20 @@ app.all("/functions/v1/:name{.+}", async (c) => {
1293
1034
  // gateway-wide default. Same precedence the Deno path uses.
1294
1035
  const timeoutMs = wdManifest?.timeoutMs ?? opts.defaultTimeoutMs;
1295
1036
  const timer = timeoutMs > 0
1296
- ? setTimeout(
1297
- () =>
1298
- abort.abort(
1299
- new Error(`workerd dispatch timed out after ${timeoutMs}ms`),
1300
- ),
1301
- timeoutMs,
1302
- )
1037
+ ? setTimeout(() => abort.abort(new Error(`workerd dispatch timed out after ${timeoutMs}ms`)), timeoutMs)
1303
1038
  : null;
1304
1039
 
1305
1040
  let response: Response;
1306
1041
  let errored = false;
1307
1042
  try {
1308
- response = await workerdBackend.dispatch(
1309
- proxyReq,
1310
- name,
1311
- auth,
1312
- abort.signal,
1313
- );
1043
+ response = await workerdBackend.dispatch(proxyReq, name, auth, abort.signal);
1314
1044
  } catch (err) {
1315
1045
  errored = true;
1316
- const reasonMsg = (abort.signal.reason as Error | undefined)?.message ??
1317
- "";
1318
- if (
1319
- err instanceof DOMException && err.name === "AbortError" &&
1320
- reasonMsg.includes("timed out")
1321
- ) {
1046
+ const reasonMsg = (abort.signal.reason as Error | undefined)?.message ?? "";
1047
+ if (err instanceof DOMException && err.name === "AbortError" && reasonMsg.includes("timed out")) {
1322
1048
  response = c.json({ error: "Function execution timed out" }, 504);
1323
1049
  } else if (
1324
- err instanceof DOMException && err.name === "AbortError" &&
1325
- reasonMsg.includes("Body read stalled")
1050
+ err instanceof DOMException && err.name === "AbortError" && reasonMsg.includes("Body read stalled")
1326
1051
  ) {
1327
1052
  response = c.json({ error: "Request body read timed out" }, 408);
1328
1053
  } else {
@@ -1343,7 +1068,9 @@ app.all("/functions/v1/:name{.+}", async (c) => {
1343
1068
  const outcome = supervisor.record(name, errored);
1344
1069
  if (outcome.breakerJustTripped) {
1345
1070
  console.warn(
1346
- `[1tube] Circuit breaker OPEN for "${name}" (workerd) — refusing requests for ${wdManifest.recycle.cooldownMs}ms (errorRate >= ${wdManifest.recycle.errorRate}).`,
1071
+ `[1tube] Circuit breaker OPEN for "${name}" (workerd) — refusing requests for ${
1072
+ wdManifest.recycle.cooldownMs
1073
+ }ms (errorRate >= ${wdManifest.recycle.errorRate}).`,
1347
1074
  );
1348
1075
  }
1349
1076
  if (outcome.recycleJustRecommended) {
@@ -1356,19 +1083,12 @@ app.all("/functions/v1/:name{.+}", async (c) => {
1356
1083
  return response;
1357
1084
  }
1358
1085
 
1359
- // Deno backend dispatch path (unchanged from pre-workerd behaviour).
1360
- // getOrLoad triggers a one-time dynamic import for lazy candidates; subsequent
1361
- // calls are a hot map lookup. Concurrent first-requests dedupe on the same
1362
- // in-flight Promise.
1363
- let fn: Awaited<ReturnType<typeof registry.getOrLoad>>;
1364
- try {
1365
- fn = await registry.getOrLoad(name);
1366
- } catch (err) {
1367
- console.error(`[1tube] Lazy import of "${name}" failed:`, err);
1368
- return c.json({ error: "Function failed to load" }, 500);
1369
- }
1370
-
1371
- if (!fn) {
1086
+ // Deno backend dispatch path: forward the request to the function's
1087
+ // Worker over postMessage. Each function lives in its own Worker so
1088
+ // there's no in-process handler to invoke; HMR works by terminating
1089
+ // the worker (clean module cache) and spawning a fresh one.
1090
+ const handle = registry.workerHandle(name);
1091
+ if (!handle) {
1372
1092
  return c.json({ error: `Function "${name}" not found` }, 404);
1373
1093
  }
1374
1094
 
@@ -1376,9 +1096,7 @@ app.all("/functions/v1/:name{.+}", async (c) => {
1376
1096
  const decision = supervisor.admit(name);
1377
1097
  if (!decision.ok) {
1378
1098
  const headers: Record<string, string> = {};
1379
- if (decision.retryAfter) {
1380
- headers["Retry-After"] = String(decision.retryAfter);
1381
- }
1099
+ if (decision.retryAfter) headers["Retry-After"] = String(decision.retryAfter);
1382
1100
  return c.json(
1383
1101
  { error: "Service temporarily unavailable", reason: decision.reason },
1384
1102
  decision.status as 503,
@@ -1386,34 +1104,26 @@ app.all("/functions/v1/:name{.+}", async (c) => {
1386
1104
  );
1387
1105
  }
1388
1106
 
1389
- const timeoutMs = fn.timeoutMs ?? fn.manifest.timeoutMs ??
1107
+ const timeoutMs = handle.timeoutMs ?? handle.manifest.timeoutMs ??
1390
1108
  opts.defaultTimeoutMs;
1391
1109
 
1392
1110
  // Strip /functions/v1 prefix to match Supabase Edge Runtime behavior.
1393
1111
  const originalUrl = new URL(c.req.raw.url);
1394
- const rewrittenPath = originalUrl.pathname.replace(/^\/functions\/v1/, "") ||
1395
- "/";
1396
- const rewrittenUrl = new URL(
1397
- rewrittenPath + originalUrl.search,
1398
- originalUrl.origin,
1399
- );
1112
+ const rewrittenPath = originalUrl.pathname.replace(/^\/functions\/v1/, "") || "/";
1113
+ const rewrittenUrl = new URL(rewrittenPath + originalUrl.search, originalUrl.origin);
1400
1114
 
1401
1115
  const abort = new AbortController();
1402
1116
 
1403
- // Wrap an actual body stream with the slow-loris watchdog. Body-less
1404
- // methods (GET / HEAD / DELETE-without-body) skip wrapping — there's
1405
- // nothing to read. The watchdog resets its idle timer on every chunk;
1406
- // if no chunk arrives within `bodyReadIdleMs`, it aborts the same
1407
- // signal the handler is already wired to, so `req.json()` /
1408
- // `req.text()` / `req.arrayBuffer()` reject with AbortError and the
1409
- // request slot is freed immediately.
1117
+ // Slow-loris watchdog around any actual body stream, same as the
1118
+ // workerd path. The watchdog aborts the request signal we forward to
1119
+ // the worker if the client stalls mid-upload.
1410
1120
  const rawBody = c.req.raw.body;
1411
1121
  const wrappedBody = rawBody !== null
1412
1122
  ? watchdogBody(rawBody, opts.bodyReadIdleMs, abort, (idleMs) => {
1413
- console.warn(
1414
- `[1tube] Body read stalled in "${name}" (no progress in ${idleMs}ms) — aborting request.`,
1415
- );
1416
- }).body
1123
+ console.warn(
1124
+ `[1tube] Body read stalled in "${name}" (no progress in ${idleMs}ms) — aborting request.`,
1125
+ );
1126
+ }).body
1417
1127
  : null;
1418
1128
 
1419
1129
  const rewrittenReq = new Request(rewrittenUrl.toString(), {
@@ -1425,67 +1135,45 @@ app.all("/functions/v1/:name{.+}", async (c) => {
1425
1135
  duplex: "half",
1426
1136
  });
1427
1137
 
1428
- const invoke = (): Promise<Response> => {
1429
- if (!fn.isPublic) {
1430
- const auth = (c as any).get("__authContext");
1431
- if (!auth) {
1432
- // Surfaced to the caller via the outer try/catch path below.
1433
- return Promise.resolve(c.json({ error: "Unauthorized" }, 401));
1434
- }
1435
- return Promise.resolve((fn.handler as any)(rewrittenReq, auth));
1436
- }
1437
- return Promise.resolve((fn.handler as any)(rewrittenReq));
1438
- };
1138
+ const auth = (c as any).get("__authContext") ?? null;
1139
+ if (!handle.isPublic && !auth) {
1140
+ return c.json({ error: "Unauthorized" }, 401);
1141
+ }
1439
1142
 
1440
- // Bind the function name as the active async context for the entire handler
1441
- // call tree. This lets the global `unhandledrejection` listener attribute
1442
- // orphan rejections (from setTimeout/fire-and-forget Promises inside the
1443
- // function) to the correct function — preserved across awaits in 2.7+.
1444
- const inner = enforceManifest && !allowAll
1445
- ? () =>
1446
- runWithEnvScope(
1447
- { functionName: name, allow: new Set(fn.manifest.permissions.env) },
1448
- invoke,
1449
- )
1450
- : invoke;
1451
-
1452
- const handlerPromise = Promise.resolve(
1453
- currentRequestStorage.run({ functionName: name }, inner),
1454
- ) as Promise<Response>;
1143
+ const timer = timeoutMs > 0
1144
+ ? setTimeout(
1145
+ () =>
1146
+ abort.abort(
1147
+ new Error(`Function "${name}" timed out after ${timeoutMs}ms`),
1148
+ ),
1149
+ timeoutMs,
1150
+ )
1151
+ : null;
1455
1152
 
1456
1153
  let response: Response;
1457
1154
  let errored = false;
1458
1155
  try {
1459
- if (timeoutMs > 0) {
1460
- const timeout = new Promise<never>((_, reject) =>
1461
- setTimeout(() => {
1462
- abort.abort();
1463
- reject(
1464
- new Error(`Function "${name}" timed out after ${timeoutMs}ms`),
1465
- );
1466
- }, timeoutMs)
1467
- );
1468
- response = await Promise.race([handlerPromise, timeout]);
1469
- } else {
1470
- response = await handlerPromise;
1471
- }
1156
+ response = await handle.dispatch(rewrittenReq, auth, abort.signal);
1472
1157
  } catch (err) {
1473
1158
  errored = true;
1474
- if (err instanceof Error && err.message.includes("timed out")) {
1475
- console.error(`[1tube] ${err.message}`);
1159
+ const reasonMsg = (abort.signal.reason as Error | undefined)?.message ?? "";
1160
+ if (
1161
+ err instanceof DOMException && err.name === "AbortError" &&
1162
+ reasonMsg.includes("timed out")
1163
+ ) {
1164
+ console.error(`[1tube] Function "${name}" timed out after ${timeoutMs}ms`);
1476
1165
  response = c.json({ error: "Function execution timed out" }, 504);
1477
1166
  } else if (
1478
1167
  err instanceof DOMException && err.name === "AbortError" &&
1479
- err.message.includes("Body read stalled")
1168
+ reasonMsg.includes("Body read stalled")
1480
1169
  ) {
1481
- // Slow-loris watchdog tripped. The handler was awaiting the body and
1482
- // the abort propagated up. Return 408 so legitimate clients can retry
1483
- // — 503 would imply we'd accept a retry from the same offender.
1484
1170
  response = c.json({ error: "Request body read timed out" }, 408);
1485
1171
  } else {
1486
1172
  console.error(`[1tube] Unhandled error in "${name}":`, err);
1487
1173
  response = c.json({ error: "Internal server error" }, 500);
1488
1174
  }
1175
+ } finally {
1176
+ if (timer !== null) clearTimeout(timer);
1489
1177
  }
1490
1178
 
1491
1179
  if (response.status >= 500) errored = true;
@@ -1493,12 +1181,14 @@ app.all("/functions/v1/:name{.+}", async (c) => {
1493
1181
  const outcome = supervisor.record(name, errored);
1494
1182
  if (outcome.breakerJustTripped) {
1495
1183
  console.warn(
1496
- `[1tube] Circuit breaker OPEN for "${name}" — refusing requests for ${fn.manifest.recycle.cooldownMs}ms (errorRate >= ${fn.manifest.recycle.errorRate}).`,
1184
+ `[1tube] Circuit breaker OPEN for "${name}" — refusing requests for ${
1185
+ handle.manifest.recycle.cooldownMs
1186
+ }ms (errorRate >= ${handle.manifest.recycle.errorRate}).`,
1497
1187
  );
1498
1188
  }
1499
1189
  if (outcome.recycleJustRecommended) {
1500
1190
  console.warn(
1501
- `[1tube] "${name}" reached ${fn.manifest.recycle.maxRequests} invocations — recycle recommended.`,
1191
+ `[1tube] "${name}" reached ${handle.manifest.recycle.maxRequests} invocations — recycle recommended.`,
1502
1192
  );
1503
1193
  }
1504
1194
 
@@ -1556,16 +1246,11 @@ app.get(
1556
1246
  }),
1557
1247
  );
1558
1248
 
1559
- async function runFunctionAdmin(
1560
- c: Context,
1561
- action: (backend: WorkerdBackend) => Promise<Response>,
1562
- ): Promise<Response> {
1249
+ async function runFunctionAdmin(c: Context, action: (backend: WorkerdBackend) => Promise<Response>): Promise<Response> {
1563
1250
  const forbidden = requireInternal(c);
1564
1251
  if (forbidden) return forbidden;
1565
1252
  if (!workerdBackend) {
1566
- return c.json({
1567
- error: "Edge function editor is available only with backend=workerd",
1568
- }, 503);
1253
+ return c.json({ error: "Edge function editor is available only with backend=workerd" }, 503);
1569
1254
  }
1570
1255
  try {
1571
1256
  return await action(workerdBackend);
@@ -1575,23 +1260,14 @@ async function runFunctionAdmin(
1575
1260
  }
1576
1261
  }
1577
1262
 
1578
- app.get(
1579
- "/1tube/api/functions",
1580
- (c) =>
1581
- runFunctionAdmin(c, async (backend) =>
1582
- c.json({
1583
- functions: await backend.listEditableFunctions(),
1584
- })),
1263
+ app.get("/1tube/api/functions", (c) =>
1264
+ runFunctionAdmin(c, async (backend) => c.json({
1265
+ functions: await backend.listEditableFunctions(),
1266
+ }))
1585
1267
  );
1586
1268
 
1587
- app.get(
1588
- "/1tube/api/functions/:name/source",
1589
- (c) =>
1590
- runFunctionAdmin(
1591
- c,
1592
- async (backend) =>
1593
- c.json(await backend.readEditableSource(c.req.param("name"))),
1594
- ),
1269
+ app.get("/1tube/api/functions/:name/source", (c) =>
1270
+ runFunctionAdmin(c, async (backend) => c.json(await backend.readEditableSource(c.req.param("name"))))
1595
1271
  );
1596
1272
 
1597
1273
  app.post("/1tube/api/functions", (c) =>
@@ -1601,58 +1277,36 @@ app.post("/1tube/api/functions", (c) =>
1601
1277
  return c.json({ error: "Missing function name" }, 400);
1602
1278
  }
1603
1279
  const source = await backend.createEditableFunction(body.name);
1604
- return c.json(
1605
- { source, functions: await backend.listEditableFunctions() },
1606
- 201,
1280
+ return c.json({ source, functions: await backend.listEditableFunctions() }, 201);
1281
+ })
1282
+ );
1283
+
1284
+ app.put("/1tube/api/functions/:name/source", (c) =>
1285
+ runFunctionAdmin(c, async (backend) => {
1286
+ const body = await c.req.json().catch(() => ({})) as { files?: unknown };
1287
+ if (!body.files || typeof body.files !== "object" || Array.isArray(body.files)) {
1288
+ return c.json({ error: "Expected JSON body { files: { path: content } }" }, 400);
1289
+ }
1290
+ const reload = await backend.saveEditableSource(
1291
+ c.req.param("name"),
1292
+ { files: body.files as Record<string, string> },
1607
1293
  );
1608
- }));
1609
-
1610
- app.put(
1611
- "/1tube/api/functions/:name/source",
1612
- (c) =>
1613
- runFunctionAdmin(c, async (backend) => {
1614
- const body = await c.req.json().catch(() => ({})) as { files?: unknown };
1615
- if (
1616
- !body.files || typeof body.files !== "object" ||
1617
- Array.isArray(body.files)
1618
- ) {
1619
- return c.json({
1620
- error: "Expected JSON body { files: { path: content } }",
1621
- }, 400);
1622
- }
1623
- const reload = await backend.saveEditableSource(
1624
- c.req.param("name"),
1625
- { files: body.files as Record<string, string> },
1626
- );
1627
- return c.json({
1628
- reload,
1629
- functions: await backend.listEditableFunctions(),
1630
- });
1631
- }),
1294
+ return c.json({ reload, functions: await backend.listEditableFunctions() });
1295
+ })
1632
1296
  );
1633
1297
 
1634
- app.delete(
1635
- "/1tube/api/functions/:name",
1636
- (c) =>
1637
- runFunctionAdmin(c, async (backend) => {
1638
- const reload = await backend.deleteEditableFunction(c.req.param("name"));
1639
- return c.json({
1640
- reload,
1641
- functions: await backend.listEditableFunctions(),
1642
- });
1643
- }),
1298
+ app.delete("/1tube/api/functions/:name", (c) =>
1299
+ runFunctionAdmin(c, async (backend) => {
1300
+ const reload = await backend.deleteEditableFunction(c.req.param("name"));
1301
+ return c.json({ reload, functions: await backend.listEditableFunctions() });
1302
+ })
1644
1303
  );
1645
1304
 
1646
- app.post(
1647
- "/1tube/api/functions/:name/revert",
1648
- (c) =>
1649
- runFunctionAdmin(c, async (backend) => {
1650
- const reload = await backend.revertEditableFunction(c.req.param("name"));
1651
- return c.json({
1652
- reload,
1653
- functions: await backend.listEditableFunctions(),
1654
- });
1655
- }),
1305
+ app.post("/1tube/api/functions/:name/revert", (c) =>
1306
+ runFunctionAdmin(c, async (backend) => {
1307
+ const reload = await backend.revertEditableFunction(c.req.param("name"));
1308
+ return c.json({ reload, functions: await backend.listEditableFunctions() });
1309
+ })
1656
1310
  );
1657
1311
 
1658
1312
  // Root liveness probe — intentionally minimal so we don't leak the registered
@@ -1681,14 +1335,9 @@ async function shutdown(reason: string) {
1681
1335
  if (shuttingDown) return;
1682
1336
  shuttingDown = true;
1683
1337
  const totalGraceMs = opts.shutdownGraceMs;
1684
- console.log(
1685
- `[1tube] Shutdown requested (${reason}); draining up to ${totalGraceMs}ms...`,
1686
- );
1338
+ console.log(`[1tube] Shutdown requested (${reason}); draining up to ${totalGraceMs}ms...`);
1687
1339
  const shutdownStartedAt = performance.now();
1688
- const remaining = () =>
1689
- Math.max(0, totalGraceMs - (performance.now() - shutdownStartedAt));
1690
-
1691
- watcherAbort?.abort();
1340
+ const remaining = () => Math.max(0, totalGraceMs - (performance.now() - shutdownStartedAt));
1692
1341
 
1693
1342
  // Stop the workerd watchdog FIRST so a poll racing this shutdown
1694
1343
  // can't observe a half-down workerd and trigger a doomed recycle
@@ -1700,6 +1349,11 @@ async function shutdown(reason: string) {
1700
1349
  console.warn("[1tube] workerd hot reloader stop() error:", err);
1701
1350
  });
1702
1351
  }
1352
+ if (denoHotReloader) {
1353
+ denoHotReloader.stop().catch((err) => {
1354
+ console.warn("[1tube] deno hot reloader stop() error:", err);
1355
+ });
1356
+ }
1703
1357
 
1704
1358
  // Phase 1: stop accepting NEW connections. Already-accepted
1705
1359
  // requests keep running against the still-live workerd. This is
@@ -1714,9 +1368,7 @@ async function shutdown(reason: string) {
1714
1368
  const drainStart = performance.now();
1715
1369
  const drained = await Promise.race([
1716
1370
  server.finished.then(() => "drained" as const),
1717
- new Promise<"timeout">((resolve) =>
1718
- setTimeout(() => resolve("timeout"), remaining())
1719
- ),
1371
+ new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), remaining())),
1720
1372
  ]);
1721
1373
  const drainMs = performance.now() - drainStart;
1722
1374
  if (drained === "timeout") {
@@ -1725,11 +1377,7 @@ async function shutdown(reason: string) {
1725
1377
  `forcing workerd teardown — some in-flight requests may have been dropped.`,
1726
1378
  );
1727
1379
  } else {
1728
- console.log(
1729
- `[1tube] Gateway drained ${
1730
- drainMs.toFixed(0)
1731
- }ms; tearing down workerd...`,
1732
- );
1380
+ console.log(`[1tube] Gateway drained ${drainMs.toFixed(0)}ms; tearing down workerd...`);
1733
1381
  }
1734
1382
 
1735
1383
  // Phase 3: stop the workerd subprocess. Now safe to tear down —
@@ -1746,20 +1394,25 @@ async function shutdown(reason: string) {
1746
1394
  console.warn("[1tube] workerd backend stop() error:", err);
1747
1395
  return "stopped" as const;
1748
1396
  }),
1749
- new Promise<"timeout">((resolve) =>
1750
- setTimeout(() => resolve("timeout"), wdGraceMs)
1751
- ),
1397
+ new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), wdGraceMs)),
1752
1398
  ]);
1753
1399
  const wdMs = performance.now() - wdStart;
1754
1400
  if (wdResult === "timeout") {
1755
- console.warn(
1756
- `[1tube] workerd stop() did not return within ${wdGraceMs}ms — abandoning.`,
1757
- );
1401
+ console.warn(`[1tube] workerd stop() did not return within ${wdGraceMs}ms — abandoning.`);
1758
1402
  } else {
1759
1403
  console.log(`[1tube] workerd stopped in ${wdMs.toFixed(0)}ms.`);
1760
1404
  }
1761
1405
  }
1762
1406
 
1407
+ // Phase 3 (deno): terminate every per-function Worker. Bounded by
1408
+ // whatever shutdown grace remains; teardown is fast (just SIGKILL
1409
+ // equivalents in V8 isolate-land) so we don't bother racing it.
1410
+ if (denoWorkerHost) {
1411
+ await denoWorkerHost.stop().catch((err) => {
1412
+ console.warn("[1tube] deno worker host stop() error:", err);
1413
+ });
1414
+ }
1415
+
1763
1416
  flushLogs();
1764
1417
  console.log("[1tube] Drain complete; exiting.");
1765
1418
  flushLogs();