1tube 0.1.13 → 0.1.15

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 CHANGED
@@ -16,6 +16,7 @@
16
16
  "@std/assert": "jsr:@std/assert@^1",
17
17
  "hono": "npm:hono@4",
18
18
  "esbuild": "npm:esbuild@^0.28",
19
+ "es-module-lexer": "npm:es-module-lexer@^2",
19
20
  "@deno/esbuild-plugin": "jsr:@deno/esbuild-plugin@^1.2",
20
21
  "@zip-js/zip-js": "jsr:@zip-js/zip-js@^2.8"
21
22
  },
package/deno.lock CHANGED
@@ -14,6 +14,7 @@
14
14
  "npm:@google/genai@1.39.0": "1.39.0",
15
15
  "npm:@supabase/supabase-js@2": "2.93.3",
16
16
  "npm:ai@6": "6.0.67_zod@3.25.76",
17
+ "npm:es-module-lexer@2": "2.0.0",
17
18
  "npm:esbuild@0.28": "0.28.0",
18
19
  "npm:esbuild@~0.25.5": "0.25.12",
19
20
  "npm:hono@4": "4.12.14",
@@ -514,6 +515,9 @@
514
515
  "safe-buffer"
515
516
  ]
516
517
  },
518
+ "es-module-lexer@2.0.0": {
519
+ "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw=="
520
+ },
517
521
  "esbuild@0.25.12": {
518
522
  "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
519
523
  "optionalDependencies": [
@@ -860,6 +864,7 @@
860
864
  "jsr:@std/assert@1",
861
865
  "jsr:@std/path@1",
862
866
  "jsr:@zip-js/zip-js@^2.8.0",
867
+ "npm:es-module-lexer@2",
863
868
  "npm:esbuild@0.28",
864
869
  "npm:hono@4"
865
870
  ],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "1tube",
3
- "version": "0.1.13",
3
+ "version": "0.1.15",
4
4
  "private": false,
5
5
  "description": "Self-hosted Supabase Edge Functions gateway for Deno, plus an ESLint plugin for consumers",
6
6
  "license": "MIT",
@@ -639,6 +639,15 @@ export function resolveEnvAllowlist(
639
639
  return { resolved, missing, mode: "restricted" };
640
640
  }
641
641
 
642
+ function runtimeSecretNames(envSource: EnvSource = Deno.env): string[] {
643
+ const raw = envSource.get("1TUBE_SECRET_NAMES") ?? "";
644
+ const names = raw
645
+ .split(",")
646
+ .map((s) => s.trim())
647
+ .filter((s) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(s));
648
+ return [...new Set(names)].sort();
649
+ }
650
+
642
651
  /**
643
652
  * Compute the env vars exposed to a single function as workerd
644
653
  * `fromEnvironment` bindings.
@@ -1121,16 +1130,22 @@ export function createWorkerdBackend(opts: WorkerdBackendOptions): WorkerdBacken
1121
1130
  }
1122
1131
  }
1123
1132
 
1124
- // Resolve the gateway-wide env allowlist. This is the *maximum*
1125
- // surface any single function can ask for; per-function manifests
1126
- // narrow it further via `permissions.env`. Most-restrictive wins
1127
- // — operator declares what's available, the function declares
1128
- // what it actually uses, and we hand workerd the intersection.
1133
+ // Resolve the gateway-wide env policy. Workerd gets live secrets
1134
+ // plus the operator's explicit allowlist. We deliberately do not
1135
+ // infer source usage here: dynamic access is common, and secrets
1136
+ // are runtime configuration.
1137
+ const hasConfiguredEnvPolicy = (opts.envAllowlist?.length ?? 0) > 0 ||
1138
+ ((Deno.env.get("1TUBE_WORKERD_ENV") ?? "").trim().length > 0);
1139
+ const secretNames = runtimeSecretNames();
1129
1140
  const {
1130
- resolved: envBindings,
1141
+ resolved: configuredEnvBindings,
1131
1142
  missing: missingEnv,
1132
- mode: envMode,
1143
+ mode: configuredEnvMode,
1133
1144
  } = resolveEnvAllowlist(opts.envAllowlist);
1145
+ const gatewayEnvBindings = [...new Set([
1146
+ ...secretNames,
1147
+ ...(hasConfiguredEnvPolicy ? configuredEnvBindings : []),
1148
+ ])].sort();
1134
1149
  if (missingEnv.length > 0) {
1135
1150
  console.warn(
1136
1151
  `[1tube] workerd env allowlist references vars not present in the gateway env: ` +
@@ -1138,17 +1153,20 @@ export function createWorkerdBackend(opts: WorkerdBackendOptions): WorkerdBacken
1138
1153
  `Set them before starting the gateway, or remove them from the allowlist.`,
1139
1154
  );
1140
1155
  }
1141
- if (envBindings.length > 0) {
1142
- if (envMode === "all") {
1156
+ if (hasConfiguredEnvPolicy && configuredEnvBindings.length > 0) {
1157
+ if (configuredEnvMode === "all") {
1143
1158
  console.log(
1144
- `[1tube] forwarding all ${envBindings.length} env var(s) to workerd ` +
1145
- `(default; pass --workerd-env=A,B,C or set 1TUBE_WORKERD_ENV to restrict)`,
1159
+ `[1tube] forwarding all ${configuredEnvBindings.length} env var(s) to workerd ` +
1160
+ `plus ${secretNames.length} runtime secret name(s) (explicit '*')`,
1146
1161
  );
1147
1162
  } else {
1148
1163
  console.log(
1149
- `[1tube] forwarding ${envBindings.length} env var(s) to workerd: ${envBindings.join(", ")}`,
1164
+ `[1tube] forwarding ${gatewayEnvBindings.length} env var(s) to workerd ` +
1165
+ `(${secretNames.length} secret(s), ${configuredEnvBindings.length} explicit): ${gatewayEnvBindings.join(", ")}`,
1150
1166
  );
1151
1167
  }
1168
+ } else if (secretNames.length > 0) {
1169
+ console.log(`[1tube] forwarding ${secretNames.length} runtime secret env var(s) to workerd: ${secretNames.join(", ")}`);
1152
1170
  }
1153
1171
 
1154
1172
  // Load each function's 1tube.json. Missing files yield
@@ -1202,7 +1220,7 @@ export function createWorkerdBackend(opts: WorkerdBackendOptions): WorkerdBacken
1202
1220
  const m = newManifests.get(r.name)!;
1203
1221
  const bundleEmbedPath = prebuiltBundleFiles?.get(r.name) ??
1204
1222
  r.bundlePath.split(/[\\/]/).pop()!;
1205
- const fnEnvBindings = intersectEnvForFunction(envBindings, m.permissions.env, enforceManifest);
1223
+ const fnEnvBindings = intersectEnvForFunction(gatewayEnvBindings, m.permissions.env, enforceManifest);
1206
1224
  for (const internalName of internalEnvBindings) {
1207
1225
  if (!fnEnvBindings.includes(internalName)) fnEnvBindings.push(internalName);
1208
1226
  }
@@ -1241,6 +1259,21 @@ export function createWorkerdBackend(opts: WorkerdBackendOptions): WorkerdBacken
1241
1259
 
1242
1260
  const newRoutesByName = new Map(capnp.routes.map((r) => [r.name, r]));
1243
1261
  const newNames = capnp.routes.map((r) => r.name).sort();
1262
+ if (args.gen === 0) {
1263
+ const ports = capnp.routes.map((r) => r.port);
1264
+ const minPort = Math.min(...ports);
1265
+ const maxPort = Math.max(...ports);
1266
+ const totalEnvBindings = capnpInputs.reduce((sum, input) => sum + (input.envBindings?.length ?? 0), 0);
1267
+ const maxEnvBindings = capnpInputs.reduce((max, input) => Math.max(max, input.envBindings?.length ?? 0), 0);
1268
+ console.log(
1269
+ `[1tube] workerd config: path=${capnpPath} services=${capnp.routes.length} ` +
1270
+ `ports=${minPort}${minPort === maxPort ? "" : `-${maxPort}`} ` +
1271
+ `compat=${effectiveCompatDate ?? "default"} ` +
1272
+ `flags=${(opts.compatibilityFlags ?? ["default"]).join(",")} ` +
1273
+ `envMode=${hasConfiguredEnvPolicy ? configuredEnvMode : "secrets"} ` +
1274
+ `envBindingsTotal=${totalEnvBindings} envBindingsMaxPerFn=${maxEnvBindings}`,
1275
+ );
1276
+ }
1244
1277
 
1245
1278
  // Preflight: refuse to spawn workerd against ports that are
1246
1279
  // already in use. Without this, a leftover workerd from a
@@ -1341,11 +1374,16 @@ export function createWorkerdBackend(opts: WorkerdBackendOptions): WorkerdBacken
1341
1374
 
1342
1375
  const maxHeapArgs = buildMaxHeapExtraArgs(opts.maxHeapMB);
1343
1376
  const extraArgs = [...inspectorArgs, ...maxHeapArgs];
1377
+ const verboseWorkerd = (Deno.env.get("1TUBE_WORKERD_VERBOSE") ?? "") === "1";
1378
+ if (verboseWorkerd) {
1379
+ console.log(`[1tube] workerd verbose logging enabled (1TUBE_WORKERD_VERBOSE=1)`);
1380
+ }
1344
1381
 
1345
1382
  const newProcess = createWorkerdProcess({
1346
1383
  binary: workerdBin,
1347
1384
  capnpPath,
1348
1385
  routes: capnp.routes,
1386
+ ...(verboseWorkerd ? { globalArgs: ["-v"] } : {}),
1349
1387
  extraArgs,
1350
1388
  env: sharedRuntime
1351
1389
  ? {
@@ -290,6 +290,12 @@ export interface SharedBundleResult extends SharedRuntimeModule {
290
290
 
291
291
  export async function disposeBundlerResources(): Promise<void> {
292
292
  await esbuild.stop();
293
+ // esbuild.stop() tears down the service process synchronously from
294
+ // esbuild's point of view, but Deno's node:child_process shim settles
295
+ // the underlying spawn wait op on the next turn. Let that completion
296
+ // run before sanitizer-enabled tests assert that no child process
297
+ // resources remain.
298
+ await new Promise((resolve) => setTimeout(resolve, 0));
293
299
  }
294
300
 
295
301
  function sharedModuleStubSource(module: WorkerdSharedModuleInput): string {
@@ -44,6 +44,8 @@ const DEFAULT_READY_TIMEOUT_MS = 15_000;
44
44
  const DEFAULT_PROBE_INTERVAL_MS = 50;
45
45
  /** Default grace period before escalating from SIGTERM to SIGKILL. */
46
46
  const DEFAULT_SHUTDOWN_TIMEOUT_MS = 5_000;
47
+ /** Bounded child-output tail attached to startup failures. */
48
+ const OUTPUT_TAIL_LINES = 800;
47
49
 
48
50
  export interface WorkerdProcessOptions {
49
51
  /** Absolute or PATH-resolvable path to the `workerd` binary. */
@@ -362,6 +364,46 @@ function bestEffortKill(child: Deno.ChildProcess, signal: Deno.Signal): boolean
362
364
  }
363
365
  }
