1tube 0.1.40 → 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.40",
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
 
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Build-time capture of each function's declared `serve()` options for the
3
+ * Vercel target.
4
+ *
5
+ * On Vercel each function runs standalone, so `_shared/handler.ts` takes the
6
+ * `getStandaloneServe()` path and the gateway registry is never installed —
7
+ * which means a `serve({ timeoutMs })` value would otherwise be invisible to the
8
+ * static build that bakes `maxDuration` into `.vc-config.json`.
9
+ *
10
+ * Rather than parse source or require a side-car `1tube.json`, we read the value
11
+ * the function ACTUALLY declares, through the same contract the runtime uses:
12
+ * `_shared/handler.ts` calls `globalThis.__edgeFunctionRegistry.register(handler,
13
+ * { public, timeoutMs })` at import time whenever that global exists. We install
14
+ * a capturing stub, import each entrypoint once, and record what it registered.
15
+ *
16
+ * Cost & safety:
17
+ * - Runs ONLY at build time — the deployed function pays nothing.
18
+ * - Imports each entrypoint a single time; Deno caches the shared module graph
19
+ * (`handler.ts`, providers, …) across functions, so the marginal cost per
20
+ * function is just its own top-level `serve(...)` call.
21
+ * - The registry path returns before binding any server, so importing does not
22
+ * start listeners or do per-request work.
23
+ * - Resilient: a function that throws at import (e.g. a stray top-level env
24
+ * read) is skipped with a warning; the caller falls back to its manifest /
25
+ * the global default.
26
+ */
27
+
28
+ import { pathToFileURL } from "node:url";
29
+ import type { BundleInput } from "../../bundler/core.ts";
30
+
31
+ /** What a function declared on its `serve({ ... })` call. */
32
+ export interface DeclaredServeConfig {
33
+ public: boolean;
34
+ /** Per-function wall-clock budget (ms). Undefined = no explicit timeout. */
35
+ timeoutMs?: number;
36
+ }
37
+
38
+ interface RegistryStub {
39
+ register(
40
+ handler: unknown,
41
+ opts?: { public?: boolean; timeoutMs?: number },
42
+ ): void;
43
+ }
44
+
45
+ const REGISTRY_KEY = "__edgeFunctionRegistry";
46
+
47
+ /**
48
+ * Minimal stand-in for `Deno.serve`'s return value so importing an entrypoint
49
+ * that calls `Deno.serve` directly (instead of the registry `serve()` wrapper)
50
+ * does not bind a real port during the build.
51
+ */
52
+ function fakeHttpServer(): unknown {
53
+ return {
54
+ finished: Promise.resolve(),
55
+ shutdown: () => Promise.resolve(),
56
+ ref() {},
57
+ unref() {},
58
+ addr: { transport: "tcp", hostname: "0.0.0.0", port: 0 },
59
+ };
60
+ }
61
+
62
+ /**
63
+ * Import every entrypoint with a stub registry installed and collect the
64
+ * `serve()` options each one registers. Returns a `name → config` map; functions
65
+ * that don't register (or fail to import) are simply absent.
66
+ */
67
+ export async function captureDeclaredServeConfigs(
68
+ inputs: readonly BundleInput[],
69
+ onWarn: (message: string) => void = (m) => console.warn(m),
70
+ ): Promise<Map<string, DeclaredServeConfig>> {
71
+ const out = new Map<string, DeclaredServeConfig>();
72
+ if (inputs.length === 0) return out;
73
+
74
+ const globalAny = globalThis as Record<string, unknown>;
75
+ const previousRegistry = globalAny[REGISTRY_KEY];
76
+
77
+ let current: DeclaredServeConfig | undefined;
78
+ const stub: RegistryStub = {
79
+ register(_handler, opts) {
80
+ current = { public: !!opts?.public, timeoutMs: opts?.timeoutMs };
81
+ },
82
+ };
83
+ globalAny[REGISTRY_KEY] = stub;
84
+
85
+ // The registry path never calls Deno.serve, but neutralise it anyway so a raw
86
+ // `Deno.serve(...)` entrypoint can't bind a port (or leak a listener) during
87
+ // the sweep. Snapshot/restore around the whole pass.
88
+ const denoNs = (globalThis as { Deno?: { serve?: unknown } }).Deno;
89
+ const hadServe = !!denoNs && "serve" in denoNs;
90
+ const previousServe = denoNs?.serve;
91
+ if (denoNs) denoNs.serve = fakeHttpServer as unknown as typeof denoNs.serve;
92
+
93
+ try {
94
+ for (const input of inputs) {
95
+ current = undefined;
96
+ try {
97
+ await import(pathToFileURL(input.entrypoint).href);
98
+ } catch (err) {
99
+ onWarn(
100
+ `could not load ${input.name} to read its serve() timeout; ` +
101
+ `falling back to manifest/default (${
102
+ err instanceof Error ? err.message : String(err)
103
+ })`,
104
+ );
105
+ continue;
106
+ }
107
+ if (current) out.set(input.name, current);
108
+ }
109
+ } finally {
110
+ if (previousRegistry === undefined) delete globalAny[REGISTRY_KEY];
111
+ else globalAny[REGISTRY_KEY] = previousRegistry;
112
+ if (denoNs) {
113
+ if (hadServe) {
114
+ denoNs.serve = previousServe as typeof denoNs.serve;
115
+ } else {
116
+ delete denoNs.serve;
117
+ }
118
+ }
119
+ }
120
+
121
+ return out;
122
+ }