1tube 0.1.19 → 0.1.21
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/package.json +1 -1
- package/src/backends/workerd/backend.ts +71 -75
- package/src/deno-hmr-snapshot.ts +159 -0
- package/src/discovery.ts +31 -10
- package/src/server.ts +309 -130
package/package.json
CHANGED
|
@@ -1352,89 +1352,85 @@ export function createWorkerdBackend(opts: WorkerdBackendOptions): WorkerdBacken
|
|
|
1352
1352
|
|
|
1353
1353
|
// Preflight: refuse to spawn workerd against ports that are
|
|
1354
1354
|
// already in use. Without this, a leftover workerd from a
|
|
1355
|
-
// previous run
|
|
1356
|
-
// surfaces only as
|
|
1357
|
-
//
|
|
1358
|
-
//
|
|
1359
|
-
//
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
`--kill-stale-workerd is set, attempting cleanup...`,
|
|
1368
|
-
);
|
|
1355
|
+
// previous run can satisfy the readiness probe and silently
|
|
1356
|
+
// steal our traffic; the failure then surfaces only as stale
|
|
1357
|
+
// response bodies. On reload the still-serving current process
|
|
1358
|
+
// occupies the OTHER slot's ports by design, so probing the next
|
|
1359
|
+
// slot is safe and catches orphaned older generations.
|
|
1360
|
+
let conflicts = probeSocketsFree(capnp.routes);
|
|
1361
|
+
if (conflicts.length > 0 && opts.killStaleWorkerd) {
|
|
1362
|
+
console.log(
|
|
1363
|
+
`[1tube] port preflight found ${conflicts.length} conflict(s)` +
|
|
1364
|
+
(args.gen > 0 ? ` for reload gen=${args.gen}` : "") +
|
|
1365
|
+
`; --kill-stale-workerd is set, attempting cleanup...`,
|
|
1366
|
+
);
|
|
1369
1367
|
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
}
|
|
1390
|
-
await new Promise((r) => setTimeout(r, 250));
|
|
1391
|
-
conflicts = probeSocketsFree(capnp.routes);
|
|
1368
|
+
// First try the precise fix: resolve the conflicted port(s)
|
|
1369
|
+
// to their owning PID(s), and kill only owners named workerd.
|
|
1370
|
+
// That is the behavior we want during firmware flashing:
|
|
1371
|
+
// remove the stale candidate that owns 8800, but never kill
|
|
1372
|
+
// a random non-workerd process that happens to be there.
|
|
1373
|
+
const targeted = await killWorkerdOwnersOfPorts(conflicts);
|
|
1374
|
+
if (targeted.attempted) {
|
|
1375
|
+
if (targeted.code === 0) {
|
|
1376
|
+
const details = targeted.stdout?.trim();
|
|
1377
|
+
console.log(
|
|
1378
|
+
`[1tube] targeted stale-workerd cleanup by port complete` +
|
|
1379
|
+
(details ? ` (${details})` : "") +
|
|
1380
|
+
`; re-probing...`,
|
|
1381
|
+
);
|
|
1382
|
+
} else {
|
|
1383
|
+
console.warn(
|
|
1384
|
+
`[1tube] targeted stale-workerd cleanup exited ${targeted.code}` +
|
|
1385
|
+
(targeted.stderr ? `: ${targeted.stderr.trim()}` : ""),
|
|
1386
|
+
);
|
|
1392
1387
|
}
|
|
1388
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
1389
|
+
conflicts = probeSocketsFree(capnp.routes);
|
|
1390
|
+
}
|
|
1393
1391
|
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
`
|
|
1413
|
-
|
|
1414
|
-
);
|
|
1415
|
-
} else {
|
|
1416
|
-
console.warn(
|
|
1417
|
-
`[1tube] ${result.command?.join(" ")} exited ${result.code}` +
|
|
1418
|
-
(result.stderr ? `: ${result.stderr.trim()}` : ""),
|
|
1419
|
-
);
|
|
1420
|
-
}
|
|
1421
|
-
// Brief settle time: Windows TCP sockets in CLOSE_WAIT can
|
|
1422
|
-
// hold the port for a fraction of a second after the owner
|
|
1423
|
-
// process is killed. 250ms is empirically enough on the
|
|
1424
|
-
// dev boxes we've tested without making the boot path feel
|
|
1425
|
-
// sluggish.
|
|
1426
|
-
await new Promise((r) => setTimeout(r, 250));
|
|
1427
|
-
conflicts = probeSocketsFree(capnp.routes);
|
|
1392
|
+
// If targeted cleanup did not exist on this platform, or it
|
|
1393
|
+
// found no matching workerd owner, fall back to the old typed
|
|
1394
|
+
// process-name cleanup.
|
|
1395
|
+
const result = conflicts.length > 0 ? await killStaleWorkerd() : null;
|
|
1396
|
+
if (result === null) {
|
|
1397
|
+
// Already clear.
|
|
1398
|
+
} else if (result.attempted) {
|
|
1399
|
+
// taskkill returns 128 when no matching image is running —
|
|
1400
|
+
// that's not an error in our context (means the offender
|
|
1401
|
+
// wasn't workerd, which is fine; preflight will catch it).
|
|
1402
|
+
// pkill returns 1 in the same case.
|
|
1403
|
+
const noMatch = (Deno.build.os === "windows" && result.code === 128) ||
|
|
1404
|
+
(Deno.build.os !== "windows" && result.code === 1);
|
|
1405
|
+
if (result.code === 0) {
|
|
1406
|
+
console.log(`[1tube] killed leftover workerd process(es); re-probing...`);
|
|
1407
|
+
} else if (noMatch) {
|
|
1408
|
+
console.log(
|
|
1409
|
+
`[1tube] no leftover workerd processes found; the conflict is held ` +
|
|
1410
|
+
`by something else (re-probing to confirm)...`,
|
|
1411
|
+
);
|
|
1428
1412
|
} else {
|
|
1429
1413
|
console.warn(
|
|
1430
|
-
`[1tube]
|
|
1431
|
-
|
|
1414
|
+
`[1tube] ${result.command?.join(" ")} exited ${result.code}` +
|
|
1415
|
+
(result.stderr ? `: ${result.stderr.trim()}` : ""),
|
|
1432
1416
|
);
|
|
1433
1417
|
}
|
|
1418
|
+
// Brief settle time: Windows TCP sockets in CLOSE_WAIT can
|
|
1419
|
+
// hold the port for a fraction of a second after the owner
|
|
1420
|
+
// process is killed. 250ms is empirically enough on the
|
|
1421
|
+
// dev boxes we've tested without making the boot path feel
|
|
1422
|
+
// sluggish.
|
|
1423
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
1424
|
+
conflicts = probeSocketsFree(capnp.routes);
|
|
1425
|
+
} else {
|
|
1426
|
+
console.warn(
|
|
1427
|
+
`[1tube] could not run ${result.command?.join(" ")}: ` +
|
|
1428
|
+
`${result.stderr ?? "unknown error"}`,
|
|
1429
|
+
);
|
|
1434
1430
|
}
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1431
|
+
}
|
|
1432
|
+
if (conflicts.length > 0) {
|
|
1433
|
+
throw new Error(formatPortConflictError(conflicts));
|
|
1438
1434
|
}
|
|
1439
1435
|
|
|
1440
1436
|
const inspectorArgs = buildInspectorExtraArgs(opts.inspectorAddr, slot);
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deno backend HMR snapshotting.
|
|
3
|
+
*
|
|
4
|
+
* Deno's in-process module cache is keyed by module URL and cannot be
|
|
5
|
+
* invalidated. Importing `index.ts?v=...` refreshes only the entrypoint;
|
|
6
|
+
* relative children such as `./types.ts` keep their old file URLs. HMR reloads
|
|
7
|
+
* therefore import from a fresh snapshot path per generation, which gives the
|
|
8
|
+
* entire relative dependency graph new module identities.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { basename, dirname, join, resolve as resolvePath } from "node:path";
|
|
12
|
+
|
|
13
|
+
const DEFAULT_KEEP_GENERATIONS = 3;
|
|
14
|
+
|
|
15
|
+
export type SnapshotChanged = ReadonlySet<string> | "all";
|
|
16
|
+
|
|
17
|
+
export interface DenoHmrSnapshotOptions {
|
|
18
|
+
/** Real functions directory, e.g. `supabase/functions`. */
|
|
19
|
+
functionsDir: string;
|
|
20
|
+
/** Changed function names, or `"all"` for shared/full reloads. */
|
|
21
|
+
changed: SnapshotChanged;
|
|
22
|
+
/** Monotonic generation number owned by the caller. */
|
|
23
|
+
generation: number;
|
|
24
|
+
/** Cache root; defaults to `<cwd>/.1tube-cache/deno-hmr`. */
|
|
25
|
+
cacheDir?: string;
|
|
26
|
+
/** Number of newest generations to keep on disk. */
|
|
27
|
+
keepGenerations?: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface DenoHmrSnapshot {
|
|
31
|
+
/** Generation root, e.g. `.1tube-cache/deno-hmr/gen-4`. */
|
|
32
|
+
root: string;
|
|
33
|
+
/** Snapshot functions root passed to the discovery importer. */
|
|
34
|
+
functionsDir: string;
|
|
35
|
+
/** Names of top-level directories materialized into this snapshot. */
|
|
36
|
+
copiedDirs: string[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function defaultCacheDir(): string {
|
|
40
|
+
return join(Deno.cwd(), ".1tube-cache", "deno-hmr");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function isSharedDir(name: string): boolean {
|
|
44
|
+
return name.startsWith("_") || name.endsWith("_shared");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function copyEntry(src: string, dest: string): Promise<void> {
|
|
48
|
+
const info = await Deno.lstat(src);
|
|
49
|
+
|
|
50
|
+
if (info.isSymlink) {
|
|
51
|
+
const target = await Deno.readLink(src);
|
|
52
|
+
try {
|
|
53
|
+
await Deno.symlink(target, dest);
|
|
54
|
+
return;
|
|
55
|
+
} catch {
|
|
56
|
+
// Windows often disallows symlink creation for normal users. Fall
|
|
57
|
+
// through to copying the resolved target so the snapshot remains usable.
|
|
58
|
+
const stat = await Deno.stat(src);
|
|
59
|
+
if (stat.isDirectory) {
|
|
60
|
+
await copyDirectory(src, dest);
|
|
61
|
+
} else {
|
|
62
|
+
await Deno.copyFile(src, dest);
|
|
63
|
+
}
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (info.isDirectory) {
|
|
69
|
+
await copyDirectory(src, dest);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (info.isFile) {
|
|
74
|
+
await Deno.mkdir(dirname(dest), { recursive: true });
|
|
75
|
+
await Deno.copyFile(src, dest);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function copyDirectory(src: string, dest: string): Promise<void> {
|
|
80
|
+
await Deno.mkdir(dest, { recursive: true });
|
|
81
|
+
for await (const entry of Deno.readDir(src)) {
|
|
82
|
+
await copyEntry(join(src, entry.name), join(dest, entry.name));
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function existingTopLevelDirs(functionsDir: string): Promise<string[]> {
|
|
87
|
+
const dirs: string[] = [];
|
|
88
|
+
for await (const entry of Deno.readDir(functionsDir)) {
|
|
89
|
+
if (entry.isDirectory) dirs.push(entry.name);
|
|
90
|
+
}
|
|
91
|
+
dirs.sort();
|
|
92
|
+
return dirs;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function pruneGenerations(
|
|
96
|
+
cacheDir: string,
|
|
97
|
+
keepGenerations: number,
|
|
98
|
+
): Promise<void> {
|
|
99
|
+
const generations: Array<{ name: string; n: number }> = [];
|
|
100
|
+
try {
|
|
101
|
+
for await (const entry of Deno.readDir(cacheDir)) {
|
|
102
|
+
if (!entry.isDirectory) continue;
|
|
103
|
+
const match = /^gen-(\d+)$/.exec(entry.name);
|
|
104
|
+
if (match) generations.push({ name: entry.name, n: Number(match[1]) });
|
|
105
|
+
}
|
|
106
|
+
} catch (err) {
|
|
107
|
+
if (err instanceof Deno.errors.NotFound) return;
|
|
108
|
+
throw err;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
generations.sort((a, b) => b.n - a.n);
|
|
112
|
+
for (const old of generations.slice(Math.max(1, keepGenerations))) {
|
|
113
|
+
await Deno.remove(join(cacheDir, old.name), { recursive: true }).catch(
|
|
114
|
+
() => {},
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export async function createDenoHmrSnapshot(
|
|
120
|
+
options: DenoHmrSnapshotOptions,
|
|
121
|
+
): Promise<DenoHmrSnapshot> {
|
|
122
|
+
const sourceRoot = await Deno.realPath(options.functionsDir);
|
|
123
|
+
const cacheDir = resolvePath(options.cacheDir ?? defaultCacheDir());
|
|
124
|
+
const keepGenerations = Math.max(
|
|
125
|
+
1,
|
|
126
|
+
options.keepGenerations ?? DEFAULT_KEEP_GENERATIONS,
|
|
127
|
+
);
|
|
128
|
+
const root = join(cacheDir, `gen-${options.generation}`);
|
|
129
|
+
const snapshotFunctionsDir = join(root, basename(sourceRoot));
|
|
130
|
+
|
|
131
|
+
await Deno.remove(root, { recursive: true }).catch((err) => {
|
|
132
|
+
if (!(err instanceof Deno.errors.NotFound)) throw err;
|
|
133
|
+
});
|
|
134
|
+
await Deno.mkdir(snapshotFunctionsDir, { recursive: true });
|
|
135
|
+
|
|
136
|
+
const topLevelDirs = await existingTopLevelDirs(sourceRoot);
|
|
137
|
+
const selected = (() => {
|
|
138
|
+
if (options.changed === "all") return topLevelDirs;
|
|
139
|
+
const changed = options.changed;
|
|
140
|
+
return topLevelDirs.filter((name) =>
|
|
141
|
+
isSharedDir(name) || changed.has(name)
|
|
142
|
+
);
|
|
143
|
+
})();
|
|
144
|
+
|
|
145
|
+
for (const name of selected) {
|
|
146
|
+
await copyDirectory(
|
|
147
|
+
join(sourceRoot, name),
|
|
148
|
+
join(snapshotFunctionsDir, name),
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
await pruneGenerations(cacheDir, keepGenerations);
|
|
153
|
+
|
|
154
|
+
return {
|
|
155
|
+
root,
|
|
156
|
+
functionsDir: snapshotFunctionsDir,
|
|
157
|
+
copiedDirs: selected,
|
|
158
|
+
};
|
|
159
|
+
}
|
package/src/discovery.ts
CHANGED
|
@@ -28,6 +28,13 @@ export interface DiscoveryProgress {
|
|
|
28
28
|
export interface DiscoveryOptions {
|
|
29
29
|
/** When set, appended as `?v=` to module URLs to force re-transpilation. */
|
|
30
30
|
cacheBust?: string;
|
|
31
|
+
/**
|
|
32
|
+
* Optional alternate root for dynamic imports. Discovery and manifests still
|
|
33
|
+
* read from `functionsDir`; only module URLs are resolved under this root.
|
|
34
|
+
* Used by Deno-backend HMR snapshots so transitive relative imports get a
|
|
35
|
+
* fresh file URL namespace.
|
|
36
|
+
*/
|
|
37
|
+
importRoot?: string;
|
|
31
38
|
/** Restrict loading to this set of function names (others are skipped entirely). */
|
|
32
39
|
only?: ReadonlySet<string>;
|
|
33
40
|
/** Max number of concurrent dynamic imports. Defaults to 8. */
|
|
@@ -73,6 +80,9 @@ export async function discoverAndLoad(
|
|
|
73
80
|
const errors: Array<{ name: string; error: string }> = [];
|
|
74
81
|
|
|
75
82
|
const resolvedDir = await Deno.realPath(functionsDir);
|
|
83
|
+
const importRoot = options?.importRoot
|
|
84
|
+
? await Deno.realPath(options.importRoot)
|
|
85
|
+
: resolvedDir;
|
|
76
86
|
|
|
77
87
|
const candidates: string[] = [];
|
|
78
88
|
for await (const entry of Deno.readDir(resolvedDir)) {
|
|
@@ -121,7 +131,7 @@ export async function discoverAndLoad(
|
|
|
121
131
|
let completed = 0;
|
|
122
132
|
|
|
123
133
|
const loadOne = async (name: string): Promise<void> => {
|
|
124
|
-
const indexPath = join(
|
|
134
|
+
const indexPath = join(importRoot, name, "index.ts");
|
|
125
135
|
// pathToFileURL is the node:url equivalent of std/path's toFileUrl;
|
|
126
136
|
// both produce a `file://` URL whose searchParams we mutate below for
|
|
127
137
|
// HMR cache-busting.
|
|
@@ -143,7 +153,11 @@ export async function discoverAndLoad(
|
|
|
143
153
|
// on demand. This is the single biggest startup-time win for sites
|
|
144
154
|
// with many rarely-called functions.
|
|
145
155
|
if (options?.lazy && !manifest.warm) {
|
|
146
|
-
registry.registerCandidate({
|
|
156
|
+
registry.registerCandidate({
|
|
157
|
+
name,
|
|
158
|
+
moduleUrl: moduleUrl.href,
|
|
159
|
+
manifest,
|
|
160
|
+
});
|
|
147
161
|
deferred.push(name);
|
|
148
162
|
const durationMs = performance.now() - start;
|
|
149
163
|
options?.onProgress?.({
|
|
@@ -160,7 +174,11 @@ export async function discoverAndLoad(
|
|
|
160
174
|
// Even in lazy mode, eagerly-warm functions still register a candidate
|
|
161
175
|
// so HMR reloads can re-import via the same URL.
|
|
162
176
|
if (options?.lazy && manifest.warm) {
|
|
163
|
-
registry.registerCandidate({
|
|
177
|
+
registry.registerCandidate({
|
|
178
|
+
name,
|
|
179
|
+
moduleUrl: moduleUrl.href,
|
|
180
|
+
manifest,
|
|
181
|
+
});
|
|
164
182
|
}
|
|
165
183
|
await registry.runWithCurrentFunction(name, async () => {
|
|
166
184
|
await import(moduleUrl.href);
|
|
@@ -193,13 +211,16 @@ export async function discoverAndLoad(
|
|
|
193
211
|
// internally, but we still benefit from overlapping transpile + JSR fetches.
|
|
194
212
|
const concurrency = Math.max(1, options?.concurrency ?? 8);
|
|
195
213
|
let cursor = 0;
|
|
196
|
-
const workers = Array.from(
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
214
|
+
const workers = Array.from(
|
|
215
|
+
{ length: Math.min(concurrency, work.length) },
|
|
216
|
+
async () => {
|
|
217
|
+
while (true) {
|
|
218
|
+
const i = cursor++;
|
|
219
|
+
if (i >= work.length) return;
|
|
220
|
+
await loadOne(work[i]);
|
|
221
|
+
}
|
|
222
|
+
},
|
|
223
|
+
);
|
|
203
224
|
await Promise.all(workers);
|
|
204
225
|
|
|
205
226
|
return { loaded, skipped, errors, removed, deferred };
|
package/src/server.ts
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
// cleanly when launched from a host project's node_modules — bare
|
|
17
17
|
// specifiers would otherwise require the host's deno.json to mirror our
|
|
18
18
|
// import map. Same convention applies to every src/ file.
|
|
19
|
-
import {
|
|
19
|
+
import { type Context, Hono } from "npm:hono@4";
|
|
20
20
|
import { bodyLimit } from "npm:hono@4/body-limit";
|
|
21
21
|
import { relative, sep as SEPARATOR } from "node:path";
|
|
22
22
|
import { currentRequestStorage, FunctionRegistry } from "./registry.ts";
|
|
@@ -32,8 +32,15 @@ import { FunctionSupervisor } from "./supervisor.ts";
|
|
|
32
32
|
import { installEnvScope, runWithEnvScope } from "./env-scope.ts";
|
|
33
33
|
import { flushLogs } from "./log-buffer.ts";
|
|
34
34
|
import { VERSION } from "./version.ts";
|
|
35
|
-
import {
|
|
36
|
-
import {
|
|
35
|
+
import { createDenoHmrSnapshot } from "./deno-hmr-snapshot.ts";
|
|
36
|
+
import {
|
|
37
|
+
createWorkerdBackend,
|
|
38
|
+
type WorkerdBackend,
|
|
39
|
+
} from "./backends/workerd/backend.ts";
|
|
40
|
+
import {
|
|
41
|
+
createWorkerdHotReloader,
|
|
42
|
+
type WorkerdHotReloader,
|
|
43
|
+
} from "./backends/workerd/hot-reloader.ts";
|
|
37
44
|
import {
|
|
38
45
|
createWorkerdWatchdog,
|
|
39
46
|
recommendedBudgetBytes,
|
|
@@ -182,10 +189,15 @@ function parseArgs(): CliOpts {
|
|
|
182
189
|
// --lazy or 1TUBE_LAZY=1 when boot speed matters more than first-hit
|
|
183
190
|
// latency (e.g. sites with hundreds of rarely-called functions).
|
|
184
191
|
const lazyEnv = Deno.env.get("1TUBE_LAZY");
|
|
185
|
-
let lazy = lazyEnv === undefined
|
|
192
|
+
let lazy = lazyEnv === undefined
|
|
193
|
+
? false
|
|
194
|
+
: (lazyEnv === "1" || lazyEnv === "true");
|
|
186
195
|
const backendEnv = (Deno.env.get("1TUBE_BACKEND") ?? "").trim().toLowerCase();
|
|
187
|
-
let backend: "deno" | "workerd" = backendEnv === "workerd"
|
|
188
|
-
|
|
196
|
+
let backend: "deno" | "workerd" = backendEnv === "workerd"
|
|
197
|
+
? "workerd"
|
|
198
|
+
: "deno";
|
|
199
|
+
let workerdBin: string | undefined = Deno.env.get("1TUBE_WORKERD_BIN") ||
|
|
200
|
+
undefined;
|
|
189
201
|
// Operator can either use --workerd-env=A,B,C on the CLI or set
|
|
190
202
|
// 1TUBE_WORKERD_ENV. The CLI flag wins; otherwise the backend reads
|
|
191
203
|
// the env var itself so a missing flag still picks up the var.
|
|
@@ -211,7 +223,8 @@ function parseArgs(): CliOpts {
|
|
|
211
223
|
if (v === "1" || v.toLowerCase() === "true") return DEFAULT_INSPECTOR_ADDR;
|
|
212
224
|
return v;
|
|
213
225
|
})();
|
|
214
|
-
let prebuiltDir: string | undefined = Deno.env.get("1TUBE_PREBUILT") ||
|
|
226
|
+
let prebuiltDir: string | undefined = Deno.env.get("1TUBE_PREBUILT") ||
|
|
227
|
+
undefined;
|
|
215
228
|
// Auto-kill leftover workerd processes if the boot preflight finds
|
|
216
229
|
// port conflicts. CLI flag overrides env var; both default to off.
|
|
217
230
|
let killStaleWorkerd: boolean = (() => {
|
|
@@ -225,19 +238,30 @@ function parseArgs(): CliOpts {
|
|
|
225
238
|
const n = parseInt(v, 10);
|
|
226
239
|
return Number.isFinite(n) && n > 0 ? n : undefined;
|
|
227
240
|
})();
|
|
228
|
-
let workerdCompatibilityDate: string | undefined =
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
.
|
|
232
|
-
|
|
241
|
+
let workerdCompatibilityDate: string | undefined =
|
|
242
|
+
Deno.env.get("1TUBE_WORKERD_COMPAT_DATE") || undefined;
|
|
243
|
+
const workerdCompatibilityFlags: string[] =
|
|
244
|
+
(Deno.env.get("1TUBE_WORKERD_COMPAT_FLAGS") ?? "")
|
|
245
|
+
.split(",")
|
|
246
|
+
.map((s) => s.trim())
|
|
247
|
+
.filter((s) => s.length > 0);
|
|
233
248
|
let workerdExperimental = envFlag("1TUBE_WORKERD_EXPERIMENTAL");
|
|
234
249
|
let port = parseInt(Deno.env.get("PORT") || "3100", 10);
|
|
235
250
|
let host = (Deno.env.get("1TUBE_HOST") || "127.0.0.1").trim();
|
|
236
251
|
let functionsPath = Deno.env.get("FUNCTIONS_PATH") || "./supabase/functions";
|
|
237
|
-
let defaultTimeoutMs = parseInt(
|
|
252
|
+
let defaultTimeoutMs = parseInt(
|
|
253
|
+
Deno.env.get("FUNCTION_TIMEOUT_MS") || "150000",
|
|
254
|
+
10,
|
|
255
|
+
);
|
|
238
256
|
const bodyLimitMb = parseFloat(Deno.env.get("1TUBE_BODY_LIMIT_MB") || "30");
|
|
239
|
-
const bodyReadIdleMs = parseInt(
|
|
240
|
-
|
|
257
|
+
const bodyReadIdleMs = parseInt(
|
|
258
|
+
Deno.env.get("1TUBE_BODY_READ_MS") || "30000",
|
|
259
|
+
10,
|
|
260
|
+
);
|
|
261
|
+
const shutdownGraceMs = parseInt(
|
|
262
|
+
Deno.env.get("1TUBE_SHUTDOWN_GRACE_MS") || "10000",
|
|
263
|
+
10,
|
|
264
|
+
);
|
|
241
265
|
|
|
242
266
|
for (let i = 0; i < args.length; i++) {
|
|
243
267
|
const a = args[i];
|
|
@@ -260,14 +284,22 @@ function parseArgs(): CliOpts {
|
|
|
260
284
|
} else if (a === "--backend" && args[i + 1]) {
|
|
261
285
|
const v = args[++i].toLowerCase();
|
|
262
286
|
if (v !== "deno" && v !== "workerd") {
|
|
263
|
-
console.error(
|
|
287
|
+
console.error(
|
|
288
|
+
`[1tube] FATAL: --backend must be 'deno' or 'workerd', got ${
|
|
289
|
+
JSON.stringify(v)
|
|
290
|
+
}`,
|
|
291
|
+
);
|
|
264
292
|
Deno.exit(EXIT_CODES.USAGE);
|
|
265
293
|
}
|
|
266
294
|
backend = v;
|
|
267
295
|
} else if (a.startsWith("--backend=")) {
|
|
268
296
|
const v = a.slice("--backend=".length).toLowerCase();
|
|
269
297
|
if (v !== "deno" && v !== "workerd") {
|
|
270
|
-
console.error(
|
|
298
|
+
console.error(
|
|
299
|
+
`[1tube] FATAL: --backend must be 'deno' or 'workerd', got ${
|
|
300
|
+
JSON.stringify(v)
|
|
301
|
+
}`,
|
|
302
|
+
);
|
|
271
303
|
Deno.exit(EXIT_CODES.USAGE);
|
|
272
304
|
}
|
|
273
305
|
backend = v;
|
|
@@ -322,18 +354,26 @@ function parseArgs(): CliOpts {
|
|
|
322
354
|
workerdCompatibilityFlags.push(args[++i]);
|
|
323
355
|
} else if (a.startsWith("--compat-flag=")) {
|
|
324
356
|
workerdCompatibilityFlags.push(a.slice("--compat-flag=".length));
|
|
325
|
-
} else if (
|
|
357
|
+
} else if (
|
|
358
|
+
a === "--workerd-experimental" || a === "--workerd-experimental=true"
|
|
359
|
+
) {
|
|
326
360
|
workerdExperimental = true;
|
|
327
|
-
} else if (
|
|
361
|
+
} else if (
|
|
362
|
+
a === "--workerd-experimental=false" || a === "--no-workerd-experimental"
|
|
363
|
+
) {
|
|
328
364
|
workerdExperimental = false;
|
|
329
|
-
} else if (
|
|
365
|
+
} else if (
|
|
366
|
+
a === "--kill-stale-workerd" || a === "--kill-stale-workerd=true"
|
|
367
|
+
) {
|
|
330
368
|
// Boolean flag — accept both bare-flag and explicit `=true` forms
|
|
331
369
|
// so we don't surprise scripts that programmatically generate
|
|
332
370
|
// CLI strings. `--no-kill-stale-workerd` is the explicit override
|
|
333
371
|
// for cases where the env var is set globally but a particular
|
|
334
372
|
// run wants the safer behavior.
|
|
335
373
|
killStaleWorkerd = true;
|
|
336
|
-
} else if (
|
|
374
|
+
} else if (
|
|
375
|
+
a === "--kill-stale-workerd=false" || a === "--no-kill-stale-workerd"
|
|
376
|
+
) {
|
|
337
377
|
killStaleWorkerd = false;
|
|
338
378
|
}
|
|
339
379
|
}
|
|
@@ -421,7 +461,9 @@ async function applyDevDefaults() {
|
|
|
421
461
|
if (Deno.env.get(target)) continue;
|
|
422
462
|
const value = sources
|
|
423
463
|
.map((source) => statusEnv[source])
|
|
424
|
-
.find((candidate) =>
|
|
464
|
+
.find((candidate) =>
|
|
465
|
+
typeof candidate === "string" && candidate.length > 0
|
|
466
|
+
);
|
|
425
467
|
if (!value) continue;
|
|
426
468
|
Deno.env.set(target, value);
|
|
427
469
|
applied++;
|
|
@@ -526,7 +568,9 @@ function isInternalRequest(req: Request): boolean {
|
|
|
526
568
|
}
|
|
527
569
|
|
|
528
570
|
function requireInternal(c: Context): Response | null {
|
|
529
|
-
return isInternalRequest(c.req.raw)
|
|
571
|
+
return isInternalRequest(c.req.raw)
|
|
572
|
+
? null
|
|
573
|
+
: c.json({ error: "Forbidden" }, 403);
|
|
530
574
|
}
|
|
531
575
|
|
|
532
576
|
if (opts.dev) {
|
|
@@ -535,17 +579,24 @@ if (opts.dev) {
|
|
|
535
579
|
enforceProdSecrets();
|
|
536
580
|
}
|
|
537
581
|
|
|
538
|
-
const denoVersion =
|
|
582
|
+
const denoVersion =
|
|
583
|
+
`${Deno.version.deno} (V8 ${Deno.version.v8}, TS ${Deno.version.typescript})`;
|
|
539
584
|
const bannerText = ` 1tube v${VERSION} · Deno ${denoVersion} `;
|
|
540
585
|
const bannerLine = "─".repeat(bannerText.length);
|
|
541
586
|
console.log(`\x1b[36m┌${bannerLine}┐\x1b[0m`);
|
|
542
|
-
console.log(
|
|
587
|
+
console.log(
|
|
588
|
+
`\x1b[36m│\x1b[0m \x1b[1m1tube\x1b[0m v${VERSION} \x1b[2m·\x1b[0m Deno ${denoVersion} \x1b[36m│\x1b[0m`,
|
|
589
|
+
);
|
|
543
590
|
console.log(`\x1b[36m└${bannerLine}┘\x1b[0m`);
|
|
544
591
|
console.log(
|
|
545
592
|
`[1tube] mode=${opts.dev ? "dev" : "prod"} hmr=${opts.hmr ? "on" : "off"} ` +
|
|
546
593
|
`lazy=${opts.lazy ? "on" : "off"} backend=${opts.backend} ` +
|
|
547
|
-
`host=${opts.host} bodyLimit=${
|
|
548
|
-
|
|
594
|
+
`host=${opts.host} bodyLimit=${
|
|
595
|
+
(opts.bodyLimitBytes / 1024 / 1024).toFixed(1)
|
|
596
|
+
}MB ` +
|
|
597
|
+
`bodyReadIdle=${
|
|
598
|
+
opts.bodyReadIdleMs > 0 ? opts.bodyReadIdleMs + "ms" : "off"
|
|
599
|
+
}`,
|
|
549
600
|
);
|
|
550
601
|
|
|
551
602
|
const registry = new FunctionRegistry();
|
|
@@ -608,18 +659,19 @@ if (enforceManifest && !allowAll) {
|
|
|
608
659
|
// the dispatcher consults). Empty string is a sentinel for "no
|
|
609
660
|
// functions dir"; the only caller that uses this path is the HMR
|
|
610
661
|
// watcher, and that's gated off when prebuilt is on.
|
|
611
|
-
const resolvedFunctionsPath = opts.prebuiltDir
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
662
|
+
const resolvedFunctionsPath = opts.prebuiltDir ? "" : await (async () => {
|
|
663
|
+
try {
|
|
664
|
+
return await Deno.realPath(opts.functionsPath);
|
|
665
|
+
} catch (err) {
|
|
666
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
667
|
+
console.error(
|
|
668
|
+
`[1tube] FATAL: functions directory not found: ${opts.functionsPath} (${msg})`,
|
|
669
|
+
);
|
|
670
|
+
flushLogs();
|
|
671
|
+
Deno.exit(EXIT_CODES.CONFIG);
|
|
672
|
+
}
|
|
673
|
+
})();
|
|
674
|
+
let denoHmrGeneration = 0;
|
|
623
675
|
|
|
624
676
|
/**
|
|
625
677
|
* Map a changed filesystem path to either a function name or `null` ("shared",
|
|
@@ -628,7 +680,10 @@ const resolvedFunctionsPath = opts.prebuiltDir
|
|
|
628
680
|
*/
|
|
629
681
|
function classifyChangedPath(absPath: string): string | null {
|
|
630
682
|
const rel = relative(resolvedFunctionsPath, absPath);
|
|
631
|
-
if (
|
|
683
|
+
if (
|
|
684
|
+
!rel || rel.startsWith("..") || rel.startsWith(SEPARATOR + "..") ||
|
|
685
|
+
rel === "."
|
|
686
|
+
) {
|
|
632
687
|
return null;
|
|
633
688
|
}
|
|
634
689
|
const first = rel.split(/[\\/]/, 1)[0];
|
|
@@ -651,27 +706,44 @@ async function reloadFunctions(
|
|
|
651
706
|
changedFunctionNames: Set<string> | "all",
|
|
652
707
|
) {
|
|
653
708
|
const isFullReload = changedFunctionNames === "all";
|
|
709
|
+
const isInitialBoot = reason === "initial boot";
|
|
654
710
|
const start = performance.now();
|
|
711
|
+
let importRoot: string | undefined;
|
|
655
712
|
|
|
656
713
|
if (isFullReload) {
|
|
657
714
|
console.log(`[1tube] Reloading all functions (${reason})...`);
|
|
658
|
-
registry.clear();
|
|
659
715
|
} else {
|
|
660
716
|
const names = [...changedFunctionNames].sort();
|
|
661
717
|
if (names.length === 0) return;
|
|
662
718
|
console.log(
|
|
663
|
-
`[1tube] Reloading ${names.length} function(s) (${reason}): ${
|
|
719
|
+
`[1tube] Reloading ${names.length} function(s) (${reason}): ${
|
|
720
|
+
names.join(", ")
|
|
721
|
+
}`,
|
|
664
722
|
);
|
|
665
723
|
}
|
|
666
724
|
|
|
725
|
+
if (!isInitialBoot) {
|
|
726
|
+
const snapshot = await createDenoHmrSnapshot({
|
|
727
|
+
functionsDir: opts.functionsPath,
|
|
728
|
+
changed: changedFunctionNames,
|
|
729
|
+
generation: ++denoHmrGeneration,
|
|
730
|
+
});
|
|
731
|
+
importRoot = snapshot.functionsDir;
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
if (isFullReload) {
|
|
735
|
+
registry.clear();
|
|
736
|
+
}
|
|
737
|
+
|
|
667
738
|
// Cache-bust only when re-importing modules that may already be in Deno's
|
|
668
739
|
// module cache — i.e. any reload after the initial boot.
|
|
669
|
-
const cacheBust =
|
|
670
|
-
|
|
740
|
+
const cacheBust = isInitialBoot
|
|
741
|
+
? undefined
|
|
742
|
+
: `${Date.now()}-${crypto.randomUUID()}`;
|
|
671
743
|
|
|
672
744
|
// HMR reloads always import eagerly so the change is observable on the
|
|
673
745
|
// very next request — even when boot-time discovery was lazy.
|
|
674
|
-
const useLazy = opts.lazy &&
|
|
746
|
+
const useLazy = opts.lazy && isInitialBoot;
|
|
675
747
|
|
|
676
748
|
// Append-only "still working" heartbeat. Does not redraw, does not race
|
|
677
749
|
// with foreign console.log() from imported modules — see
|
|
@@ -691,6 +763,7 @@ async function reloadFunctions(
|
|
|
691
763
|
registry,
|
|
692
764
|
{
|
|
693
765
|
cacheBust,
|
|
766
|
+
importRoot,
|
|
694
767
|
only: isFullReload ? undefined : changedFunctionNames,
|
|
695
768
|
onStart: (p) => {
|
|
696
769
|
// Late-bind the total on the first start event — by then we
|
|
@@ -748,7 +821,9 @@ async function reloadFunctions(
|
|
|
748
821
|
}
|
|
749
822
|
} else {
|
|
750
823
|
console.log(
|
|
751
|
-
`[1tube] Reloaded ${loaded.length} function(s) in ${
|
|
824
|
+
`[1tube] Reloaded ${loaded.length} function(s) in ${
|
|
825
|
+
totalMs.toFixed(0)
|
|
826
|
+
}ms ` +
|
|
752
827
|
`(${registry.size} total registered)`,
|
|
753
828
|
);
|
|
754
829
|
}
|
|
@@ -778,7 +853,8 @@ if (opts.backend === "workerd") {
|
|
|
778
853
|
// Surface the security implication BEFORE we bring workerd up so
|
|
779
854
|
// an operator who typo'd `--inspector` on a public host has a
|
|
780
855
|
// chance to ctrl-C before any code runs.
|
|
781
|
-
const isLoopback =
|
|
856
|
+
const isLoopback =
|
|
857
|
+
/^(127\.0\.0\.1|localhost|\[::1\])(:|$)/.test(opts.workerdInspector) ||
|
|
782
858
|
/^(127\.0\.0\.1|localhost):/.test(opts.workerdInspector) ||
|
|
783
859
|
/^\d+$/.test(opts.workerdInspector);
|
|
784
860
|
if (!isLoopback) {
|
|
@@ -808,14 +884,23 @@ if (opts.backend === "workerd") {
|
|
|
808
884
|
`[1tube] auto-kill leftover workerd: ON (--kill-stale-workerd)`,
|
|
809
885
|
);
|
|
810
886
|
}
|
|
811
|
-
if (
|
|
887
|
+
if (
|
|
888
|
+
opts.workerdCompatibilityDate ||
|
|
889
|
+
(opts.workerdCompatibilityFlags?.length ?? 0) > 0
|
|
890
|
+
) {
|
|
812
891
|
console.log(
|
|
813
|
-
`[1tube] workerd compatibility: date=${
|
|
814
|
-
|
|
892
|
+
`[1tube] workerd compatibility: date=${
|
|
893
|
+
opts.workerdCompatibilityDate ?? "default"
|
|
894
|
+
} ` +
|
|
895
|
+
`flags=${
|
|
896
|
+
(opts.workerdCompatibilityFlags ?? []).join(",") || "default"
|
|
897
|
+
}`,
|
|
815
898
|
);
|
|
816
899
|
}
|
|
817
900
|
if (opts.workerdExperimental) {
|
|
818
|
-
console.log(
|
|
901
|
+
console.log(
|
|
902
|
+
`[1tube] workerd process experimental mode: ON (--experimental)`,
|
|
903
|
+
);
|
|
819
904
|
}
|
|
820
905
|
workerdBackend = createWorkerdBackend({
|
|
821
906
|
functionsDir: opts.functionsPath,
|
|
@@ -830,7 +915,9 @@ if (opts.backend === "workerd") {
|
|
|
830
915
|
...(opts.workerdBasePort ? { basePort: opts.workerdBasePort } : {}),
|
|
831
916
|
...(opts.workerdInspector ? { inspectorAddr: opts.workerdInspector } : {}),
|
|
832
917
|
...(opts.workerdMaxHeapMB ? { maxHeapMB: opts.workerdMaxHeapMB } : {}),
|
|
833
|
-
...(opts.workerdCompatibilityDate
|
|
918
|
+
...(opts.workerdCompatibilityDate
|
|
919
|
+
? { compatibilityDate: opts.workerdCompatibilityDate }
|
|
920
|
+
: {}),
|
|
834
921
|
...((opts.workerdCompatibilityFlags?.length ?? 0) > 0
|
|
835
922
|
? { compatibilityFlags: opts.workerdCompatibilityFlags }
|
|
836
923
|
: {}),
|
|
@@ -841,7 +928,9 @@ if (opts.backend === "workerd") {
|
|
|
841
928
|
logLineSink: (line) => {
|
|
842
929
|
// Surface workerd's own logs through stderr with a tag so they
|
|
843
930
|
// interleave with [1tube] output cleanly.
|
|
844
|
-
try {
|
|
931
|
+
try {
|
|
932
|
+
Deno.stderr.writeSync(new TextEncoder().encode(`[workerd] ${line}\n`));
|
|
933
|
+
} catch { /* */ }
|
|
845
934
|
},
|
|
846
935
|
onUnexpectedExit: ({ crashCount, expectedRetry }) => {
|
|
847
936
|
if (!expectedRetry) {
|
|
@@ -893,7 +982,9 @@ if (opts.backend === "workerd") {
|
|
|
893
982
|
}
|
|
894
983
|
const wdMs = performance.now() - wdStart;
|
|
895
984
|
console.log(
|
|
896
|
-
`[1tube] workerd backend ready (v${
|
|
985
|
+
`[1tube] workerd backend ready (v${
|
|
986
|
+
workerdBackend.workerdVersion ?? "?"
|
|
987
|
+
}) ` +
|
|
897
988
|
`· ${workerdNames.size} function(s) in ${wdMs.toFixed(0)}ms`,
|
|
898
989
|
);
|
|
899
990
|
if (workerdNames.size > 0) {
|
|
@@ -906,7 +997,10 @@ if (opts.backend === "workerd") {
|
|
|
906
997
|
// yields nothing (no manifest declares a memory hint), the
|
|
907
998
|
// watchdog stays off — silently, so existing deployments aren't
|
|
908
999
|
// surprised by sudden recycle behaviour.
|
|
909
|
-
const explicitCap = parseInt(
|
|
1000
|
+
const explicitCap = parseInt(
|
|
1001
|
+
Deno.env.get("1TUBE_WORKERD_MAX_RSS_MB") || "",
|
|
1002
|
+
10,
|
|
1003
|
+
);
|
|
910
1004
|
const explicitBudget = Number.isFinite(explicitCap) && explicitCap > 0
|
|
911
1005
|
? explicitCap * 1024 * 1024
|
|
912
1006
|
: null;
|
|
@@ -915,11 +1009,16 @@ if (opts.backend === "workerd") {
|
|
|
915
1009
|
// boot log so operators know what the recommendation was based on.
|
|
916
1010
|
let declaredSum = 0;
|
|
917
1011
|
for (const m of workerdBackend.manifests.values()) {
|
|
918
|
-
if (typeof m.memoryMB === "number" && m.memoryMB > 0)
|
|
1012
|
+
if (typeof m.memoryMB === "number" && m.memoryMB > 0) {
|
|
1013
|
+
declaredSum += m.memoryMB;
|
|
1014
|
+
}
|
|
919
1015
|
}
|
|
920
1016
|
const budget = explicitBudget ?? recommended;
|
|
921
1017
|
if (budget !== null) {
|
|
922
|
-
const intervalMs = parseInt(
|
|
1018
|
+
const intervalMs = parseInt(
|
|
1019
|
+
Deno.env.get("1TUBE_WORKERD_RSS_INTERVAL_MS") || "",
|
|
1020
|
+
10,
|
|
1021
|
+
);
|
|
923
1022
|
workerdWatchdog = createWorkerdWatchdog({
|
|
924
1023
|
backend: workerdBackend,
|
|
925
1024
|
budgetBytes: budget,
|
|
@@ -932,7 +1031,9 @@ if (opts.backend === "workerd") {
|
|
|
932
1031
|
? "1TUBE_WORKERD_MAX_RSS_MB"
|
|
933
1032
|
: `manifest sum (${declaredSum}MB × 1.5 + 64MB overhead)`;
|
|
934
1033
|
console.log(
|
|
935
|
-
`[1tube] workerd memory watchdog ON: budget=${
|
|
1034
|
+
`[1tube] workerd memory watchdog ON: budget=${
|
|
1035
|
+
(budget / 1024 / 1024).toFixed(0)
|
|
1036
|
+
}MB (source: ${src})`,
|
|
936
1037
|
);
|
|
937
1038
|
} else {
|
|
938
1039
|
console.log(
|
|
@@ -1052,7 +1153,9 @@ const rpmOverride = Number.isFinite(defaultRpmEnv) && defaultRpmEnv > 0
|
|
|
1052
1153
|
? defaultRpmEnv
|
|
1053
1154
|
: null;
|
|
1054
1155
|
if (rpmOverride !== null) {
|
|
1055
|
-
console.log(
|
|
1156
|
+
console.log(
|
|
1157
|
+
`[1tube] default rpm overridden via 1TUBE_DEFAULT_RPM=${rpmOverride}`,
|
|
1158
|
+
);
|
|
1056
1159
|
}
|
|
1057
1160
|
const rateLimiter = createRateLimiter({
|
|
1058
1161
|
registry,
|
|
@@ -1146,7 +1249,9 @@ app.all("/functions/v1/:name{.+}", async (c) => {
|
|
|
1146
1249
|
const decision = supervisor.admit(name);
|
|
1147
1250
|
if (!decision.ok) {
|
|
1148
1251
|
const headers: Record<string, string> = {};
|
|
1149
|
-
if (decision.retryAfter)
|
|
1252
|
+
if (decision.retryAfter) {
|
|
1253
|
+
headers["Retry-After"] = String(decision.retryAfter);
|
|
1254
|
+
}
|
|
1150
1255
|
return c.json(
|
|
1151
1256
|
{ error: "Service temporarily unavailable", reason: decision.reason },
|
|
1152
1257
|
decision.status as 503,
|
|
@@ -1158,18 +1263,22 @@ app.all("/functions/v1/:name{.+}", async (c) => {
|
|
|
1158
1263
|
const rawBody = c.req.raw.body;
|
|
1159
1264
|
const wrappedBody = rawBody !== null
|
|
1160
1265
|
? watchdogBody(rawBody, opts.bodyReadIdleMs, abort, (idleMs) => {
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1266
|
+
console.warn(
|
|
1267
|
+
`[1tube] Body read stalled in "${name}" (no progress in ${idleMs}ms) — aborting request.`,
|
|
1268
|
+
);
|
|
1269
|
+
}).body
|
|
1165
1270
|
: null;
|
|
1166
1271
|
// Mirror the Deno-path URL rewrite: user code expects to see the
|
|
1167
1272
|
// pathname without the `/functions/v1` prefix, matching Supabase
|
|
1168
1273
|
// Edge Runtime behaviour. Without this strip, hello/echo see the
|
|
1169
1274
|
// gateway prefix in their `new URL(req.url).pathname`.
|
|
1170
1275
|
const originalUrl = new URL(c.req.raw.url);
|
|
1171
|
-
const rewrittenPath =
|
|
1172
|
-
|
|
1276
|
+
const rewrittenPath =
|
|
1277
|
+
originalUrl.pathname.replace(/^\/functions\/v1/, "") || "/";
|
|
1278
|
+
const rewrittenUrl = new URL(
|
|
1279
|
+
rewrittenPath + originalUrl.search,
|
|
1280
|
+
originalUrl.origin,
|
|
1281
|
+
);
|
|
1173
1282
|
const proxyReq = new Request(rewrittenUrl.toString(), {
|
|
1174
1283
|
method: c.req.raw.method,
|
|
1175
1284
|
headers: c.req.raw.headers,
|
|
@@ -1184,20 +1293,36 @@ app.all("/functions/v1/:name{.+}", async (c) => {
|
|
|
1184
1293
|
// gateway-wide default. Same precedence the Deno path uses.
|
|
1185
1294
|
const timeoutMs = wdManifest?.timeoutMs ?? opts.defaultTimeoutMs;
|
|
1186
1295
|
const timer = timeoutMs > 0
|
|
1187
|
-
? setTimeout(
|
|
1296
|
+
? setTimeout(
|
|
1297
|
+
() =>
|
|
1298
|
+
abort.abort(
|
|
1299
|
+
new Error(`workerd dispatch timed out after ${timeoutMs}ms`),
|
|
1300
|
+
),
|
|
1301
|
+
timeoutMs,
|
|
1302
|
+
)
|
|
1188
1303
|
: null;
|
|
1189
1304
|
|
|
1190
1305
|
let response: Response;
|
|
1191
1306
|
let errored = false;
|
|
1192
1307
|
try {
|
|
1193
|
-
response = await workerdBackend.dispatch(
|
|
1308
|
+
response = await workerdBackend.dispatch(
|
|
1309
|
+
proxyReq,
|
|
1310
|
+
name,
|
|
1311
|
+
auth,
|
|
1312
|
+
abort.signal,
|
|
1313
|
+
);
|
|
1194
1314
|
} catch (err) {
|
|
1195
1315
|
errored = true;
|
|
1196
|
-
const reasonMsg = (abort.signal.reason as Error | undefined)?.message ??
|
|
1197
|
-
|
|
1316
|
+
const reasonMsg = (abort.signal.reason as Error | undefined)?.message ??
|
|
1317
|
+
"";
|
|
1318
|
+
if (
|
|
1319
|
+
err instanceof DOMException && err.name === "AbortError" &&
|
|
1320
|
+
reasonMsg.includes("timed out")
|
|
1321
|
+
) {
|
|
1198
1322
|
response = c.json({ error: "Function execution timed out" }, 504);
|
|
1199
1323
|
} else if (
|
|
1200
|
-
err instanceof DOMException && err.name === "AbortError" &&
|
|
1324
|
+
err instanceof DOMException && err.name === "AbortError" &&
|
|
1325
|
+
reasonMsg.includes("Body read stalled")
|
|
1201
1326
|
) {
|
|
1202
1327
|
response = c.json({ error: "Request body read timed out" }, 408);
|
|
1203
1328
|
} else {
|
|
@@ -1218,9 +1343,7 @@ app.all("/functions/v1/:name{.+}", async (c) => {
|
|
|
1218
1343
|
const outcome = supervisor.record(name, errored);
|
|
1219
1344
|
if (outcome.breakerJustTripped) {
|
|
1220
1345
|
console.warn(
|
|
1221
|
-
`[1tube] Circuit breaker OPEN for "${name}" (workerd) — refusing requests for ${
|
|
1222
|
-
wdManifest.recycle.cooldownMs
|
|
1223
|
-
}ms (errorRate >= ${wdManifest.recycle.errorRate}).`,
|
|
1346
|
+
`[1tube] Circuit breaker OPEN for "${name}" (workerd) — refusing requests for ${wdManifest.recycle.cooldownMs}ms (errorRate >= ${wdManifest.recycle.errorRate}).`,
|
|
1224
1347
|
);
|
|
1225
1348
|
}
|
|
1226
1349
|
if (outcome.recycleJustRecommended) {
|
|
@@ -1253,7 +1376,9 @@ app.all("/functions/v1/:name{.+}", async (c) => {
|
|
|
1253
1376
|
const decision = supervisor.admit(name);
|
|
1254
1377
|
if (!decision.ok) {
|
|
1255
1378
|
const headers: Record<string, string> = {};
|
|
1256
|
-
if (decision.retryAfter)
|
|
1379
|
+
if (decision.retryAfter) {
|
|
1380
|
+
headers["Retry-After"] = String(decision.retryAfter);
|
|
1381
|
+
}
|
|
1257
1382
|
return c.json(
|
|
1258
1383
|
{ error: "Service temporarily unavailable", reason: decision.reason },
|
|
1259
1384
|
decision.status as 503,
|
|
@@ -1261,12 +1386,17 @@ app.all("/functions/v1/:name{.+}", async (c) => {
|
|
|
1261
1386
|
);
|
|
1262
1387
|
}
|
|
1263
1388
|
|
|
1264
|
-
const timeoutMs = fn.timeoutMs ?? fn.manifest.timeoutMs ??
|
|
1389
|
+
const timeoutMs = fn.timeoutMs ?? fn.manifest.timeoutMs ??
|
|
1390
|
+
opts.defaultTimeoutMs;
|
|
1265
1391
|
|
|
1266
1392
|
// Strip /functions/v1 prefix to match Supabase Edge Runtime behavior.
|
|
1267
1393
|
const originalUrl = new URL(c.req.raw.url);
|
|
1268
|
-
const rewrittenPath = originalUrl.pathname.replace(/^\/functions\/v1/, "") ||
|
|
1269
|
-
|
|
1394
|
+
const rewrittenPath = originalUrl.pathname.replace(/^\/functions\/v1/, "") ||
|
|
1395
|
+
"/";
|
|
1396
|
+
const rewrittenUrl = new URL(
|
|
1397
|
+
rewrittenPath + originalUrl.search,
|
|
1398
|
+
originalUrl.origin,
|
|
1399
|
+
);
|
|
1270
1400
|
|
|
1271
1401
|
const abort = new AbortController();
|
|
1272
1402
|
|
|
@@ -1280,10 +1410,10 @@ app.all("/functions/v1/:name{.+}", async (c) => {
|
|
|
1280
1410
|
const rawBody = c.req.raw.body;
|
|
1281
1411
|
const wrappedBody = rawBody !== null
|
|
1282
1412
|
? watchdogBody(rawBody, opts.bodyReadIdleMs, abort, (idleMs) => {
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1413
|
+
console.warn(
|
|
1414
|
+
`[1tube] Body read stalled in "${name}" (no progress in ${idleMs}ms) — aborting request.`,
|
|
1415
|
+
);
|
|
1416
|
+
}).body
|
|
1287
1417
|
: null;
|
|
1288
1418
|
|
|
1289
1419
|
const rewrittenReq = new Request(rewrittenUrl.toString(), {
|
|
@@ -1313,10 +1443,10 @@ app.all("/functions/v1/:name{.+}", async (c) => {
|
|
|
1313
1443
|
// function) to the correct function — preserved across awaits in 2.7+.
|
|
1314
1444
|
const inner = enforceManifest && !allowAll
|
|
1315
1445
|
? () =>
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1446
|
+
runWithEnvScope(
|
|
1447
|
+
{ functionName: name, allow: new Set(fn.manifest.permissions.env) },
|
|
1448
|
+
invoke,
|
|
1449
|
+
)
|
|
1320
1450
|
: invoke;
|
|
1321
1451
|
|
|
1322
1452
|
const handlerPromise = Promise.resolve(
|
|
@@ -1330,8 +1460,10 @@ app.all("/functions/v1/:name{.+}", async (c) => {
|
|
|
1330
1460
|
const timeout = new Promise<never>((_, reject) =>
|
|
1331
1461
|
setTimeout(() => {
|
|
1332
1462
|
abort.abort();
|
|
1333
|
-
reject(
|
|
1334
|
-
|
|
1463
|
+
reject(
|
|
1464
|
+
new Error(`Function "${name}" timed out after ${timeoutMs}ms`),
|
|
1465
|
+
);
|
|
1466
|
+
}, timeoutMs)
|
|
1335
1467
|
);
|
|
1336
1468
|
response = await Promise.race([handlerPromise, timeout]);
|
|
1337
1469
|
} else {
|
|
@@ -1361,9 +1493,7 @@ app.all("/functions/v1/:name{.+}", async (c) => {
|
|
|
1361
1493
|
const outcome = supervisor.record(name, errored);
|
|
1362
1494
|
if (outcome.breakerJustTripped) {
|
|
1363
1495
|
console.warn(
|
|
1364
|
-
`[1tube] Circuit breaker OPEN for "${name}" — refusing requests for ${
|
|
1365
|
-
fn.manifest.recycle.cooldownMs
|
|
1366
|
-
}ms (errorRate >= ${fn.manifest.recycle.errorRate}).`,
|
|
1496
|
+
`[1tube] Circuit breaker OPEN for "${name}" — refusing requests for ${fn.manifest.recycle.cooldownMs}ms (errorRate >= ${fn.manifest.recycle.errorRate}).`,
|
|
1367
1497
|
);
|
|
1368
1498
|
}
|
|
1369
1499
|
if (outcome.recycleJustRecommended) {
|
|
@@ -1426,11 +1556,16 @@ app.get(
|
|
|
1426
1556
|
}),
|
|
1427
1557
|
);
|
|
1428
1558
|
|
|
1429
|
-
async function runFunctionAdmin(
|
|
1559
|
+
async function runFunctionAdmin(
|
|
1560
|
+
c: Context,
|
|
1561
|
+
action: (backend: WorkerdBackend) => Promise<Response>,
|
|
1562
|
+
): Promise<Response> {
|
|
1430
1563
|
const forbidden = requireInternal(c);
|
|
1431
1564
|
if (forbidden) return forbidden;
|
|
1432
1565
|
if (!workerdBackend) {
|
|
1433
|
-
return c.json({
|
|
1566
|
+
return c.json({
|
|
1567
|
+
error: "Edge function editor is available only with backend=workerd",
|
|
1568
|
+
}, 503);
|
|
1434
1569
|
}
|
|
1435
1570
|
try {
|
|
1436
1571
|
return await action(workerdBackend);
|
|
@@ -1440,14 +1575,23 @@ async function runFunctionAdmin(c: Context, action: (backend: WorkerdBackend) =>
|
|
|
1440
1575
|
}
|
|
1441
1576
|
}
|
|
1442
1577
|
|
|
1443
|
-
app.get(
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1578
|
+
app.get(
|
|
1579
|
+
"/1tube/api/functions",
|
|
1580
|
+
(c) =>
|
|
1581
|
+
runFunctionAdmin(c, async (backend) =>
|
|
1582
|
+
c.json({
|
|
1583
|
+
functions: await backend.listEditableFunctions(),
|
|
1584
|
+
})),
|
|
1447
1585
|
);
|
|
1448
1586
|
|
|
1449
|
-
app.get(
|
|
1450
|
-
|
|
1587
|
+
app.get(
|
|
1588
|
+
"/1tube/api/functions/:name/source",
|
|
1589
|
+
(c) =>
|
|
1590
|
+
runFunctionAdmin(
|
|
1591
|
+
c,
|
|
1592
|
+
async (backend) =>
|
|
1593
|
+
c.json(await backend.readEditableSource(c.req.param("name"))),
|
|
1594
|
+
),
|
|
1451
1595
|
);
|
|
1452
1596
|
|
|
1453
1597
|
app.post("/1tube/api/functions", (c) =>
|
|
@@ -1457,36 +1601,58 @@ app.post("/1tube/api/functions", (c) =>
|
|
|
1457
1601
|
return c.json({ error: "Missing function name" }, 400);
|
|
1458
1602
|
}
|
|
1459
1603
|
const source = await backend.createEditableFunction(body.name);
|
|
1460
|
-
return c.json(
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
app.put("/1tube/api/functions/:name/source", (c) =>
|
|
1465
|
-
runFunctionAdmin(c, async (backend) => {
|
|
1466
|
-
const body = await c.req.json().catch(() => ({})) as { files?: unknown };
|
|
1467
|
-
if (!body.files || typeof body.files !== "object" || Array.isArray(body.files)) {
|
|
1468
|
-
return c.json({ error: "Expected JSON body { files: { path: content } }" }, 400);
|
|
1469
|
-
}
|
|
1470
|
-
const reload = await backend.saveEditableSource(
|
|
1471
|
-
c.req.param("name"),
|
|
1472
|
-
{ files: body.files as Record<string, string> },
|
|
1604
|
+
return c.json(
|
|
1605
|
+
{ source, functions: await backend.listEditableFunctions() },
|
|
1606
|
+
201,
|
|
1473
1607
|
);
|
|
1474
|
-
|
|
1475
|
-
|
|
1608
|
+
}));
|
|
1609
|
+
|
|
1610
|
+
app.put(
|
|
1611
|
+
"/1tube/api/functions/:name/source",
|
|
1612
|
+
(c) =>
|
|
1613
|
+
runFunctionAdmin(c, async (backend) => {
|
|
1614
|
+
const body = await c.req.json().catch(() => ({})) as { files?: unknown };
|
|
1615
|
+
if (
|
|
1616
|
+
!body.files || typeof body.files !== "object" ||
|
|
1617
|
+
Array.isArray(body.files)
|
|
1618
|
+
) {
|
|
1619
|
+
return c.json({
|
|
1620
|
+
error: "Expected JSON body { files: { path: content } }",
|
|
1621
|
+
}, 400);
|
|
1622
|
+
}
|
|
1623
|
+
const reload = await backend.saveEditableSource(
|
|
1624
|
+
c.req.param("name"),
|
|
1625
|
+
{ files: body.files as Record<string, string> },
|
|
1626
|
+
);
|
|
1627
|
+
return c.json({
|
|
1628
|
+
reload,
|
|
1629
|
+
functions: await backend.listEditableFunctions(),
|
|
1630
|
+
});
|
|
1631
|
+
}),
|
|
1476
1632
|
);
|
|
1477
1633
|
|
|
1478
|
-
app.delete(
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1634
|
+
app.delete(
|
|
1635
|
+
"/1tube/api/functions/:name",
|
|
1636
|
+
(c) =>
|
|
1637
|
+
runFunctionAdmin(c, async (backend) => {
|
|
1638
|
+
const reload = await backend.deleteEditableFunction(c.req.param("name"));
|
|
1639
|
+
return c.json({
|
|
1640
|
+
reload,
|
|
1641
|
+
functions: await backend.listEditableFunctions(),
|
|
1642
|
+
});
|
|
1643
|
+
}),
|
|
1483
1644
|
);
|
|
1484
1645
|
|
|
1485
|
-
app.post(
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1646
|
+
app.post(
|
|
1647
|
+
"/1tube/api/functions/:name/revert",
|
|
1648
|
+
(c) =>
|
|
1649
|
+
runFunctionAdmin(c, async (backend) => {
|
|
1650
|
+
const reload = await backend.revertEditableFunction(c.req.param("name"));
|
|
1651
|
+
return c.json({
|
|
1652
|
+
reload,
|
|
1653
|
+
functions: await backend.listEditableFunctions(),
|
|
1654
|
+
});
|
|
1655
|
+
}),
|
|
1490
1656
|
);
|
|
1491
1657
|
|
|
1492
1658
|
// Root liveness probe — intentionally minimal so we don't leak the registered
|
|
@@ -1515,9 +1681,12 @@ async function shutdown(reason: string) {
|
|
|
1515
1681
|
if (shuttingDown) return;
|
|
1516
1682
|
shuttingDown = true;
|
|
1517
1683
|
const totalGraceMs = opts.shutdownGraceMs;
|
|
1518
|
-
console.log(
|
|
1684
|
+
console.log(
|
|
1685
|
+
`[1tube] Shutdown requested (${reason}); draining up to ${totalGraceMs}ms...`,
|
|
1686
|
+
);
|
|
1519
1687
|
const shutdownStartedAt = performance.now();
|
|
1520
|
-
const remaining = () =>
|
|
1688
|
+
const remaining = () =>
|
|
1689
|
+
Math.max(0, totalGraceMs - (performance.now() - shutdownStartedAt));
|
|
1521
1690
|
|
|
1522
1691
|
watcherAbort?.abort();
|
|
1523
1692
|
|
|
@@ -1545,7 +1714,9 @@ async function shutdown(reason: string) {
|
|
|
1545
1714
|
const drainStart = performance.now();
|
|
1546
1715
|
const drained = await Promise.race([
|
|
1547
1716
|
server.finished.then(() => "drained" as const),
|
|
1548
|
-
new Promise<"timeout">((resolve) =>
|
|
1717
|
+
new Promise<"timeout">((resolve) =>
|
|
1718
|
+
setTimeout(() => resolve("timeout"), remaining())
|
|
1719
|
+
),
|
|
1549
1720
|
]);
|
|
1550
1721
|
const drainMs = performance.now() - drainStart;
|
|
1551
1722
|
if (drained === "timeout") {
|
|
@@ -1554,7 +1725,11 @@ async function shutdown(reason: string) {
|
|
|
1554
1725
|
`forcing workerd teardown — some in-flight requests may have been dropped.`,
|
|
1555
1726
|
);
|
|
1556
1727
|
} else {
|
|
1557
|
-
console.log(
|
|
1728
|
+
console.log(
|
|
1729
|
+
`[1tube] Gateway drained ${
|
|
1730
|
+
drainMs.toFixed(0)
|
|
1731
|
+
}ms; tearing down workerd...`,
|
|
1732
|
+
);
|
|
1558
1733
|
}
|
|
1559
1734
|
|
|
1560
1735
|
// Phase 3: stop the workerd subprocess. Now safe to tear down —
|
|
@@ -1571,11 +1746,15 @@ async function shutdown(reason: string) {
|
|
|
1571
1746
|
console.warn("[1tube] workerd backend stop() error:", err);
|
|
1572
1747
|
return "stopped" as const;
|
|
1573
1748
|
}),
|
|
1574
|
-
new Promise<"timeout">((resolve) =>
|
|
1749
|
+
new Promise<"timeout">((resolve) =>
|
|
1750
|
+
setTimeout(() => resolve("timeout"), wdGraceMs)
|
|
1751
|
+
),
|
|
1575
1752
|
]);
|
|
1576
1753
|
const wdMs = performance.now() - wdStart;
|
|
1577
1754
|
if (wdResult === "timeout") {
|
|
1578
|
-
console.warn(
|
|
1755
|
+
console.warn(
|
|
1756
|
+
`[1tube] workerd stop() did not return within ${wdGraceMs}ms — abandoning.`,
|
|
1757
|
+
);
|
|
1579
1758
|
} else {
|
|
1580
1759
|
console.log(`[1tube] workerd stopped in ${wdMs.toFixed(0)}ms.`);
|
|
1581
1760
|
}
|