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 +5 -5
- package/package.json +2 -2
- package/src/backends/deno/worker-host.ts +251 -29
- package/src/backends/vercel/capture-config.ts +122 -0
- package/src/boot-progress.ts +99 -0
- package/src/cli/vercel-build.ts +185 -10
- package/src/gateway/cors.ts +95 -11
- package/src/gateway/logging.ts +3 -2
- package/src/gateway/rate-limit.ts +6 -8
- package/src/gateway/route-prefix.ts +83 -0
- package/src/registry.ts +7 -1
- package/src/server.ts +90 -15
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.
|
|
24
|
-
"npm:hono@^4.12.
|
|
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.
|
|
519
|
-
"integrity": "sha512-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
/**
|
|
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
|
-
//
|
|
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
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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
|
-
|
|
270
|
-
|
|
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
|
-
|
|
652
|
+
resolved: string,
|
|
653
|
+
onFound?: (count: number) => void,
|
|
507
654
|
): Promise<DiscoveredCandidate[]> {
|
|
508
|
-
|
|
509
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
672
|
+
return;
|
|
518
673
|
}
|
|
519
|
-
const manifest = await loadManifest(resolved,
|
|
674
|
+
const manifest = await loadManifest(resolved, name).catch(() =>
|
|
520
675
|
defaultManifest()
|
|
521
676
|
);
|
|
522
677
|
out.push({
|
|
523
|
-
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
1114
|
-
: await discoverNamed(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|