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 +1 -0
- package/deno.lock +5 -0
- package/package.json +1 -1
- package/src/backends/workerd/backend.ts +51 -13
- package/src/backends/workerd/bundler.ts +6 -0
- package/src/backends/workerd/process.ts +70 -12
- package/src/cli/envelope.ts +14 -2
- package/src/cli/package.ts +16 -2
- package/src/firmware-content-hash.ts +192 -0
package/deno.json
CHANGED
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
|
@@ -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
|
|
1125
|
-
//
|
|
1126
|
-
//
|
|
1127
|
-
//
|
|
1128
|
-
|
|
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:
|
|
1141
|
+
resolved: configuredEnvBindings,
|
|
1131
1142
|
missing: missingEnv,
|
|
1132
|
-
mode:
|
|
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 (
|
|
1142
|
-
if (
|
|
1156
|
+
if (hasConfiguredEnvPolicy && configuredEnvBindings.length > 0) {
|
|
1157
|
+
if (configuredEnvMode === "all") {
|
|
1143
1158
|
console.log(
|
|
1144
|
-
`[1tube] forwarding all ${
|
|
1145
|
-
`
|
|
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 ${
|
|
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(
|
|
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,
|
|
425
|
-
pumpLines(child.stderr,
|
|
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
|
-
|
|
461
|
-
|
|
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
|
}
|
package/src/cli/envelope.ts
CHANGED
|
@@ -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(
|
|
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 (
|
|
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: {
|
package/src/cli/package.ts
CHANGED
|
@@ -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
|
|
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
|
+
}
|