364
366
 
367
+ function quoteArg(arg: string): string {
368
+ return /^[A-Za-z0-9_./:=@+-]+$/.test(arg) ? arg : JSON.stringify(arg);
369
+ }
370
+
371
+ function summarizeRoutes(routes: readonly CapnpRoute[]): string {
372
+ if (routes.length === 0) return "routes=0";
373
+ const ports = routes.map((r) => r.port);
374
+ const min = Math.min(...ports);
375
+ const max = Math.max(...ports);
376
+ const hosts = [...new Set(routes.map((r) => r.address))].join(",");
377
+ return `routes=${routes.length} hosts=${hosts} ports=${min}${min === max ? "" : `-${max}`}`;
378
+ }
379
+
380
+ function formatStartupFailure(opts: {
381
+ command: readonly string[];
382
+ cwd: string;
383
+ capnpPath: string;
384
+ routes: readonly CapnpRoute[];
385
+ exitCode: number | null | undefined;
386
+ outputTail: readonly string[];
387
+ cause: unknown;
388
+ }): string {
389
+ const lines = [
390
+ "workerd exited before sockets became ready",
391
+ ` exitCode=${opts.exitCode ?? "<unknown>"}`,
392
+ ` command=${opts.command.map(quoteArg).join(" ")}`,
393
+ ` cwd=${opts.cwd}`,
394
+ ` capnp=${opts.capnpPath}`,
395
+ ` ${summarizeRoutes(opts.routes)}`,
396
+ ];
397
+ if (opts.cause instanceof Error) {
398
+ lines.push(` readiness=${opts.cause.message}`);
399
+ }
400
+ if (opts.outputTail.length > 0) {
401
+ lines.push(` -- last ${opts.outputTail.length} workerd output line(s) --`);
402
+ for (const line of opts.outputTail) lines.push(` | ${line}`);
403
+ }
404
+ return lines.join("\n");
405
+ }
406
+
365
407
  /** Build the manager. Stateful — caller owns lifecycle. */
