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