1tube 0.1.41 → 0.1.42
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/deno.lock +5 -5
- package/package.json +2 -2
- package/src/backends/deno/worker-host.ts +251 -29
- package/src/boot-progress.ts +99 -0
- 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
|
|
package/src/boot-progress.ts
CHANGED
|
@@ -129,3 +129,102 @@ export function createBootProgress(
|
|
|
129
129
|
},
|
|
130
130
|
};
|
|
131
131
|
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Live "scanning" indicator for the pre-spawn boot phase (directory
|
|
135
|
+
* discovery + the optional batch dep-graph build).
|
|
136
|
+
*
|
|
137
|
+
* Unlike {@link createBootProgress}, this DOES redraw a single line in
|
|
138
|
+
* place — and that's safe here precisely because it only runs before any
|
|
139
|
+
* function module is imported, so there are no foreign `console.log()`
|
|
140
|
+
* writers to corrupt the frame yet. {@link stop} clears the line so the
|
|
141
|
+
* append-only boot progress (or the final summary) starts on a clean
|
|
142
|
+
* row. On a non-TTY sink it renders nothing at all: a `\r`-spinner piped
|
|
143
|
+
* to a file or CI log is just noise.
|
|
144
|
+
*/
|
|
145
|
+
export interface ScanProgress {
|
|
146
|
+
/** Begin rendering and arm the elapsed-time ticker. */
|
|
147
|
+
start(): void;
|
|
148
|
+
/** Update the discovered-function count (scan phase). */
|
|
149
|
+
setFound(found: number): void;
|
|
150
|
+
/** Switch the phase label (e.g. "scan" → "graph"). */
|
|
151
|
+
setPhase(phase: "scan" | "graph"): void;
|
|
152
|
+
/** Clear the line and stop ticking. Idempotent. */
|
|
153
|
+
stop(): void;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export interface ScanProgressOptions {
|
|
157
|
+
/** Redraw cadence in ms. Default 120 — smooth elapsed counter. */
|
|
158
|
+
tickMs?: number;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function createScanProgress(
|
|
162
|
+
sink: Sink = Deno.stdout,
|
|
163
|
+
opts: ScanProgressOptions = {},
|
|
164
|
+
): ScanProgress {
|
|
165
|
+
const enc = new TextEncoder();
|
|
166
|
+
const tickMs = opts.tickMs ?? 120;
|
|
167
|
+
const isTty = (() => {
|
|
168
|
+
try {
|
|
169
|
+
return sink.isTerminal?.() ?? false;
|
|
170
|
+
} catch {
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
})();
|
|
174
|
+
|
|
175
|
+
let found = 0;
|
|
176
|
+
let phase: "scan" | "graph" = "scan";
|
|
177
|
+
let startedAt = 0;
|
|
178
|
+
let timer: number | undefined;
|
|
179
|
+
let active = false;
|
|
180
|
+
|
|
181
|
+
const write = (s: string) => {
|
|
182
|
+
try {
|
|
183
|
+
sink.writeSync(enc.encode(s));
|
|
184
|
+
} catch {
|
|
185
|
+
// Closed pipe — never crash boot over a broken stdout.
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
const render = () => {
|
|
190
|
+
if (!isTty || !active) return;
|
|
191
|
+
const elapsed = ((performance.now() - startedAt) / 1000).toFixed(1);
|
|
192
|
+
const body = phase === "graph"
|
|
193
|
+
? `Building dependency graph… ${
|
|
194
|
+
found > 0 ? `${found} function${found === 1 ? "" : "s"} ` : ""
|
|
195
|
+
}`
|
|
196
|
+
: `Scanning functions… ${found > 0 ? `found ${found} ` : ""}`;
|
|
197
|
+
// \r → column 0, \x1b[2K → clear the whole line, then repaint.
|
|
198
|
+
write(`\r\x1b[2K[1tube] ${body}\x1b[2m(${elapsed}s)\x1b[0m`);
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
return {
|
|
202
|
+
start() {
|
|
203
|
+
if (active) return;
|
|
204
|
+
active = true;
|
|
205
|
+
startedAt = performance.now();
|
|
206
|
+
render();
|
|
207
|
+
if (isTty && tickMs > 0) {
|
|
208
|
+
timer = setInterval(render, tickMs) as unknown as number;
|
|
209
|
+
}
|
|
210
|
+
},
|
|
211
|
+
setFound(n) {
|
|
212
|
+
found = n;
|
|
213
|
+
render();
|
|
214
|
+
},
|
|
215
|
+
setPhase(p) {
|
|
216
|
+
phase = p;
|
|
217
|
+
render();
|
|
218
|
+
},
|
|
219
|
+
stop() {
|
|
220
|
+
if (!active) return;
|
|
221
|
+
active = false;
|
|
222
|
+
if (timer !== undefined) {
|
|
223
|
+
clearInterval(timer);
|
|
224
|
+
timer = undefined;
|
|
225
|
+
}
|
|
226
|
+
// Wipe the line so the next writer starts clean.
|
|
227
|
+
if (isTty) write("\r\x1b[2K");
|
|
228
|
+
},
|
|
229
|
+
};
|
|
230
|
+
}
|
package/src/gateway/cors.ts
CHANGED
|
@@ -9,43 +9,119 @@
|
|
|
9
9
|
* absence of the env var is reported and CORS is disabled (no
|
|
10
10
|
* `Access-Control-*` headers are emitted, browsers will refuse cross-origin
|
|
11
11
|
* requests).
|
|
12
|
+
*
|
|
13
|
+
* Everything else about the CORS response is configurable too, with the
|
|
14
|
+
* historical values as defaults (so an existing deployment that sets none
|
|
15
|
+
* of these behaves exactly as before):
|
|
16
|
+
* - `1TUBE_CORS_ALLOW_HEADERS` — replaces the default `Access-Control-
|
|
17
|
+
* Allow-Headers` allowlist.
|
|
18
|
+
* - `1TUBE_CORS_ALLOW_METHODS` — replaces the default methods list.
|
|
19
|
+
* - `1TUBE_CORS_EXPOSE_HEADERS` — ADDED to (never replaces) the internal
|
|
20
|
+
* `x-1tube-*` set, so operators can expose extra response headers
|
|
21
|
+
* without breaking the warming/stale overlay.
|
|
22
|
+
* - `1TUBE_CORS_MAX_AGE` — seconds for `Access-Control-Max-Age`
|
|
23
|
+
* (preflight cache). Unset = header omitted (unchanged default).
|
|
24
|
+
* - `1TUBE_CORS_ALLOW_CREDENTIALS` — force credentials on/off. Unset =
|
|
25
|
+
* the historical behaviour (on for an explicit allowlist, off for `*`).
|
|
12
26
|
*/
|
|
13
27
|
|
|
14
28
|
import type { Context, Next } from "npm:hono@4";
|
|
15
29
|
|
|
16
|
-
const
|
|
30
|
+
const DEFAULT_ALLOWED_HEADERS =
|
|
17
31
|
"authorization, x-client-info, apikey, content-type, x-application-name, user-agent";
|
|
18
|
-
const
|
|
32
|
+
const DEFAULT_ALLOWED_METHODS = "POST, GET, OPTIONS, PUT, PATCH, DELETE";
|
|
19
33
|
// Headers browser JS must be able to read on cross-origin responses.
|
|
20
34
|
// `x-1tube-warming` drives the "backend is warming up" overlay;
|
|
21
35
|
// `x-1tube-stale` marks a response served by a pre-edit worker while
|
|
22
36
|
// its HMR respawn is still in flight; `retry-after` lets clients
|
|
23
|
-
// honour the gateway's suggested backoff.
|
|
24
|
-
|
|
37
|
+
// honour the gateway's suggested backoff. These are always exposed —
|
|
38
|
+
// operator config only ADDS to them.
|
|
39
|
+
const INTERNAL_EXPOSED_HEADERS = "x-1tube-warming, x-1tube-stale, retry-after";
|
|
25
40
|
|
|
26
41
|
interface CorsConfig {
|
|
27
42
|
/** "*" means allow any. Empty array means CORS disabled. */
|
|
28
43
|
allowAny: boolean;
|
|
29
44
|
allowList: string[];
|
|
30
45
|
wildcardList: string[];
|
|
46
|
+
allowHeaders: string;
|
|
47
|
+
allowMethods: string;
|
|
48
|
+
exposeHeaders: string;
|
|
49
|
+
/** Seconds for Access-Control-Max-Age, or null to omit the header. */
|
|
50
|
+
maxAge: string | null;
|
|
51
|
+
/** Explicit credentials override; null = derive from allowAny. */
|
|
52
|
+
allowCredentials: boolean | null;
|
|
31
53
|
}
|
|
32
54
|
|
|
33
55
|
let _config: CorsConfig | null = null;
|
|
34
56
|
|
|
57
|
+
/** Merge comma-lists, dedupe case-insensitively, keep first-seen order. */
|
|
58
|
+
function mergeHeaderList(base: string, extra: string): string {
|
|
59
|
+
const seen = new Set<string>();
|
|
60
|
+
const out: string[] = [];
|
|
61
|
+
for (
|
|
62
|
+
const part of `${base},${extra}`.split(",").map((s) => s.trim()).filter(
|
|
63
|
+
Boolean,
|
|
64
|
+
)
|
|
65
|
+
) {
|
|
66
|
+
const key = part.toLowerCase();
|
|
67
|
+
if (seen.has(key)) continue;
|
|
68
|
+
seen.add(key);
|
|
69
|
+
out.push(part);
|
|
70
|
+
}
|
|
71
|
+
return out.join(", ");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function readBoolEnv(name: string): boolean | null {
|
|
75
|
+
const v = Deno.env.get(name);
|
|
76
|
+
if (v === undefined) return null;
|
|
77
|
+
const t = v.trim().toLowerCase();
|
|
78
|
+
if (t === "1" || t === "true") return true;
|
|
79
|
+
if (t === "0" || t === "false") return false;
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Headers/methods/etc. are independent of the origin allowlist branch. */
|
|
84
|
+
function loadResponseShape(): Pick<
|
|
85
|
+
CorsConfig,
|
|
86
|
+
"allowHeaders" | "allowMethods" | "exposeHeaders" | "maxAge" | "allowCredentials"
|
|
87
|
+
> {
|
|
88
|
+
const allowHeaders =
|
|
89
|
+
(Deno.env.get("1TUBE_CORS_ALLOW_HEADERS") || DEFAULT_ALLOWED_HEADERS).trim();
|
|
90
|
+
const allowMethods =
|
|
91
|
+
(Deno.env.get("1TUBE_CORS_ALLOW_METHODS") || DEFAULT_ALLOWED_METHODS).trim();
|
|
92
|
+
const exposeHeaders = mergeHeaderList(
|
|
93
|
+
INTERNAL_EXPOSED_HEADERS,
|
|
94
|
+
(Deno.env.get("1TUBE_CORS_EXPOSE_HEADERS") || "").trim(),
|
|
95
|
+
);
|
|
96
|
+
const maxAgeRaw = (Deno.env.get("1TUBE_CORS_MAX_AGE") || "").trim();
|
|
97
|
+
const maxAgeNum = parseInt(maxAgeRaw, 10);
|
|
98
|
+
const maxAge = maxAgeRaw !== "" && Number.isFinite(maxAgeNum) && maxAgeNum >= 0
|
|
99
|
+
? String(maxAgeNum)
|
|
100
|
+
: null;
|
|
101
|
+
return {
|
|
102
|
+
allowHeaders,
|
|
103
|
+
allowMethods,
|
|
104
|
+
exposeHeaders,
|
|
105
|
+
maxAge,
|
|
106
|
+
allowCredentials: readBoolEnv("1TUBE_CORS_ALLOW_CREDENTIALS"),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
35
110
|
function loadConfig(): CorsConfig {
|
|
36
111
|
if (_config) return _config;
|
|
37
112
|
const raw = (Deno.env.get("1TUBE_CORS_ORIGIN") || "").trim();
|
|
38
113
|
const isDev = Deno.env.get("1TUBE_DEV") === "1";
|
|
114
|
+
const shape = loadResponseShape();
|
|
39
115
|
|
|
40
116
|
if (!raw) {
|
|
41
117
|
if (isDev) {
|
|
42
|
-
_config = { allowAny: true, allowList: [], wildcardList: [] };
|
|
118
|
+
_config = { allowAny: true, allowList: [], wildcardList: [], ...shape };
|
|
43
119
|
} else {
|
|
44
120
|
console.warn(
|
|
45
121
|
"[1tube] 1TUBE_CORS_ORIGIN not set — CORS disabled. Set it to '*' or a " +
|
|
46
122
|
"comma-separated domain allowlist (e.g. app.example.com,*.example.com).",
|
|
47
123
|
);
|
|
48
|
-
_config = { allowAny: false, allowList: [], wildcardList: [] };
|
|
124
|
+
_config = { allowAny: false, allowList: [], wildcardList: [], ...shape };
|
|
49
125
|
}
|
|
50
126
|
return _config;
|
|
51
127
|
}
|
|
@@ -54,7 +130,7 @@ function loadConfig(): CorsConfig {
|
|
|
54
130
|
const wildcardList: string[] = [];
|
|
55
131
|
for (const entry of raw.split(",").map((s) => s.trim()).filter(Boolean)) {
|
|
56
132
|
if (entry === "*") {
|
|
57
|
-
_config = { allowAny: true, allowList: [], wildcardList: [] };
|
|
133
|
+
_config = { allowAny: true, allowList: [], wildcardList: [], ...shape };
|
|
58
134
|
return _config;
|
|
59
135
|
}
|
|
60
136
|
const domain = normalizeDomainPattern(entry);
|
|
@@ -67,6 +143,7 @@ function loadConfig(): CorsConfig {
|
|
|
67
143
|
allowAny: false,
|
|
68
144
|
allowList,
|
|
69
145
|
wildcardList,
|
|
146
|
+
...shape,
|
|
70
147
|
};
|
|
71
148
|
return _config;
|
|
72
149
|
}
|
|
@@ -118,14 +195,21 @@ function buildCorsHeaders(reqOrigin: string | null): Record<string, string> | nu
|
|
|
118
195
|
|
|
119
196
|
const headers: Record<string, string> = {
|
|
120
197
|
"Access-Control-Allow-Origin": allowed,
|
|
121
|
-
"Access-Control-Allow-Headers":
|
|
122
|
-
"Access-Control-Allow-Methods":
|
|
123
|
-
"Access-Control-Expose-Headers":
|
|
198
|
+
"Access-Control-Allow-Headers": cfg.allowHeaders,
|
|
199
|
+
"Access-Control-Allow-Methods": cfg.allowMethods,
|
|
200
|
+
"Access-Control-Expose-Headers": cfg.exposeHeaders,
|
|
124
201
|
};
|
|
125
202
|
if (allowed !== "*") {
|
|
126
203
|
headers["Vary"] = "Origin";
|
|
127
204
|
}
|
|
128
|
-
if (
|
|
205
|
+
if (cfg.maxAge !== null) {
|
|
206
|
+
headers["Access-Control-Max-Age"] = cfg.maxAge;
|
|
207
|
+
}
|
|
208
|
+
// Default (override unset): credentials on for an explicit allowlist,
|
|
209
|
+
// off in `*` mode. An explicit override wins — but never claim
|
|
210
|
+
// credentials alongside a literal `*` origin, which browsers reject.
|
|
211
|
+
const wantCredentials = cfg.allowCredentials ?? !cfg.allowAny;
|
|
212
|
+
if (wantCredentials && allowed !== "*") {
|
|
129
213
|
headers["Access-Control-Allow-Credentials"] = "true";
|
|
130
214
|
}
|
|
131
215
|
return headers;
|
package/src/gateway/logging.ts
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
|
|
12
12
|
import type { Context, Next } from "npm:hono@4";
|
|
13
13
|
import { logError, logInfo } from "../log-buffer.ts";
|
|
14
|
+
import { getRoutePrefix, routeRemainder } from "./route-prefix.ts";
|
|
14
15
|
import { uuidv7 } from "../logs/id.ts";
|
|
15
16
|
import type {
|
|
16
17
|
InvocationErrorKind,
|
|
@@ -95,7 +96,7 @@ export function setInvocationError(c: Context, info: InvocationErrorInfo): void
|
|
|
95
96
|
export async function loggingMiddleware(c: Context, next: Next) {
|
|
96
97
|
const start = performance.now();
|
|
97
98
|
const startedAtMs = Date.now();
|
|
98
|
-
const fnName = safeFnName(c.req.path
|
|
99
|
+
const fnName = safeFnName(routeRemainder(c.req.path) || "unknown");
|
|
99
100
|
// UUIDv7: time-ordered, so the persisted id doubles as a stable sort
|
|
100
101
|
// tiebreaker. Assigned for every request — even when persistence is
|
|
101
102
|
// off the header is useful for support tickets / client-side traces.
|
|
@@ -157,7 +158,7 @@ export async function loggingMiddleware(c: Context, next: Next) {
|
|
|
157
158
|
|
|
158
159
|
// Note: only method, function name, status, duration, and a truncated user
|
|
159
160
|
// id are logged. No headers (esp. Authorization), no body, no query string.
|
|
160
|
-
const line = `\x1b[36m[1tube]${reset} ${c.req.method}
|
|
161
|
+
const line = `\x1b[36m[1tube]${reset} ${c.req.method} ${getRoutePrefix()}/${fnName} → ${statusColor}${status}${reset} ${dim}(${durationMs}ms)${reset}${userTag}`;
|
|
161
162
|
|
|
162
163
|
if (isError) {
|
|
163
164
|
logError(line);
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
import type { Context, Next } from "npm:hono@4";
|
|
12
12
|
import { getConnInfo } from "npm:hono@4/deno";
|
|
13
13
|
import type { FunctionRegistry } from "../registry.ts";
|
|
14
|
+
import { routeRemainder } from "./route-prefix.ts";
|
|
14
15
|
|
|
15
16
|
interface Bucket {
|
|
16
17
|
tokens: number;
|
|
@@ -131,14 +132,11 @@ export function createRateLimiter(config: Partial<RateLimitConfig> = {}) {
|
|
|
131
132
|
_activeMaxBuckets = cfg.maxBuckets;
|
|
132
133
|
|
|
133
134
|
return async (c: Context, next: Next) => {
|
|
134
|
-
// Middleware is mounted on
|
|
135
|
-
// the trailing segments. Parse the function
|
|
136
|
-
// ourselves — first segment after the
|
|
137
|
-
|
|
138
|
-
const
|
|
139
|
-
? path.slice("/functions/v1/".length)
|
|
140
|
-
: "";
|
|
141
|
-
const fnName = after.split("/", 1)[0] || "";
|
|
135
|
+
// Middleware is mounted on the function route wildcard, so
|
|
136
|
+
// c.req.param() won't see the trailing segments. Parse the function
|
|
137
|
+
// name out of the path ourselves — first segment after the
|
|
138
|
+
// (configurable) prefix, before any nested route.
|
|
139
|
+
const fnName = routeRemainder(c.req.path).split("/", 1)[0] || "";
|
|
142
140
|
|
|
143
141
|
const manifestRpm = fnName ? cfg.registry?.manifestFor(fnName)?.rpm : undefined;
|
|
144
142
|
const rpm = manifestRpm ?? ((fnName && cfg.overrides[fnName]) || cfg.defaultRpm);
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configurable function route prefix.
|
|
3
|
+
*
|
|
4
|
+
* The gateway has always served functions under Supabase's
|
|
5
|
+
* `/functions/v1/<name>` convention. That prefix is now configurable
|
|
6
|
+
* (`--route-prefix` / `1TUBE_ROUTE_PREFIX`) so the gateway can sit
|
|
7
|
+
* behind a different path convention (e.g. `/api`, `/edge/v2`) without a
|
|
8
|
+
* reverse-proxy rewrite. The default is unchanged, so existing
|
|
9
|
+
* deployments keep working with zero config.
|
|
10
|
+
*
|
|
11
|
+
* The prefix is process-global module state, set once at boot BEFORE any
|
|
12
|
+
* route is mounted. It lives here (rather than threaded through every
|
|
13
|
+
* call site) because the logging and rate-limit middleware � whose
|
|
14
|
+
* public signatures we don't want to break � must strip the exact same
|
|
15
|
+
* prefix the router mounts on. There is one gateway per process, so a
|
|
16
|
+
* single shared value is correct; tests that need a custom prefix call
|
|
17
|
+
* {@link setRoutePrefix} and reset it afterwards (or re-import the
|
|
18
|
+
* module fresh).
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** Supabase Edge Runtime convention � the historical, unchanged default. */
|
|
22
|
+
export const DEFAULT_ROUTE_PREFIX = "/functions/v1";
|
|
23
|
+
|
|
24
|
+
let _prefix = DEFAULT_ROUTE_PREFIX;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Canonicalize a raw prefix: guarantee exactly one leading slash, no
|
|
28
|
+
* trailing slash, and no duplicate inner slashes. Empty / root-only
|
|
29
|
+
* inputs fall back to the default � the router can't mount on bare `/`
|
|
30
|
+
* without swallowing `/health`, `/1tube/*`, and friends.
|
|
31
|
+
*/
|
|
32
|
+
export function normalizeRoutePrefix(raw: string | undefined | null): string {
|
|
33
|
+
let v = (raw ?? "").trim();
|
|
34
|
+
if (!v) return DEFAULT_ROUTE_PREFIX;
|
|
35
|
+
if (!v.startsWith("/")) v = "/" + v;
|
|
36
|
+
v = v.replace(/\/{2,}/g, "/").replace(/\/+$/, "");
|
|
37
|
+
return v === "" ? DEFAULT_ROUTE_PREFIX : v;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Set the process-wide route prefix. Returns the normalized value used. */
|
|
41
|
+
export function setRoutePrefix(raw: string | undefined | null): string {
|
|
42
|
+
_prefix = normalizeRoutePrefix(raw);
|
|
43
|
+
return _prefix;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Current normalized prefix (leading slash, no trailing slash). */
|
|
47
|
+
export function getRoutePrefix(): string {
|
|
48
|
+
return _prefix;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Hono wildcard mount pattern for middleware, e.g. `/functions/v1/*`. */
|
|
52
|
+
export function routeWildcard(): string {
|
|
53
|
+
return `${_prefix}/*`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Hono dispatch pattern capturing the function name + nested path. */
|
|
57
|
+
export function routeDispatchPattern(): string {
|
|
58
|
+
return `${_prefix}/:name{.+}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Function name + nested path that follows `${prefix}/`. Returns "" when
|
|
63
|
+
* the path isn't under the prefix. Used by logging + rate-limit to pull
|
|
64
|
+
* the function name out of the request path (the middleware is mounted on
|
|
65
|
+
* the wildcard, so `c.req.param()` can't see it).
|
|
66
|
+
*/
|
|
67
|
+
export function routeRemainder(path: string): string {
|
|
68
|
+
if (path.startsWith(_prefix + "/")) return path.slice(_prefix.length + 1);
|
|
69
|
+
return "";
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Strip the prefix off a full pathname, KEEPING the leading slash of the
|
|
74
|
+
* remainder (`/functions/v1/hello/world` ? `/hello/world`,
|
|
75
|
+
* `/functions/v1` ? ""). This is the URL the function/worker should see,
|
|
76
|
+
* mirroring Supabase Edge Runtime behaviour. Callers fall back to "/" for
|
|
77
|
+
* the empty result.
|
|
78
|
+
*/
|
|
79
|
+
export function stripRoutePrefixFromPathname(pathname: string): string {
|
|
80
|
+
if (pathname === _prefix) return "";
|
|
81
|
+
if (pathname.startsWith(_prefix + "/")) return pathname.slice(_prefix.length);
|
|
82
|
+
return pathname;
|
|
83
|
+
}
|
package/src/registry.ts
CHANGED
|
@@ -104,7 +104,13 @@ export interface WorkerFunctionHandle {
|
|
|
104
104
|
*/
|
|
105
105
|
invocationId?: string,
|
|
106
106
|
): Promise<Response>;
|
|
107
|
-
|
|
107
|
+
/**
|
|
108
|
+
* Tear down the worker. With `drainMs > 0`, in-flight requests are
|
|
109
|
+
* allowed to finish (up to `drainMs`) before the worker is killed —
|
|
110
|
+
* the graceful HMR handoff path. Omitting it (or passing 0) hard-
|
|
111
|
+
* terminates immediately, rejecting anything still pending.
|
|
112
|
+
*/
|
|
113
|
+
terminate(opts?: { drainMs?: number }): Promise<void>;
|
|
108
114
|
}
|
|
109
115
|
|
|
110
116
|
export class FunctionRegistry {
|
package/src/server.ts
CHANGED
|
@@ -35,6 +35,14 @@ import { createLogQuery } from "./logs/query.ts";
|
|
|
35
35
|
import { registerLogRoutes } from "./logs/api.ts";
|
|
36
36
|
import { parseMarkedConsoleLine } from "./logs/console-marker.ts";
|
|
37
37
|
import { createRateLimiter } from "./gateway/rate-limit.ts";
|
|
38
|
+
import {
|
|
39
|
+
getRoutePrefix,
|
|
40
|
+
normalizeRoutePrefix,
|
|
41
|
+
routeDispatchPattern,
|
|
42
|
+
routeWildcard,
|
|
43
|
+
setRoutePrefix,
|
|
44
|
+
stripRoutePrefixFromPathname,
|
|
45
|
+
} from "./gateway/route-prefix.ts";
|
|
38
46
|
import { createHealthHandler, createMetricsHandler } from "./health.ts";
|
|
39
47
|
import { FunctionSupervisor } from "./supervisor.ts";
|
|
40
48
|
import { installEnvScope } from "./env-scope.ts";
|
|
@@ -66,7 +74,7 @@ import {
|
|
|
66
74
|
createRewriteCache,
|
|
67
75
|
type RewriteCache,
|
|
68
76
|
} from "./backends/deno/source-rewriter.ts";
|
|
69
|
-
import { createBootProgress } from "./boot-progress.ts";
|
|
77
|
+
import { createBootProgress, createScanProgress } from "./boot-progress.ts";
|
|
70
78
|
import {
|
|
71
79
|
createWorkerdWatchdog,
|
|
72
80
|
recommendedBudgetBytes,
|
|
@@ -114,6 +122,13 @@ interface CliOpts {
|
|
|
114
122
|
port: number;
|
|
115
123
|
host: string;
|
|
116
124
|
functionsPath: string;
|
|
125
|
+
/**
|
|
126
|
+
* URL prefix every function is served under. Defaults to Supabase's
|
|
127
|
+
* `/functions/v1`. Override with `--route-prefix <p>` /
|
|
128
|
+
* `1TUBE_ROUTE_PREFIX` to mount functions at e.g. `/api` or `/edge/v2`.
|
|
129
|
+
* Normalized to a single leading slash, no trailing slash.
|
|
130
|
+
*/
|
|
131
|
+
routePrefix: string;
|
|
117
132
|
defaultTimeoutMs: number;
|
|
118
133
|
bodyLimitBytes: number;
|
|
119
134
|
/**
|
|
@@ -179,6 +194,13 @@ interface CliOpts {
|
|
|
179
194
|
workerdShared?: readonly string[];
|
|
180
195
|
/** Max concurrent Deno Worker spawns during eager boot/reload. */
|
|
181
196
|
denoWorkerConcurrency?: number;
|
|
197
|
+
/**
|
|
198
|
+
* Grace window (ms) an HMR-superseded Deno worker keeps serving its
|
|
199
|
+
* in-flight requests before it is terminated. Prevents a reload under
|
|
200
|
+
* load from dropping responses. `1TUBE_HMR_DRAIN_MS`, default 10000;
|
|
201
|
+
* 0 reverts to terminate-immediately. Deno backend only.
|
|
202
|
+
*/
|
|
203
|
+
denoReloadDrainMs?: number;
|
|
182
204
|
/**
|
|
183
205
|
* First loopback port workerd may use for per-function sockets.
|
|
184
206
|
* The backend reserves a second generation range at +500 during
|
|
@@ -306,6 +328,12 @@ function parseArgs(): CliOpts {
|
|
|
306
328
|
const n = parseInt(v, 10);
|
|
307
329
|
return Number.isFinite(n) && n > 0 ? n : undefined;
|
|
308
330
|
})();
|
|
331
|
+
let denoReloadDrainMs: number | undefined = (() => {
|
|
332
|
+
const v = Deno.env.get("1TUBE_HMR_DRAIN_MS");
|
|
333
|
+
if (!v) return undefined;
|
|
334
|
+
const n = parseInt(v, 10);
|
|
335
|
+
return Number.isFinite(n) && n >= 0 ? n : undefined;
|
|
336
|
+
})();
|
|
309
337
|
let workerdBasePort: number | undefined = (() => {
|
|
310
338
|
const v = Deno.env.get("1TUBE_WORKERD_BASE_PORT");
|
|
311
339
|
if (!v) return undefined;
|
|
@@ -373,6 +401,9 @@ function parseArgs(): CliOpts {
|
|
|
373
401
|
let port = parseInt(Deno.env.get("PORT") || "3100", 10);
|
|
374
402
|
let host = (Deno.env.get("1TUBE_HOST") || "127.0.0.1").trim();
|
|
375
403
|
let functionsPath = Deno.env.get("FUNCTIONS_PATH") || "./supabase/functions";
|
|
404
|
+
// Normalized at the end via setRoutePrefix(); kept raw here so an
|
|
405
|
+
// explicit CLI flag can still override the env var.
|
|
406
|
+
let routePrefix = Deno.env.get("1TUBE_ROUTE_PREFIX") || "/functions/v1";
|
|
376
407
|
let defaultTimeoutMs = parseInt(
|
|
377
408
|
Deno.env.get("FUNCTION_TIMEOUT_MS") || "150000",
|
|
378
409
|
10,
|
|
@@ -395,6 +426,10 @@ function parseArgs(): CliOpts {
|
|
|
395
426
|
host = args[++i];
|
|
396
427
|
} else if (a === "--functions" && args[i + 1]) {
|
|
397
428
|
functionsPath = args[++i];
|
|
429
|
+
} else if (a === "--route-prefix" && args[i + 1]) {
|
|
430
|
+
routePrefix = args[++i];
|
|
431
|
+
} else if (a.startsWith("--route-prefix=")) {
|
|
432
|
+
routePrefix = a.slice("--route-prefix=".length);
|
|
398
433
|
} else if (a === "--timeout" && args[i + 1]) {
|
|
399
434
|
defaultTimeoutMs = parseInt(args[++i], 10);
|
|
400
435
|
} else if (a === "--dev") {
|
|
@@ -459,6 +494,12 @@ function parseArgs(): CliOpts {
|
|
|
459
494
|
} else if (a.startsWith("--deno-worker-concurrency=")) {
|
|
460
495
|
const n = parseInt(a.slice("--deno-worker-concurrency=".length), 10);
|
|
461
496
|
if (Number.isFinite(n) && n > 0) denoWorkerConcurrency = n;
|
|
497
|
+
} else if (a === "--hmr-drain-ms" && args[i + 1]) {
|
|
498
|
+
const n = parseInt(args[++i], 10);
|
|
499
|
+
if (Number.isFinite(n) && n >= 0) denoReloadDrainMs = n;
|
|
500
|
+
} else if (a.startsWith("--hmr-drain-ms=")) {
|
|
501
|
+
const n = parseInt(a.slice("--hmr-drain-ms=".length), 10);
|
|
502
|
+
if (Number.isFinite(n) && n >= 0) denoReloadDrainMs = n;
|
|
462
503
|
} else if (a === "--workerd-base-port" && args[i + 1]) {
|
|
463
504
|
const n = parseInt(args[++i], 10);
|
|
464
505
|
if (Number.isFinite(n) && n > 0) workerdBasePort = n;
|
|
@@ -574,6 +615,7 @@ function parseArgs(): CliOpts {
|
|
|
574
615
|
port,
|
|
575
616
|
host,
|
|
576
617
|
functionsPath,
|
|
618
|
+
routePrefix: normalizeRoutePrefix(routePrefix),
|
|
577
619
|
defaultTimeoutMs,
|
|
578
620
|
bodyLimitBytes: Math.max(0, Math.floor(bodyLimitMb * 1024 * 1024)),
|
|
579
621
|
bodyReadIdleMs: Math.max(0, bodyReadIdleMs),
|
|
@@ -589,6 +631,7 @@ function parseArgs(): CliOpts {
|
|
|
589
631
|
workerdEnv,
|
|
590
632
|
workerdShared,
|
|
591
633
|
denoWorkerConcurrency,
|
|
634
|
+
denoReloadDrainMs,
|
|
592
635
|
workerdBasePort,
|
|
593
636
|
workerdInspector,
|
|
594
637
|
workerdMaxHeapMB,
|
|
@@ -1360,6 +1403,9 @@ if (opts.backend === "workerd") {
|
|
|
1360
1403
|
...(opts.denoWorkerConcurrency
|
|
1361
1404
|
? { concurrency: opts.denoWorkerConcurrency }
|
|
1362
1405
|
: {}),
|
|
1406
|
+
...(opts.denoReloadDrainMs !== undefined
|
|
1407
|
+
? { reloadDrainMs: opts.denoReloadDrainMs }
|
|
1408
|
+
: {}),
|
|
1363
1409
|
...(denoImportMapOptions ?? {}),
|
|
1364
1410
|
...(denoSharedRuntime ? { sharedRuntime: denoSharedRuntime } : {}),
|
|
1365
1411
|
...(denoRewriteCache ? { rewriteCache: denoRewriteCache } : {}),
|
|
@@ -1375,6 +1421,11 @@ if (opts.backend === "workerd") {
|
|
|
1375
1421
|
// already enough signal.
|
|
1376
1422
|
const bootStart = performance.now();
|
|
1377
1423
|
const progress = createBootProgress(Deno.stdout, { pulseMs: 2000 });
|
|
1424
|
+
// Live in-place "scanning" line for the pre-spawn phase (directory
|
|
1425
|
+
// discovery + batch dep-graph). Safe to redraw in place here because no
|
|
1426
|
+
// function module has been imported yet, so nothing else is writing to
|
|
1427
|
+
// the TTY. Cleared the instant the first worker spawn line prints.
|
|
1428
|
+
const scanProgress = createScanProgress(Deno.stdout);
|
|
1378
1429
|
const bootProfile = Deno.env.get("1TUBE_BOOT_PROFILE") === "1" ||
|
|
1379
1430
|
Deno.env.get("1TUBE_BOOT_PROFILE")?.toLowerCase() === "true";
|
|
1380
1431
|
let started = false;
|
|
@@ -1387,6 +1438,10 @@ if (opts.backend === "workerd") {
|
|
|
1387
1438
|
// duration; the only thing that changes is the column we display.
|
|
1388
1439
|
let printOrder = 0;
|
|
1389
1440
|
const startCallbacks = {
|
|
1441
|
+
onScanProgress: (p: { found: number; phase: "scan" | "graph" }) => {
|
|
1442
|
+
scanProgress.setPhase(p.phase);
|
|
1443
|
+
scanProgress.setFound(p.found);
|
|
1444
|
+
},
|
|
1390
1445
|
onBatchGraph: (p: { total: number; durationMs: number }) => {
|
|
1391
1446
|
if (bootProfile) {
|
|
1392
1447
|
console.log(
|
|
@@ -1397,6 +1452,9 @@ if (opts.backend === "workerd") {
|
|
|
1397
1452
|
}
|
|
1398
1453
|
},
|
|
1399
1454
|
onSpawnStart: (p: { index: number; total: number; name: string }) => {
|
|
1455
|
+
// Clear the scanning line before the first append-only spawn line
|
|
1456
|
+
// lands, so the two never collide on the same TTY row.
|
|
1457
|
+
scanProgress.stop();
|
|
1400
1458
|
if (!started) {
|
|
1401
1459
|
progress.start(p.total);
|
|
1402
1460
|
started = true;
|
|
@@ -1440,6 +1498,7 @@ if (opts.backend === "workerd") {
|
|
|
1440
1498
|
}
|
|
1441
1499
|
};
|
|
1442
1500
|
|
|
1501
|
+
scanProgress.start();
|
|
1443
1502
|
if (opts.deferBoot) {
|
|
1444
1503
|
// Deferred boot: the gateway starts serving right away; Workers spawn
|
|
1445
1504
|
// in the background. A request for a not-yet-ready function bumps it
|
|
@@ -1447,6 +1506,9 @@ if (opts.backend === "workerd") {
|
|
|
1447
1506
|
const { discovered, done } = await denoWorkerHost.startDeferred(
|
|
1448
1507
|
startCallbacks,
|
|
1449
1508
|
);
|
|
1509
|
+
// Scan is done by the time startDeferred resolves (spawns continue in
|
|
1510
|
+
// the background); clear the line before the deferred-boot message.
|
|
1511
|
+
scanProgress.stop();
|
|
1450
1512
|
console.log(
|
|
1451
1513
|
`[1tube] Deferred boot: ${discovered.length} function(s) warming in ` +
|
|
1452
1514
|
`the background — gateway is accepting requests now ` +
|
|
@@ -1458,6 +1520,7 @@ if (opts.backend === "workerd") {
|
|
|
1458
1520
|
});
|
|
1459
1521
|
} else {
|
|
1460
1522
|
const { loaded, errors } = await denoWorkerHost.start(startCallbacks);
|
|
1523
|
+
scanProgress.stop();
|
|
1461
1524
|
progress.stop();
|
|
1462
1525
|
logBootSummary(loaded, errors, "Loaded");
|
|
1463
1526
|
}
|
|
@@ -1506,6 +1569,16 @@ const rateLimiter = createRateLimiter({
|
|
|
1506
1569
|
...(rpmOverride !== null ? { defaultRpm: rpmOverride } : {}),
|
|
1507
1570
|
});
|
|
1508
1571
|
|
|
1572
|
+
// Lock in the (configurable) function route prefix BEFORE mounting any
|
|
1573
|
+
// route — the logging + rate-limit middleware read the same global to
|
|
1574
|
+
// strip it back off. `fnRouteWildcard` is the Hono mount pattern shared
|
|
1575
|
+
// by every middleware below (`<prefix>/*`).
|
|
1576
|
+
setRoutePrefix(opts.routePrefix);
|
|
1577
|
+
const fnRouteWildcard = routeWildcard();
|
|
1578
|
+
if (opts.routePrefix !== "/functions/v1") {
|
|
1579
|
+
console.log(`[1tube] Function route prefix: ${opts.routePrefix}`);
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1509
1582
|
// Middleware order matters:
|
|
1510
1583
|
// 1. CORS (handle preflight before anything else can short-circuit)
|
|
1511
1584
|
// 2. Fast-fail unknown function names (no auth/rate-limit cost for scans)
|
|
@@ -1514,7 +1587,7 @@ const rateLimiter = createRateLimiter({
|
|
|
1514
1587
|
// 5. JWT auth probe (sets c.userId when token is valid; does NOT enforce
|
|
1515
1588
|
// auth here — the dispatcher decides per function based on `isPublic`)
|
|
1516
1589
|
// 6. Rate limiter (now sees a real userId for per-user keying)
|
|
1517
|
-
app.use(
|
|
1590
|
+
app.use(fnRouteWildcard, corsMiddleware);
|
|
1518
1591
|
// Backend-aware function-existence probe. On the workerd backend the
|
|
1519
1592
|
// bundled-set captured at boot is the source; on the deno backend the
|
|
1520
1593
|
// registry's worker-handle map is. Both expose the same Map-lookup-cost
|
|
@@ -1522,12 +1595,12 @@ app.use("/functions/v1/*", corsMiddleware);
|
|
|
1522
1595
|
const functionExists = (name: string): boolean =>
|
|
1523
1596
|
opts.backend === "workerd" ? workerdNames.has(name) : registry.has(name);
|
|
1524
1597
|
|
|
1525
|
-
app.use(
|
|
1598
|
+
app.use(fnRouteWildcard, async (c, next) => {
|
|
1526
1599
|
// Fast-fail unknown function names before paying for logging, auth, body
|
|
1527
1600
|
// limit, and rate-limit. Without this, a scanner hammering random names
|
|
1528
1601
|
// exhausts rate-limit buckets keyed by IP and pollutes the log stream.
|
|
1529
1602
|
const path = c.req.path;
|
|
1530
|
-
const prefix = "/
|
|
1603
|
+
const prefix = getRoutePrefix() + "/";
|
|
1531
1604
|
if (path.length > prefix.length) {
|
|
1532
1605
|
const after = path.slice(prefix.length);
|
|
1533
1606
|
const slash = after.indexOf("/");
|
|
@@ -1538,9 +1611,9 @@ app.use("/functions/v1/*", async (c, next) => {
|
|
|
1538
1611
|
}
|
|
1539
1612
|
await next();
|
|
1540
1613
|
});
|
|
1541
|
-
app.use(
|
|
1614
|
+
app.use(fnRouteWildcard, loggingMiddleware);
|
|
1542
1615
|
app.use(
|
|
1543
|
-
|
|
1616
|
+
fnRouteWildcard,
|
|
1544
1617
|
bodyLimit({
|
|
1545
1618
|
maxSize: opts.bodyLimitBytes,
|
|
1546
1619
|
onError: (c) =>
|
|
@@ -1553,7 +1626,7 @@ app.use(
|
|
|
1553
1626
|
),
|
|
1554
1627
|
}),
|
|
1555
1628
|
);
|
|
1556
|
-
app.use(
|
|
1629
|
+
app.use(fnRouteWildcard, async (c, next) => {
|
|
1557
1630
|
// Probe-only auth: validate the bearer token if present, but don't reject
|
|
1558
1631
|
// unauthenticated requests here. Public functions need to pass through and
|
|
1559
1632
|
// rate-limit by IP; protected functions are gated below in the dispatcher.
|
|
@@ -1568,13 +1641,15 @@ app.use("/functions/v1/*", async (c, next) => {
|
|
|
1568
1641
|
}
|
|
1569
1642
|
await next();
|
|
1570
1643
|
});
|
|
1571
|
-
app.use(
|
|
1644
|
+
app.use(fnRouteWildcard, rateLimiter);
|
|
1572
1645
|
|
|
1573
|
-
// Function dispatch
|
|
1574
|
-
|
|
1646
|
+
// Function dispatch. The pattern is built from the configurable prefix, so
|
|
1647
|
+
// Hono can't statically infer the `:name` param — hence the `?? ""` guard
|
|
1648
|
+
// (the `{.+}` matcher guarantees a non-empty value at runtime anyway).
|
|
1649
|
+
app.all(routeDispatchPattern(), async (c) => {
|
|
1575
1650
|
// c.req.param("name") greedily captures the trailing path with the {.+}
|
|
1576
1651
|
// matcher; restrict it to the first segment.
|
|
1577
|
-
const rawName = c.req.param("name");
|
|
1652
|
+
const rawName = c.req.param("name") ?? "";
|
|
1578
1653
|
const name = rawName.split("/", 1)[0];
|
|
1579
1654
|
|
|
1580
1655
|
// Workerd backend dispatch path. Same gateway pipeline (CORS, auth
|
|
@@ -1618,12 +1693,12 @@ app.all("/functions/v1/:name{.+}", async (c) => {
|
|
|
1618
1693
|
}).body
|
|
1619
1694
|
: null;
|
|
1620
1695
|
// Mirror the Deno-path URL rewrite: user code expects to see the
|
|
1621
|
-
// pathname without the
|
|
1696
|
+
// pathname without the gateway route prefix, matching Supabase
|
|
1622
1697
|
// Edge Runtime behaviour. Without this strip, hello/echo see the
|
|
1623
1698
|
// gateway prefix in their `new URL(req.url).pathname`.
|
|
1624
1699
|
const originalUrl = new URL(c.req.raw.url);
|
|
1625
1700
|
const rewrittenPath =
|
|
1626
|
-
originalUrl.pathname
|
|
1701
|
+
stripRoutePrefixFromPathname(originalUrl.pathname) || "/";
|
|
1627
1702
|
const rewrittenUrl = new URL(
|
|
1628
1703
|
rewrittenPath + originalUrl.search,
|
|
1629
1704
|
originalUrl.origin,
|
|
@@ -1831,9 +1906,9 @@ app.all("/functions/v1/:name{.+}", async (c) => {
|
|
|
1831
1906
|
const timeoutMs = handle.timeoutMs ?? handle.manifest.timeoutMs ??
|
|
1832
1907
|
opts.defaultTimeoutMs;
|
|
1833
1908
|
|
|
1834
|
-
// Strip
|
|
1909
|
+
// Strip the gateway route prefix to match Supabase Edge Runtime behavior.
|
|
1835
1910
|
const originalUrl = new URL(c.req.raw.url);
|
|
1836
|
-
const rewrittenPath = originalUrl.pathname
|
|
1911
|
+
const rewrittenPath = stripRoutePrefixFromPathname(originalUrl.pathname) ||
|
|
1837
1912
|
"/";
|
|
1838
1913
|
const rewrittenUrl = new URL(
|
|
1839
1914
|
rewrittenPath + originalUrl.search,
|