366
408
  export function createWorkerdProcess(opts: WorkerdProcessOptions): WorkerdProcess {
367
409
  const sink = opts.logLineSink ?? defaultLogLineSink;
@@ -402,14 +444,23 @@ export function createWorkerdProcess(opts: WorkerdProcessOptions): WorkerdProces
402
444
  // a transitive node-path dependency on this module; the inline
403
445
  // separator-aware split is enough for our purposes.
404
446
  const cwd = opts.cwd ?? opts.capnpPath.replace(/[\\/][^\\/]+$/, "");
447
+ const args = [
448
+ ...(opts.globalArgs ?? []),
449
+ "serve",
450
+ opts.capnpPath,
451
+ ...(opts.extraArgs ?? []),
452
+ ];
453
+ const command = [opts.binary, ...args];
454
+ const outputTail: string[] = [];
455
+ let exitCode: number | null | undefined;
456
+ const captureLine = (line: string) => {
457
+ outputTail.push(line);
458
+ while (outputTail.length > OUTPUT_TAIL_LINES) outputTail.shift();
459
+ sink(line);
460
+ };
405
461
 
406
462
  const cmd = new Deno.Command(opts.binary, {
407
- args: [
408
- ...(opts.globalArgs ?? []),
409
- "serve",
410
- opts.capnpPath,
411
- ...(opts.extraArgs ?? []),
412
- ],
463
+ args,
413
464
  cwd,
414
465
  env: opts.env,
415
466
  stdout: "piped",
@@ -421,8 +472,8 @@ export function createWorkerdProcess(opts: WorkerdProcessOptions): WorkerdProces
421
472
  // Pump stdout + stderr into the sink. Both pumps are awaited at
422
473
  // shutdown so the consumer can be sure no lines are lost.
423
474
  pumpsDone = Promise.all([
424
- pumpLines(child.stdout, sink),
425
- pumpLines(child.stderr, sink),
475
+ pumpLines(child.stdout, captureLine),
476
+ pumpLines(child.stderr, captureLine),
426
477
  ]).then(() => {});
427
478
 
428
479
  // Watch for early exit so a workerd that crashes during boot
@@ -430,6 +481,7 @@ export function createWorkerdProcess(opts: WorkerdProcessOptions): WorkerdProces
430
481
  // the readiness wait instead of hanging until the timeout.
431
482
  const exitAbort = new AbortController();
432
483
  const exitWatch = child.status.then((status) => {
484
+ exitCode = status.code;
433
485
  const expected = stopRequested;
434
486
  if (!expected) {
435
487
  // Crash before ready or during runtime. Abort the readiness
@@ -455,11 +507,17 @@ export function createWorkerdProcess(opts: WorkerdProcessOptions): WorkerdProces
455
507
  // (extremely rare with workerd, but defence in depth).
456
508
  if (child) bestEffortKill(child, "SIGKILL");
457
509
  await exitWatch.catch(() => {}); // swallow — already in failure path
510
+ if (pumpsDone) await pumpsDone.catch(() => {});
458
511
  if (exitAbort.signal.aborted) {
459
- throw new Error(
460
- `workerd exited before sockets became ready` +
461
- (err instanceof Error ? `: ${err.message}` : ""),
462
- );
512
+ throw new Error(formatStartupFailure({
513
+ command,
514
+ cwd,
515
+ capnpPath: opts.capnpPath,
516
+ routes: opts.routes,
517
+ exitCode,
518
+ outputTail,
519
+ cause: err,
520
+ }));
463
521
  }
464
522
  throw err;
465
523
  }
@@ -53,6 +53,8 @@ export interface FirmwareEnvelope {
53
53
  createdBy: string;
54
54
  /** SHA-256 of `dist/manifest.json` bytes, lowercase hex. */
55
55
  manifestSha256: string;
56
+ /** Stable runtime-content fingerprint; excludes package/build metadata. */
57
+ contentSha256?: string;
56
58
  /** Number of functions in the inner manifest. Diagnostic. */
57
59
  functionCount: number;
58
60
  /** Sum of every bundle's byte length. Diagnostic. */
@@ -163,7 +165,11 @@ export async function signEnvelope(
163
165
  false,
164
166
  ["sign"],
165
167
  );
166
- const sig = await crypto.subtle.sign("HMAC", cryptoKey, message as BufferSource);
168
+ const sig = await crypto.subtle.sign(
169
+ "HMAC",
170
+ cryptoKey,
171
+ message as BufferSource,
172
+ );
167
173
  return {
168
174
  ...unsigned,
169
175
  signature: {
@@ -252,7 +258,10 @@ export function parseEnvelope(raw: unknown): FirmwareEnvelope {
252
258
  throw new Error(`envelope.${k} missing or not a string`);
253
259
  }
254
260
  }
255
- if (typeof o.functionCount !== "number" || typeof o.totalBundleBytes !== "number") {
261
+ if (
262
+ typeof o.functionCount !== "number" ||
263
+ typeof o.totalBundleBytes !== "number"
264
+ ) {
256
265
  throw new Error("envelope.functionCount / .totalBundleBytes missing");
257
266
  }
258
267
  return {
@@ -261,6 +270,9 @@ export function parseEnvelope(raw: unknown): FirmwareEnvelope {
261
270
  createdAt: o.createdAt as string,
262
271
  createdBy: o.createdBy as string,
263
272
  manifestSha256: o.manifestSha256 as string,
273
+ ...(typeof o.contentSha256 === "string" && o.contentSha256.length > 0
274
+ ? { contentSha256: o.contentSha256 }
275
+ : {}),
264
276
  functionCount: o.functionCount as number,
265
277
  totalBundleBytes: o.totalBundleBytes as number,
266
278
  signature: {
@@ -53,6 +53,7 @@ import {
53
53
  parsePrebuiltManifest,
54
54
  type PrebuiltManifest,
55
55
  } from "../backends/workerd/prebuilt.ts";
56
+ import { computeFirmwareContentFingerprint } from "../firmware-content-hash.ts";
56
57
  import { VERSION } from "../version.ts";
57
58
 
58
59
  /**
@@ -326,6 +327,10 @@ export async function packageDist(
326
327
  const files = await collectDistFiles(distDir, manifest, opts.onProgress);
327
328
  const manifestEntry = files.find((f) => f.zipPath === "dist/manifest.json")!;
328
329
  const manifestSha256 = await sha256Hex(manifestEntry.bytes);
330
+ const contentFingerprint = await computeFirmwareContentFingerprint(
331
+ distDir,
332
+ manifest,
333
+ );
329
334
 
330
335
  const totalBundleBytes = manifest.functions.reduce(
331
336
  (acc, f) => acc + f.bundleBytes,
@@ -341,6 +346,7 @@ export async function packageDist(
341
346
  createdAt,
342
347
  createdBy: `1tube@${VERSION}`,
343
348
  manifestSha256,
349
+ contentSha256: contentFingerprint.sha256,
344
350
  functionCount: manifest.functions.length,
345
351
  totalBundleBytes,
346
352
  };
@@ -499,7 +505,12 @@ function makeProgressRenderer(): (e: PackageProgress) => void {
499
505
  : e.phase === "compress"
500
506
  ? "zipping"
501
507
  : "wrote";
502
- const namePart = e.name ? ` ${truncateMiddle(e.name, 40)}` : "";
508
+ const displayName = e.phase === "finalize"
509
+ ? e.name
510
+ : e.name
511
+ ? truncateMiddle(e.name, 40)
512
+ : "";
513
+ const namePart = displayName ? ` ${displayName}` : "";
503
514
  const line = `[1tube package] ${tag} ${e.current}/${e.total}` +
504
515
  (pct ? ` (${pct})` : "") +
505
516
  ` ${sizeStr}${namePart}`;
@@ -675,7 +686,10 @@ export async function runPackage(args: string[]): Promise<number> {
675
686
  console.log(
676
687
  `[1tube package] envelope: ${result.envelope.functionCount} function(s), ` +
677
688
  `${fmt(result.envelope.totalBundleBytes)} bundle bytes, ` +
678
- `manifest sha256=${result.envelope.manifestSha256.slice(0, 12)}…`,
689
+ `manifest sha256=${result.envelope.manifestSha256.slice(0, 12)}…, ` +
690
+ `content sha256=${
691
+ result.envelope.contentSha256?.slice(0, 12) ?? "<none>"
692
+ }…`,
679
693
  );
680
694
  return 0;
681
695
  } catch (err) {
@@ -0,0 +1,192 @@
1
+ import { dirname, join, normalize } from "node:path";
2
+ import { init, parse } from "es-module-lexer";
3
+ import { canonicalJson } from "./cli/envelope.ts";
4
+ import type { PrebuiltManifest } from "./backends/workerd/prebuilt.ts";
5
+
6
+ async function sha256Hex(bytes: Uint8Array | string): Promise<string> {
7
+ const data = typeof bytes === "string"
8
+ ? new TextEncoder().encode(bytes)
9
+ : bytes;
10
+ const buf = await crypto.subtle.digest("SHA-256", data as BufferSource);
11
+ const view = new Uint8Array(buf);
12
+ let out = "";
13
+ for (let i = 0; i < view.length; i++) {
14
+ out += view[i].toString(16).padStart(2, "0");
15
+ }
16
+ return out;
17
+ }
18
+
19
+ function toPosix(path: string): string {
20
+ return path.replace(/\\/g, "/").replace(/^\/+/, "");
21
+ }
22
+
23
+ function uniqueSorted(values: Iterable<string>): string[] {
24
+ return [...new Set(values)].sort();
25
+ }
26
+
27
+ function sortPlain(value: unknown): unknown {
28
+ if (Array.isArray(value)) return value.map(sortPlain);
29
+ if (!value || typeof value !== "object") return value;
30
+ const obj = value as Record<string, unknown>;
31
+ const out: Record<string, unknown> = {};
32
+ for (const key of Object.keys(obj).sort()) {
33
+ out[key] = sortPlain(obj[key]);
34
+ }
35
+ return out;
36
+ }
37
+
38
+ function resolveModuleSpecifier(
39
+ fromFile: string,
40
+ specifier: string,
41
+ ): string | null {
42
+ if (!specifier.startsWith(".")) return null;
43
+ const resolved = normalize(join(dirname(fromFile), specifier));
44
+ return toPosix(resolved);
45
+ }
46
+
47
+ async function normalizeModule(
48
+ moduleFile: string,
49
+ source: string,
50
+ knownModules: ReadonlySet<string>,
51
+ ): Promise<{ source: string; dependencies: string[] }> {
52
+ const dependencies: string[] = [];
53
+ const withoutSourceMap = source.replace(
54
+ /\n?\/\/# sourceMappingURL=.*$/m,
55
+ "\n",
56
+ );
57
+ await init;
58
+ const [imports] = parse(withoutSourceMap);
59
+ const patches: Array<{ start: number; end: number; value: string }> = [];
60
+ for (const specifier of imports) {
61
+ const rawSpecifier = specifier.n ??
62
+ withoutSourceMap.slice(specifier.s, specifier.e);
63
+ if (!rawSpecifier.startsWith(".")) continue;
64
+ const target = resolveModuleSpecifier(moduleFile, rawSpecifier);
65
+ if (!target || !knownModules.has(target)) continue;
66
+
67
+ dependencies.push(target);
68
+ patches.push({
69
+ start: specifier.s,
70
+ end: specifier.e,
71
+ value: "__1tube_module_ref__",
72
+ });
73
+ }
74
+
75
+ let normalized = withoutSourceMap;
76
+ for (const patch of patches.sort((a, b) => b.start - a.start)) {
77
+ normalized = normalized.slice(0, patch.start) + patch.value +
78
+ normalized.slice(patch.end);
79
+ }
80
+
81
+ return { source: normalized, dependencies: uniqueSorted(dependencies) };
82
+ }
83
+
84
+ export interface FirmwareContentFingerprint {
85
+ sha256: string;
86
+ canonical: string;
87
+ }
88
+
89
+ export async function computeFirmwareContentFingerprint(
90
+ distDir: string,
91
+ manifest: PrebuiltManifest,
92
+ ): Promise<FirmwareContentFingerprint> {
93
+ const moduleFiles = uniqueSorted([
94
+ ...manifest.functions.flatMap((fn) =>
95
+ fn.moduleFiles?.length ? fn.moduleFiles : [fn.bundleFile]
96
+ ),
97
+ ...manifest.chunks.map((chunk) => chunk.file),
98
+ ...manifest.sharedModules.map((shared) => shared.bundleFile),
99
+ ]);
100
+
101
+ const rawSources = new Map<string, string>();
102
+ for (const file of moduleFiles) {
103
+ const bytes = await Deno.readFile(join(distDir, file));
104
+ const source = new TextDecoder().decode(bytes);
105
+ rawSources.set(file, source);
106
+ }
107
+
108
+ const knownModules = new Set(moduleFiles);
109
+ const normalizedModules = new Map(
110
+ await Promise.all(
111
+ moduleFiles.map(async (file) =>
112
+ [
113
+ file,
114
+ await normalizeModule(file, rawSources.get(file) ?? "", knownModules),
115
+ ] as const
116
+ ),
117
+ ),
118
+ );
119
+ const moduleHashes = new Map<string, string>();
120
+ async function hashModule(
121
+ file: string,
122
+ stack = new Set<string>(),
123
+ ): Promise<string> {
124
+ const cached = moduleHashes.get(file);
125
+ if (cached) return cached;
126
+
127
+ const normalized = normalizedModules.get(file);
128
+ if (!normalized) {
129
+ throw new Error(`firmware content fingerprint missing module ${file}`);
130
+ }
131
+ if (stack.has(file)) {
132
+ return await sha256Hex("__1tube_module_cycle__");
133
+ }
134
+
135
+ stack.add(file);
136
+ const dependencyHashes = [];
137
+ for (const dependency of normalized.dependencies) {
138
+ dependencyHashes.push(await hashModule(dependency, stack));
139
+ }
140
+ stack.delete(file);
141
+
142
+ const hash = await sha256Hex(canonicalJson({
143
+ source: normalized.source,
144
+ dependencies: uniqueSorted(dependencyHashes),
145
+ }));
146
+ moduleHashes.set(file, hash);
147
+ return hash;
148
+ }
149
+ for (const file of moduleFiles) {
150
+ await hashModule(file);
151
+ }
152
+
153
+ const functionEntries = manifest.functions
154
+ .map((fn) => {
155
+ const modules = fn.moduleFiles?.length ? fn.moduleFiles : [fn.bundleFile];
156
+ return {
157
+ name: fn.name,
158
+ manifest: sortPlain(fn.manifest),
159
+ modules: uniqueSorted(
160
+ modules.map((file) => moduleHashes.get(file) ?? ""),
161
+ ),
162
+ };
163
+ })
164
+ .sort((a, b) => a.name.localeCompare(b.name));
165
+
166
+ const sharedEntries = manifest.sharedModules
167
+ .map((shared) => ({
168
+ id: shared.id,
169
+ exportNames: uniqueSorted(shared.exportNames),
170
+ module: moduleHashes.get(shared.bundleFile) ?? "",
171
+ }))
172
+ .sort((a, b) => a.id.localeCompare(b.id));
173
+
174
+ const chunkEntries = uniqueSorted(
175
+ manifest.chunks.map((chunk) => moduleHashes.get(chunk.file) ?? ""),
176
+ );
177
+
178
+ const canonical = canonicalJson({
179
+ schema: manifest.schema,
180
+ compatibilityDate: manifest.compatibilityDate,
181
+ compatibilityFlags: uniqueSorted(manifest.compatibilityFlags ?? []),
182
+ envAllowlist: uniqueSorted(manifest.envAllowlist ?? []),
183
+ chunks: chunkEntries,
184
+ sharedModules: sharedEntries,
185
+ functions: functionEntries,
186
+ });
187
+
188
+ return {
189
+ sha256: await sha256Hex(canonical),
190
+ canonical,
191
+ };
192
+ }