1tube 0.1.15 → 0.1.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "1tube",
3
- "version": "0.1.15",
3
+ "version": "0.1.16",
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",
@@ -31,7 +31,7 @@
31
31
  * braces guarantee.
32
32
  */
33
33
 
34
- import { isAbsolute, join, resolve as resolvePath } from "node:path";
34
+ import { isAbsolute, join, relative, resolve as resolvePath } from "node:path";
35
35
  import { ensureDir } from "jsr:@std/fs@^1/ensure-dir";
36
36
  import { type AuthContext } from "../../registry.ts";
37
37
  import { type FunctionManifest, loadManifest } from "../../manifest.ts";
@@ -47,6 +47,14 @@ import {
47
47
  parsePrebuiltManifest,
48
48
  type PrebuiltManifest,
49
49
  } from "./prebuilt.ts";
50
+ import {
51
+ copyPrebuiltRuntimeFiles,
52
+ defaultRuntimeOverrideRoot,
53
+ RuntimeOverrideStore,
54
+ type EditableSourceResponse,
55
+ type EditableFunctionSummary,
56
+ type SaveEditableSourceInput,
57
+ } from "./runtime-overrides.ts";
50
58
  import { type CapnpRoute, generateCapnp } from "./capnp.ts";
51
59
  import {
52
60
  SHARED_RUNTIME_TOKEN_ENV,
@@ -76,6 +84,8 @@ export interface WorkerdBackendOptions {
76
84
  * `node_modules/` exists, else `.1tube-cache/workerd/`.
77
85
  */
78
86
  cacheDir?: string;
87
+ /** Writable runtime source/bundle overlay used by the admin edge editor. */
88
+ runtimeOverrideDir?: string;
79
89
  /** Loopback address for workerd sockets. Defaults to `"127.0.0.1"`. */
80
90
  bindAddress?: string;
81
91
  /** First port to assign to a function. Defaults to `8800`. */
@@ -84,6 +94,8 @@ export interface WorkerdBackendOptions {
84
94
  compatibilityDate?: string;
85
95
  /** Compatibility flags applied to every service. */
86
96
  compatibilityFlags?: readonly string[];
97
+ /** Pass `--experimental` to the workerd process. */
98
+ experimental?: boolean;
87
99
  /** Sourcemap mode passed to the bundler. */
88
100
  sourcemap?: boolean | "linked" | "inline";
89
101
  /** Sink for workerd's [workerd]-prefixed log lines. Defaults to stderr. */
@@ -135,8 +147,8 @@ export interface WorkerdBackendOptions {
135
147
  *
136
148
  * In this mode `functionsDir` is ignored, no bundler is created (so
137
149
  * the operator does not need esbuild's worker subprocess on the box),
138
- * and `reload()` is unsupported — the artifact is sealed. HMR must
139
- * not be enabled by the gateway when this is set.
150
+ * and runtime reloads compose the sealed artifact with admin
151
+ * overlays. HMR must not be enabled by the gateway when this is set.
140
152
  *
141
153
  * Bundle integrity is verified against the manifest's recorded
142
154
  * sha-256 at boot, so a tampered bundle fails fast with a clear
@@ -329,6 +341,12 @@ export interface WorkerdBackend {
329
341
  * exactly what the e2e tests assert on.
330
342
  */
331
343
  readonly lastReloadDurationMs: number | null;
344
+ listEditableFunctions(): Promise<EditableFunctionSummary[]>;
345
+ readEditableSource(name: string): Promise<EditableSourceResponse>;
346
+ saveEditableSource(name: string, input: SaveEditableSourceInput): Promise<WorkerdReloadResult>;
347
+ createEditableFunction(name: string): Promise<EditableSourceResponse>;
348
+ deleteEditableFunction(name: string): Promise<WorkerdReloadResult>;
349
+ revertEditableFunction(name: string): Promise<WorkerdReloadResult>;
332
350
  /** Tear down workerd and dispose esbuild's worker. Idempotent. */
333
351
  stop(): Promise<void>;
334
352
  }
@@ -824,6 +842,7 @@ export function createWorkerdBackend(opts: WorkerdBackendOptions): WorkerdBacken
824
842
  // Mutable state set during start(). Kept in closure scope so dispatch()
825
843
  // can reach it without an extra indirection layer.
826
844
  let bundler: Bundler | null = null;
845
+ let overlayBundler: Bundler | null = null;
827
846
  let process: WorkerdProcess | null = null;
828
847
  let routesByName = new Map<string, CapnpRoute>();
829
848
  let workerdVersion: string | null = null;
@@ -867,8 +886,19 @@ export function createWorkerdBackend(opts: WorkerdBackendOptions): WorkerdBacken
867
886
  ? opts.prebuiltDir
868
887
  : resolvePath(cwd, opts.prebuiltDir))
869
888
  : null;
889
+ const runtimeEditorEnabled = prebuiltDir !== null;
870
890
  let prebuiltManifest: PrebuiltManifest | null = null;
871
891
  let sharedRuntime: WorkerdSharedRuntime | null = null;
892
+ const runtimeOverrides = new RuntimeOverrideStore({
893
+ rootDir: opts.runtimeOverrideDir
894
+ ? (isAbsolute(opts.runtimeOverrideDir) ? opts.runtimeOverrideDir : resolvePath(cwd, opts.runtimeOverrideDir))
895
+ : defaultRuntimeOverrideRoot(cwd),
896
+ sourceFunctionsDir: resolvedFunctionsDir,
897
+ prebuiltDir,
898
+ getPrebuiltManifest: () => prebuiltManifest,
899
+ getLiveManifests: () => manifests,
900
+ getBundleBytes: () => bundleBytes,
901
+ });
872
902
 
873
903
  /**
874
904
  * Shared boot pipeline used by both `start()` and `reload()`.
@@ -916,7 +946,11 @@ export function createWorkerdBackend(opts: WorkerdBackendOptions): WorkerdBacken
916
946
  // while live mode allocates the usual node_modules/.cache slot.
917
947
  if (cacheDir === null) {
918
948
  if (prebuiltDir !== null) {
919
- cacheDir = prebuiltDir;
949
+ cacheDir = opts.cacheDir
950
+ ? (isAbsolute(opts.cacheDir) ? opts.cacheDir : resolvePath(cwd, opts.cacheDir))
951
+ : join(await defaultCacheDir(cwd), "prebuilt-runtime");
952
+ await ensureDir(cacheDir);
953
+ await writeGitignore(cacheDir);
920
954
  } else {
921
955
  cacheDir = opts.cacheDir
922
956
  ? (isAbsolute(opts.cacheDir) ? opts.cacheDir : resolvePath(cwd, opts.cacheDir))
@@ -933,6 +967,7 @@ export function createWorkerdBackend(opts: WorkerdBackendOptions): WorkerdBacken
933
967
  // to the caller for the WorkerdReloadResult; prebuilt mode never
934
968
  // rebuilds anything so this stays empty there.
935
969
  let rebundledNames: string[] = [];
970
+ const overlayManifests = new Map<string, FunctionManifest>();
936
971
  if (prebuiltDir !== null) {
937
972
  // Prebuilt mode — every "bundle" already exists on disk. We
938
973
  // synthesise BundleResults from the manifest so the rest of
@@ -945,7 +980,14 @@ export function createWorkerdBackend(opts: WorkerdBackendOptions): WorkerdBacken
945
980
  // with a null manifest. Raising hard makes the bug obvious.
946
981
  throw new Error("prebuilt manifest not loaded");
947
982
  }
948
- let entries = prebuiltManifest.functions;
983
+ await copyPrebuiltRuntimeFiles(prebuiltManifest, prebuiltDir, cacheDir!);
984
+ const baseNames = new Set(prebuiltManifest.functions.map((e) => e.name));
985
+ const overlays = runtimeEditorEnabled
986
+ ? await runtimeOverrides.compose(baseNames)
987
+ : [];
988
+ for (const overlay of overlays) overlayManifests.set(overlay.name, overlay.manifest);
989
+ const patchedNames = new Set(overlays.filter((e) => e.origin === "patched").map((e) => e.name));
990
+ let entries = prebuiltManifest.functions.filter((e) => !patchedNames.has(e.name));
949
991
  if (opts.only && opts.only.length > 0) {
950
992
  const allow = new Set(opts.only);
951
993
  entries = entries.filter((e) => allow.has(e.name));
@@ -967,6 +1009,22 @@ export function createWorkerdBackend(opts: WorkerdBackendOptions): WorkerdBacken
967
1009
  byteLength: e.bundleBytes,
968
1010
  durationMs: 0,
969
1011
  }));
1012
+ if (overlays.length > 0) {
1013
+ if (!overlayBundler) {
1014
+ overlayBundler = createBundler({
1015
+ outDir: join(cacheDir!, "runtime-overrides"),
1016
+ configPath,
1017
+ sourcemap: "linked",
1018
+ minify: true,
1019
+ });
1020
+ }
1021
+ const overlayResults = await overlayBundler.bundleAll(
1022
+ overlays.map((entry) => ({ name: entry.name, entrypoint: entry.entrypoint })),
1023
+ { concurrency: opts.bundleConcurrency ?? 4 },
1024
+ );
1025
+ bundleResults.push(...overlayResults);
1026
+ rebundledNames = overlayResults.map((r) => r.name);
1027
+ }
970
1028
  for (const shared of prebuiltManifest.sharedModules) {
971
1029
  sharedModules.push({
972
1030
  id: shared.id,
@@ -985,6 +1043,18 @@ export function createWorkerdBackend(opts: WorkerdBackendOptions): WorkerdBacken
985
1043
  );
986
1044
  }
987
1045
  }
1046
+ const baseNames = new Set(inputs.map((i) => i.name));
1047
+ const overlays = runtimeEditorEnabled
1048
+ ? await runtimeOverrides.compose(baseNames)
1049
+ : [];
1050
+ for (const overlay of overlays) overlayManifests.set(overlay.name, overlay.manifest);
1051
+ if (overlays.length > 0) {
1052
+ const overlayNames = new Set(overlays.map((entry) => entry.name));
1053
+ inputs = [
1054
+ ...inputs.filter((input) => !overlayNames.has(input.name)),
1055
+ ...overlays.map((entry) => ({ name: entry.name, entrypoint: entry.entrypoint })),
1056
+ ].sort((a, b) => a.name.localeCompare(b.name));
1057
+ }
988
1058
  if (inputs.length === 0) {
989
1059
  // Empty functions dir is a legitimate state — typically a
990
1060
  // fresh deployment that hasn't received its first firmware
@@ -1183,16 +1253,19 @@ export function createWorkerdBackend(opts: WorkerdBackendOptions): WorkerdBacken
1183
1253
  const newManifests = new Map<string, FunctionManifest>();
1184
1254
  if (prebuiltDir !== null && prebuiltManifest) {
1185
1255
  for (const e of prebuiltManifest.functions) {
1186
- newManifests.set(e.name, e.manifest);
1256
+ if (!overlayManifests.has(e.name)) newManifests.set(e.name, e.manifest);
1187
1257
  }
1188
1258
  } else {
1189
1259
  await Promise.all(
1190
1260
  bundleResults.map(async (r) => {
1191
- const m = await loadManifest(resolvedFunctionsDir, r.name);
1261
+ const m = overlayManifests.get(r.name) ?? await loadManifest(resolvedFunctionsDir, r.name);
1192
1262
  newManifests.set(r.name, m);
1193
1263
  }),
1194
1264
  );
1195
1265
  }
1266
+ for (const [name, manifest] of overlayManifests) {
1267
+ newManifests.set(name, manifest);
1268
+ }
1196
1269
 
1197
1270
  // Mirror the Deno backend's two-mode env story:
1198
1271
  // - Default: every function sees the full gateway allowlist.
@@ -1218,8 +1291,10 @@ export function createWorkerdBackend(opts: WorkerdBackendOptions): WorkerdBacken
1218
1291
  : [];
1219
1292
  const capnpInputs = bundleResults.map((r) => {
1220
1293
  const m = newManifests.get(r.name)!;
1221
- const bundleEmbedPath = prebuiltBundleFiles?.get(r.name) ??
1222
- r.bundlePath.split(/[\\/]/).pop()!;
1294
+ const bundleEmbedPath = overlayManifests.has(r.name)
1295
+ ? relative(cacheDir!, r.bundlePath).replaceAll("\\", "/")
1296
+ : prebuiltBundleFiles?.get(r.name) ??
1297
+ relative(cacheDir!, r.bundlePath).replaceAll("\\", "/");
1223
1298
  const fnEnvBindings = intersectEnvForFunction(gatewayEnvBindings, m.permissions.env, enforceManifest);
1224
1299
  for (const internalName of internalEnvBindings) {
1225
1300
  if (!fnEnvBindings.includes(internalName)) fnEnvBindings.push(internalName);
@@ -1231,7 +1306,7 @@ export function createWorkerdBackend(opts: WorkerdBackendOptions): WorkerdBacken
1231
1306
  // and capnp is written at dist/, so use the manifest-relative
1232
1307
  // path ("functions/<fn>.js") or workerd cannot resolve embeds.
1233
1308
  bundleBasename: bundleEmbedPath,
1234
- moduleFiles: prebuiltModuleFiles?.get(r.name),
1309
+ moduleFiles: overlayManifests.has(r.name) ? undefined : prebuiltModuleFiles?.get(r.name),
1235
1310
  envBindings: fnEnvBindings,
1236
1311
  };
1237
1312
  });
@@ -1378,12 +1453,16 @@ export function createWorkerdBackend(opts: WorkerdBackendOptions): WorkerdBacken
1378
1453
  if (verboseWorkerd) {
1379
1454
  console.log(`[1tube] workerd verbose logging enabled (1TUBE_WORKERD_VERBOSE=1)`);
1380
1455
  }
1456
+ const globalArgs = [
1457
+ ...(verboseWorkerd ? ["-v"] : []),
1458
+ ...(opts.experimental ? ["--experimental"] : []),
1459
+ ];
1381
1460
 
1382
1461
  const newProcess = createWorkerdProcess({
1383
1462
  binary: workerdBin,
1384
1463
  capnpPath,
1385
1464
  routes: capnp.routes,
1386
- ...(verboseWorkerd ? { globalArgs: ["-v"] } : {}),
1465
+ ...(globalArgs.length > 0 ? { globalArgs } : {}),
1387
1466
  extraArgs,
1388
1467
  env: sharedRuntime
1389
1468
  ? {
@@ -1598,6 +1677,15 @@ export function createWorkerdBackend(opts: WorkerdBackendOptions): WorkerdBacken
1598
1677
  return reloadInFlight;
1599
1678
  }
1600
1679
 
1680
+ function requireRuntimeEditor(): void {
1681
+ if (!runtimeEditorEnabled) {
1682
+ throw new Error(
1683
+ "edge function runtime editor is disabled outside prebuilt workerd mode; " +
1684
+ "use HMR/source files for local development",
1685
+ );
1686
+ }
1687
+ }
1688
+
1601
1689
  return {
1602
1690
  get pid() {
1603
1691
  return process?.pid ?? null;
@@ -1624,6 +1712,7 @@ export function createWorkerdBackend(opts: WorkerdBackendOptions): WorkerdBacken
1624
1712
  async start() {
1625
1713
  if (started) throw new Error("workerd backend already started");
1626
1714
  started = true;
1715
+ if (runtimeEditorEnabled) await runtimeOverrides.load();
1627
1716
 
1628
1717
  // Load + verify the prebuilt manifest BEFORE probing workerd, so
1629
1718
  // a stale / tampered artifact fails with a clear error before we
@@ -1783,16 +1872,14 @@ export function createWorkerdBackend(opts: WorkerdBackendOptions): WorkerdBacken
1783
1872
  },
1784
1873
 
1785
1874
  reload(changed) {
1786
- // Prebuilt artifacts are sealed — block every public reload
1787
- // request. Crash-recovery still works because the internal
1788
- // crash handler calls `doReload` directly (re-spawning workerd
1789
- // against the same on-disk bundles is legitimate; only
1790
- // re-bundling is forbidden).
1875
+ // Keep the public API contract: prebuilt firmware is immutable
1876
+ // from HMR/test callers. Runtime editor admin methods below use
1877
+ // doReload() directly after mutating the overlay store.
1791
1878
  if (prebuiltDir !== null) {
1792
1879
  return Promise.reject(
1793
1880
  new Error(
1794
- "workerd backend is in prebuilt mode; reload is not supported. " +
1795
- "Re-run `1tube build` and restart the gateway to pick up changes.",
1881
+ "workerd backend is in prebuilt mode; public reload is not supported. " +
1882
+ "Use the runtime editor APIs for overlay hot-swaps, or promote new firmware.",
1796
1883
  ),
1797
1884
  );
1798
1885
  }
@@ -1840,6 +1927,44 @@ export function createWorkerdBackend(opts: WorkerdBackendOptions): WorkerdBacken
1840
1927
  }
1841
1928
  },
1842
1929
 
1930
+ async listEditableFunctions() {
1931
+ requireRuntimeEditor();
1932
+ return await runtimeOverrides.list(new Set(names));
1933
+ },
1934
+
1935
+ async readEditableSource(name) {
1936
+ requireRuntimeEditor();
1937
+ return await runtimeOverrides.read(name, new Set(names));
1938
+ },
1939
+
1940
+ async createEditableFunction(name) {
1941
+ requireRuntimeEditor();
1942
+ if (names.includes(name.trim())) {
1943
+ throw new Error(`function "${name}" exists in firmware; open Code and save a patch instead`);
1944
+ }
1945
+ const source = await runtimeOverrides.create(name);
1946
+ await doReload(new Set([name]));
1947
+ return source;
1948
+ },
1949
+
1950
+ async saveEditableSource(name, input) {
1951
+ requireRuntimeEditor();
1952
+ await runtimeOverrides.save(name, input, new Set(names));
1953
+ return await doReload(new Set([name]));
1954
+ },
1955
+
1956
+ async deleteEditableFunction(name) {
1957
+ requireRuntimeEditor();
1958
+ await runtimeOverrides.deleteAdded(name);
1959
+ return await doReload("all");
1960
+ },
1961
+
1962
+ async revertEditableFunction(name) {
1963
+ requireRuntimeEditor();
1964
+ await runtimeOverrides.revert(name);
1965
+ return await doReload(new Set([name]));
1966
+ },
1967
+
1843
1968
  async stop() {
1844
1969
  if (stopping) return;
1845
1970
  stopping = true;
@@ -1852,8 +1977,12 @@ export function createWorkerdBackend(opts: WorkerdBackendOptions): WorkerdBacken
1852
1977
  if (bundler) {
1853
1978
  await bundler.dispose().catch(() => {});
1854
1979
  }
1980
+ if (overlayBundler && overlayBundler !== bundler) {
1981
+ await overlayBundler.dispose().catch(() => {});
1982
+ }
1855
1983
  sharedRuntime = null;
1856
1984
  bundler = null;
1985
+ overlayBundler = null;
1857
1986
  process = null;
1858
1987
  manifests.clear();
1859
1988
  }
@@ -39,6 +39,8 @@ export interface PrebuiltFunctionEntry {
39
39
  moduleFiles: string[];
40
40
  /** Per-function `1tube.json` parsed at build time. */
41
41
  manifest: FunctionManifest;
42
+ /** Optional inspectable authored source files under `sources/<name>/`. */
43
+ sourceFiles?: string[];
42
44
  }
43
45
 
44
46
  export interface PrebuiltChunkEntry {
@@ -128,6 +130,9 @@ export function parsePrebuiltManifest(raw: unknown): PrebuiltManifest {
128
130
  bundleSha256,
129
131
  moduleFiles: moduleFiles.length > 0 ? moduleFiles : [bundleFile],
130
132
  manifest: parseManifest(e.manifest, /* fromFile */ true),
133
+ sourceFiles: Array.isArray(e.sourceFiles)
134
+ ? e.sourceFiles.filter((n): n is string => typeof n === "string")
135
+ : undefined,
131
136
  };
132
137
  });
133
138
  const chunks: PrebuiltChunkEntry[] = Array.isArray(obj.chunks)
@@ -0,0 +1,600 @@
1
+ import { basename, dirname, join, normalize, relative, resolve as resolvePath } from "node:path";
2
+ import { ensureDir } from "jsr:@std/fs@^1/ensure-dir";
3
+ import {
4
+ defaultManifest,
5
+ MANIFEST_FILENAME,
6
+ parseManifest,
7
+ type FunctionManifest,
8
+ } from "../../manifest.ts";
9
+ import type { PrebuiltManifest } from "./prebuilt.ts";
10
+
11
+ const NAME_RX = /^[A-Za-z][A-Za-z0-9_-]*$/;
12
+ const OVERLAY_SCHEMA = 1;
13
+
14
+ export type EditableFunctionOrigin = "manifest" | "patched" | "added";
15
+
16
+ export interface RuntimeOverrideEntry {
17
+ name: string;
18
+ origin: "patched" | "added";
19
+ updatedAt: string;
20
+ files: string[];
21
+ }
22
+
23
+ interface RuntimeOverrideIndex {
24
+ schema: number;
25
+ functions: RuntimeOverrideEntry[];
26
+ }
27
+
28
+ export interface EditableFunctionSummary {
29
+ name: string;
30
+ origin: EditableFunctionOrigin;
31
+ files: string[];
32
+ manifest: FunctionManifest;
33
+ bundleBytes: number | null;
34
+ source: "overlay" | "snapshot" | "sourcemap" | "bundle" | "source-tree" | "missing";
35
+ degraded: boolean;
36
+ }
37
+
38
+ export interface EditableSourceFile {
39
+ path: string;
40
+ content: string;
41
+ language: "typescript" | "javascript" | "json" | "text";
42
+ }
43
+
44
+ export interface EditableSourceResponse {
45
+ name: string;
46
+ origin: EditableFunctionOrigin;
47
+ files: EditableSourceFile[];
48
+ selected: string;
49
+ degraded: boolean;
50
+ warning?: string;
51
+ }
52
+
53
+ export interface SaveEditableSourceInput {
54
+ files: Record<string, string>;
55
+ }
56
+
57
+ export interface ComposeOverlayInput {
58
+ baseNames: ReadonlySet<string>;
59
+ }
60
+
61
+ export interface OverlayComposeEntry {
62
+ name: string;
63
+ origin: "patched" | "added";
64
+ sourceDir: string;
65
+ entrypoint: string;
66
+ manifest: FunctionManifest;
67
+ files: string[];
68
+ }
69
+
70
+ export interface RuntimeOverrideStoreOptions {
71
+ rootDir: string;
72
+ sourceFunctionsDir: string;
73
+ prebuiltDir?: string | null;
74
+ getPrebuiltManifest?: () => PrebuiltManifest | null;
75
+ getLiveManifests?: () => ReadonlyMap<string, FunctionManifest>;
76
+ getBundleBytes?: () => ReadonlyMap<string, number>;
77
+ }
78
+
79
+ export function defaultRuntimeOverrideRoot(cwd: string): string {
80
+ const env = Deno.env.get("1TUBE_RUNTIME_FUNCTIONS_DIR")?.trim();
81
+ return env ? (isAbsolutePath(env) ? env : resolvePath(cwd, env)) : resolvePath(cwd, ".1tube-cache", "runtime-functions");
82
+ }
83
+
84
+ function isAbsolutePath(path: string): boolean {
85
+ return /^[A-Za-z]:[\\/]/.test(path) || path.startsWith("/") || path.startsWith("\\\\");
86
+ }
87
+
88
+ function safeName(name: string): string {
89
+ const trimmed = name.trim();
90
+ if (!NAME_RX.test(trimmed)) {
91
+ throw new Error(`invalid function name ${JSON.stringify(name)}; expected ${NAME_RX.source}`);
92
+ }
93
+ return trimmed;
94
+ }
95
+
96
+ function safeRelPath(path: string): string {
97
+ const p = path.replaceAll("\\", "/").trim();
98
+ if (!p || p.startsWith("/") || p.startsWith("./") || p.includes("\0")) {
99
+ throw new Error(`invalid source path ${JSON.stringify(path)}`);
100
+ }
101
+ const parts = p.split("/");
102
+ if (parts.some((part) => !part || part === "." || part === "..")) {
103
+ throw new Error(`invalid source path ${JSON.stringify(path)}`);
104
+ }
105
+ return parts.join("/");
106
+ }
107
+
108
+ function langFor(path: string): EditableSourceFile["language"] {
109
+ const lower = path.toLowerCase();
110
+ if (lower.endsWith(".json")) return "json";
111
+ if (lower.endsWith(".ts") || lower.endsWith(".tsx")) return "typescript";
112
+ if (lower.endsWith(".js") || lower.endsWith(".mjs") || lower.endsWith(".jsx")) return "javascript";
113
+ return "text";
114
+ }
115
+
116
+ async function pathExists(path: string): Promise<boolean> {
117
+ try {
118
+ await Deno.stat(path);
119
+ return true;
120
+ } catch (err) {
121
+ if (err instanceof Deno.errors.NotFound) return false;
122
+ throw err;
123
+ }
124
+ }
125
+
126
+ async function readTextIfExists(path: string): Promise<string | null> {
127
+ try {
128
+ return await Deno.readTextFile(path);
129
+ } catch (err) {
130
+ if (err instanceof Deno.errors.NotFound) return null;
131
+ throw err;
132
+ }
133
+ }
134
+
135
+ async function removeDirIfExists(path: string): Promise<void> {
136
+ try {
137
+ await Deno.remove(path, { recursive: true });
138
+ } catch (err) {
139
+ if (!(err instanceof Deno.errors.NotFound)) throw err;
140
+ }
141
+ }
142
+
143
+ async function listFiles(root: string): Promise<string[]> {
144
+ const out: string[] = [];
145
+ async function walk(dir: string) {
146
+ for await (const entry of Deno.readDir(dir)) {
147
+ if (entry.name === ".DS_Store") continue;
148
+ const abs = join(dir, entry.name);
149
+ if (entry.isDirectory) {
150
+ await walk(abs);
151
+ } else if (entry.isFile) {
152
+ out.push(relative(root, abs).replaceAll("\\", "/"));
153
+ }
154
+ }
155
+ }
156
+ try {
157
+ await walk(root);
158
+ } catch (err) {
159
+ if (!(err instanceof Deno.errors.NotFound)) throw err;
160
+ }
161
+ return out.sort((a, b) => a.localeCompare(b));
162
+ }
163
+
164
+ async function hashSha256Hex(text: string): Promise<string> {
165
+ const bytes = new TextEncoder().encode(text);
166
+ const digest = await crypto.subtle.digest("SHA-256", bytes as BufferSource);
167
+ return Array.from(new Uint8Array(digest)).map((b) => b.toString(16).padStart(2, "0")).join("");
168
+ }
169
+
170
+ function parseManifestText(text: string | null): FunctionManifest {
171
+ if (text === null) return defaultManifest();
172
+ try {
173
+ return parseManifest(JSON.parse(text), true);
174
+ } catch {
175
+ return defaultManifest();
176
+ }
177
+ }
178
+
179
+ export class RuntimeOverrideStore {
180
+ readonly rootDir: string;
181
+ private readonly functionsRoot: string;
182
+ private readonly indexPath: string;
183
+ private index: RuntimeOverrideIndex = { schema: OVERLAY_SCHEMA, functions: [] };
184
+ private loaded = false;
185
+
186
+ constructor(private readonly opts: RuntimeOverrideStoreOptions) {
187
+ this.rootDir = opts.rootDir;
188
+ this.functionsRoot = join(opts.rootDir, "functions");
189
+ this.indexPath = join(opts.rootDir, "overrides.json");
190
+ }
191
+
192
+ async load(): Promise<void> {
193
+ if (this.loaded) return;
194
+ await ensureDir(this.functionsRoot);
195
+ const raw = await readTextIfExists(this.indexPath);
196
+ if (raw) {
197
+ try {
198
+ const parsed = JSON.parse(raw) as RuntimeOverrideIndex;
199
+ if (parsed.schema === OVERLAY_SCHEMA && Array.isArray(parsed.functions)) {
200
+ this.index = {
201
+ schema: OVERLAY_SCHEMA,
202
+ functions: parsed.functions
203
+ .filter((f) => NAME_RX.test(f.name) && (f.origin === "added" || f.origin === "patched"))
204
+ .map((f) => ({
205
+ name: f.name,
206
+ origin: f.origin,
207
+ updatedAt: typeof f.updatedAt === "string" ? f.updatedAt : new Date(0).toISOString(),
208
+ files: Array.isArray(f.files) ? f.files.map(safeRelPath).sort() : [],
209
+ }))
210
+ .sort((a, b) => a.name.localeCompare(b.name)),
211
+ };
212
+ }
213
+ } catch (err) {
214
+ console.warn(`[1tube] ignoring invalid runtime override index ${this.indexPath}: ${err}`);
215
+ }
216
+ }
217
+ this.loaded = true;
218
+ await this.persist();
219
+ }
220
+
221
+ async list(baseNames: ReadonlySet<string>): Promise<EditableFunctionSummary[]> {
222
+ await this.load();
223
+ const names = new Set<string>(baseNames);
224
+ for (const e of this.index.functions) names.add(e.name);
225
+ const bundleBytes = this.opts.getBundleBytes?.() ?? new Map();
226
+ const rows: EditableFunctionSummary[] = [];
227
+ for (const name of [...names].sort((a, b) => a.localeCompare(b))) {
228
+ const overlay = this.index.functions.find((e) => e.name === name);
229
+ const origin: EditableFunctionOrigin = overlay?.origin ?? "manifest";
230
+ const source = overlay ? "overlay" : await this.detectBaseSource(name);
231
+ rows.push({
232
+ name,
233
+ origin,
234
+ files: overlay?.files.length ? overlay.files : await this.baseFileList(name),
235
+ manifest: overlay ? await this.readOverlayManifest(name) : this.baseManifest(name),
236
+ bundleBytes: bundleBytes.get(name) ?? null,
237
+ source,
238
+ degraded: source === "bundle" || source === "missing",
239
+ });
240
+ }
241
+ return rows;
242
+ }
243
+
244
+ async read(name: string, baseNames: ReadonlySet<string>): Promise<EditableSourceResponse> {
245
+ await this.load();
246
+ name = safeName(name);
247
+ const overlay = this.index.functions.find((e) => e.name === name);
248
+ if (overlay) {
249
+ const files = await this.readOverlayFiles(name, overlay.files);
250
+ return {
251
+ name,
252
+ origin: overlay.origin,
253
+ files,
254
+ selected: files.find((f) => f.path === "index.ts")?.path ?? files[0]?.path ?? "index.ts",
255
+ degraded: false,
256
+ };
257
+ }
258
+ if (!baseNames.has(name)) throw new Error(`function "${name}" does not exist`);
259
+ return await this.readBaseSource(name);
260
+ }
261
+
262
+ async create(name: string): Promise<EditableSourceResponse> {
263
+ await this.load();
264
+ name = safeName(name);
265
+ if (this.index.functions.some((e) => e.name === name)) {
266
+ throw new Error(`function "${name}" already has a runtime override`);
267
+ }
268
+ const baseNames = new Set(this.opts.getPrebuiltManifest?.()?.functions.map((f) => f.name) ?? []);
269
+ if (baseNames.has(name)) {
270
+ throw new Error(`function "${name}" exists in firmware; open Code and save a patch instead`);
271
+ }
272
+ await this.writeFiles(name, {
273
+ "index.ts": [
274
+ "export default async function handler(req: Request): Promise<Response> {",
275
+ " const body = req.method === \"GET\" ? null : await req.text();",
276
+ " return Response.json({ ok: true, method: req.method, body });",
277
+ "}",
278
+ "",
279
+ ].join("\n"),
280
+ [MANIFEST_FILENAME]: JSON.stringify(defaultManifestForFile(), null, 2) + "\n",
281
+ });
282
+ await this.upsertIndex({ name, origin: "added", updatedAt: new Date().toISOString(), files: await listFiles(this.fnDir(name)) });
283
+ return await this.read(name, new Set());
284
+ }
285
+
286
+ async save(name: string, input: SaveEditableSourceInput, baseNames: ReadonlySet<string>): Promise<RuntimeOverrideEntry> {
287
+ await this.load();
288
+ name = safeName(name);
289
+ const existsInBase = baseNames.has(name);
290
+ const current = this.index.functions.find((e) => e.name === name);
291
+ if (!existsInBase && current?.origin !== "added") {
292
+ throw new Error(`function "${name}" is not in firmware; create it first`);
293
+ }
294
+ const files: Record<string, string> = {};
295
+ for (const [rawPath, content] of Object.entries(input.files ?? {})) {
296
+ files[safeRelPath(rawPath)] = String(content ?? "");
297
+ }
298
+ if (!files["index.ts"] && !files["index.js"]) {
299
+ throw new Error(`runtime function "${name}" must include index.ts or index.js`);
300
+ }
301
+ if (!files[MANIFEST_FILENAME]) {
302
+ files[MANIFEST_FILENAME] = JSON.stringify(defaultManifestForFile(), null, 2) + "\n";
303
+ }
304
+ await this.writeFiles(name, files);
305
+ const entry: RuntimeOverrideEntry = {
306
+ name,
307
+ origin: existsInBase ? "patched" : "added",
308
+ updatedAt: new Date().toISOString(),
309
+ files: await listFiles(this.fnDir(name)),
310
+ };
311
+ await this.upsertIndex(entry);
312
+ return entry;
313
+ }
314
+
315
+ async deleteAdded(name: string): Promise<void> {
316
+ await this.load();
317
+ name = safeName(name);
318
+ const entry = this.index.functions.find((e) => e.name === name);
319
+ if (!entry) return;
320
+ if (entry.origin !== "added") {
321
+ throw new Error(`function "${name}" is from firmware; use revert instead`);
322
+ }
323
+ await removeDirIfExists(this.fnDir(name));
324
+ this.index.functions = this.index.functions.filter((e) => e.name !== name);
325
+ await this.persist();
326
+ }
327
+
328
+ async revert(name: string): Promise<void> {
329
+ await this.load();
330
+ name = safeName(name);
331
+ const entry = this.index.functions.find((e) => e.name === name);
332
+ if (!entry) return;
333
+ if (entry.origin !== "patched") {
334
+ throw new Error(`function "${name}" is runtime-added; delete it instead`);
335
+ }
336
+ await removeDirIfExists(this.fnDir(name));
337
+ this.index.functions = this.index.functions.filter((e) => e.name !== name);
338
+ await this.persist();
339
+ }
340
+
341
+ async compose(baseNames: ReadonlySet<string>): Promise<OverlayComposeEntry[]> {
342
+ await this.load();
343
+ const out: OverlayComposeEntry[] = [];
344
+ for (const entry of this.index.functions) {
345
+ if (entry.origin === "patched" && !baseNames.has(entry.name)) {
346
+ entry.origin = "added";
347
+ }
348
+ const dir = this.fnDir(entry.name);
349
+ const entrypoint = await this.resolveEntrypoint(dir);
350
+ out.push({
351
+ name: entry.name,
352
+ origin: entry.origin,
353
+ sourceDir: dir,
354
+ entrypoint,
355
+ manifest: await this.readOverlayManifest(entry.name),
356
+ files: await listFiles(dir),
357
+ });
358
+ }
359
+ await this.persist();
360
+ return out.sort((a, b) => a.name.localeCompare(b.name));
361
+ }
362
+
363
+ private async persist(): Promise<void> {
364
+ await ensureDir(this.rootDir);
365
+ this.index.functions.sort((a, b) => a.name.localeCompare(b.name));
366
+ await Deno.writeTextFile(this.indexPath, JSON.stringify(this.index, null, 2) + "\n");
367
+ }
368
+
369
+ private async upsertIndex(entry: RuntimeOverrideEntry): Promise<void> {
370
+ this.index.functions = this.index.functions.filter((e) => e.name !== entry.name);
371
+ this.index.functions.push(entry);
372
+ await this.persist();
373
+ }
374
+
375
+ private fnDir(name: string): string {
376
+ return join(this.functionsRoot, safeName(name));
377
+ }
378
+
379
+ private async writeFiles(name: string, files: Record<string, string>): Promise<void> {
380
+ const dir = this.fnDir(name);
381
+ await removeDirIfExists(dir);
382
+ await ensureDir(dir);
383
+ for (const [path, content] of Object.entries(files)) {
384
+ const rel = safeRelPath(path);
385
+ const abs = normalize(join(dir, rel));
386
+ if (!abs.startsWith(dir)) throw new Error(`source path escapes function dir: ${rel}`);
387
+ await ensureDir(dirname(abs));
388
+ await Deno.writeTextFile(abs, content);
389
+ }
390
+ }
391
+
392
+ private async readOverlayManifest(name: string): Promise<FunctionManifest> {
393
+ return parseManifestText(await readTextIfExists(join(this.fnDir(name), MANIFEST_FILENAME)));
394
+ }
395
+
396
+ private async readOverlayFiles(name: string, paths: string[]): Promise<EditableSourceFile[]> {
397
+ const dir = this.fnDir(name);
398
+ const listed = paths.length ? paths : await listFiles(dir);
399
+ const files: EditableSourceFile[] = [];
400
+ for (const path of listed) {
401
+ files.push({
402
+ path,
403
+ content: await Deno.readTextFile(join(dir, safeRelPath(path))),
404
+ language: langFor(path),
405
+ });
406
+ }
407
+ return files.sort((a, b) => sourceSortKey(a.path).localeCompare(sourceSortKey(b.path)));
408
+ }
409
+
410
+ private baseManifest(name: string): FunctionManifest {
411
+ const prebuilt = this.opts.getPrebuiltManifest?.();
412
+ const fromPrebuilt = prebuilt?.functions.find((f) => f.name === name)?.manifest;
413
+ if (fromPrebuilt) return fromPrebuilt;
414
+ return this.opts.getLiveManifests?.()?.get(name) ?? defaultManifest();
415
+ }
416
+
417
+ private async baseFileList(name: string): Promise<string[]> {
418
+ const sourceDir = join(this.opts.sourceFunctionsDir, name);
419
+ const files = await listFiles(sourceDir);
420
+ if (files.length > 0) return files;
421
+ return [MANIFEST_FILENAME, "index.ts"];
422
+ }
423
+
424
+ private async detectBaseSource(name: string): Promise<EditableFunctionSummary["source"]> {
425
+ const sourceDir = join(this.opts.sourceFunctionsDir, name);
426
+ if (await pathExists(join(sourceDir, "index.ts")) || await pathExists(join(sourceDir, "index.js"))) return "source-tree";
427
+ const source = await this.readPrebuiltSource(name);
428
+ return source.source;
429
+ }
430
+
431
+ private async readBaseSource(name: string): Promise<EditableSourceResponse> {
432
+ const sourceDir = join(this.opts.sourceFunctionsDir, name);
433
+ const sourceFiles = await listFiles(sourceDir);
434
+ if (sourceFiles.length > 0) {
435
+ const files: EditableSourceFile[] = [];
436
+ for (const path of sourceFiles) {
437
+ files.push({
438
+ path,
439
+ content: await Deno.readTextFile(join(sourceDir, path)),
440
+ language: langFor(path),
441
+ });
442
+ }
443
+ return {
444
+ name,
445
+ origin: "manifest",
446
+ files: files.sort((a, b) => sourceSortKey(a.path).localeCompare(sourceSortKey(b.path))),
447
+ selected: files.find((f) => f.path === "index.ts")?.path ?? files[0]?.path ?? "index.ts",
448
+ degraded: false,
449
+ };
450
+ }
451
+ const fallback = await this.readPrebuiltSource(name);
452
+ return {
453
+ name,
454
+ origin: "manifest",
455
+ files: fallback.files,
456
+ selected: fallback.files[0]?.path ?? "index.ts",
457
+ degraded: fallback.source === "bundle" || fallback.source === "missing",
458
+ warning: fallback.warning,
459
+ };
460
+ }
461
+
462
+ private async readPrebuiltSource(name: string): Promise<{
463
+ source: EditableFunctionSummary["source"];
464
+ files: EditableSourceFile[];
465
+ warning?: string;
466
+ }> {
467
+ const prebuilt = this.opts.getPrebuiltManifest?.();
468
+ const prebuiltDir = this.opts.prebuiltDir;
469
+ const entry = prebuilt?.functions.find((f) => f.name === name);
470
+ if (!entry || !prebuiltDir) {
471
+ return {
472
+ source: "missing",
473
+ files: [{ path: "index.ts", content: "", language: "typescript" }],
474
+ warning: "Source is not available for this firmware function.",
475
+ };
476
+ }
477
+ const snapshot = await this.readSourceSnapshot(name);
478
+ if (snapshot.length > 0) return { source: "snapshot", files: snapshot };
479
+
480
+ for (const moduleFile of entry.moduleFiles.length ? entry.moduleFiles : [entry.bundleFile]) {
481
+ const map = await readTextIfExists(join(prebuiltDir, `${moduleFile}.map`));
482
+ if (!map) continue;
483
+ const extracted = extractSourcesContent(map);
484
+ if (extracted.length > 0) return { source: "sourcemap", files: extracted };
485
+ }
486
+
487
+ const bundleFile = entry.moduleFiles[0] ?? entry.bundleFile;
488
+ const bundle = await readTextIfExists(join(prebuiltDir, bundleFile));
489
+ if (bundle !== null) {
490
+ return {
491
+ source: "bundle",
492
+ files: [{ path: basename(bundleFile), content: bundle, language: "javascript" }],
493
+ warning: "Authored source was not packaged with this firmware; showing emitted JS fallback.",
494
+ };
495
+ }
496
+ return {
497
+ source: "missing",
498
+ files: [{ path: "index.ts", content: "", language: "typescript" }],
499
+ warning: "Source is not available for this firmware function.",
500
+ };
501
+ }
502
+
503
+ private async readSourceSnapshot(name: string): Promise<EditableSourceFile[]> {
504
+ const prebuiltDir = this.opts.prebuiltDir;
505
+ if (!prebuiltDir) return [];
506
+ const snapshotDir = join(prebuiltDir, "sources", name);
507
+ const files = await listFiles(snapshotDir);
508
+ const out: EditableSourceFile[] = [];
509
+ for (const path of files) {
510
+ out.push({
511
+ path,
512
+ content: await Deno.readTextFile(join(snapshotDir, path)),
513
+ language: langFor(path),
514
+ });
515
+ }
516
+ return out.sort((a, b) => sourceSortKey(a.path).localeCompare(sourceSortKey(b.path)));
517
+ }
518
+
519
+ private async resolveEntrypoint(dir: string): Promise<string> {
520
+ const ts = join(dir, "index.ts");
521
+ if (await pathExists(ts)) return ts;
522
+ const js = join(dir, "index.js");
523
+ if (await pathExists(js)) return js;
524
+ throw new Error(`runtime override at ${dir} is missing index.ts or index.js`);
525
+ }
526
+ }
527
+
528
+ function extractSourcesContent(rawMap: string): EditableSourceFile[] {
529
+ try {
530
+ const map = JSON.parse(rawMap) as { sources?: unknown; sourcesContent?: unknown };
531
+ if (!Array.isArray(map.sources) || !Array.isArray(map.sourcesContent)) return [];
532
+ const files: EditableSourceFile[] = [];
533
+ for (let i = 0; i < map.sources.length; i++) {
534
+ const source = map.sources[i];
535
+ const content = map.sourcesContent[i];
536
+ if (typeof source !== "string" || typeof content !== "string") continue;
537
+ const name = source.replace(/^file:\/+/, "").replaceAll("\\", "/").split("/").filter(Boolean).pop() ?? `source-${i}.ts`;
538
+ files.push({
539
+ path: safeRelPath(name),
540
+ content,
541
+ language: langFor(name),
542
+ });
543
+ }
544
+ return files.sort((a, b) => sourceSortKey(a.path).localeCompare(sourceSortKey(b.path)));
545
+ } catch {
546
+ return [];
547
+ }
548
+ }
549
+
550
+ function sourceSortKey(path: string): string {
551
+ if (path === "index.ts" || path === "index.js") return `0:${path}`;
552
+ if (path === MANIFEST_FILENAME) return `9:${path}`;
553
+ return `5:${path}`;
554
+ }
555
+
556
+ function defaultManifestForFile(): Record<string, unknown> {
557
+ const manifest = defaultManifest();
558
+ return {
559
+ permissions: manifest.permissions,
560
+ recycle: manifest.recycle,
561
+ };
562
+ }
563
+
564
+ export async function copyPrebuiltRuntimeFiles(prebuilt: PrebuiltManifest, prebuiltDir: string, cacheDir: string): Promise<void> {
565
+ await ensureDir(cacheDir);
566
+ const copied = new Set<string>();
567
+ async function copyRel(rel: string) {
568
+ const safe = safeRelPath(rel);
569
+ if (copied.has(safe)) return;
570
+ copied.add(safe);
571
+ const from = join(prebuiltDir, safe);
572
+ const to = join(cacheDir, safe);
573
+ await ensureDir(dirname(to));
574
+ const content = await Deno.readFile(from);
575
+ await Deno.writeFile(to, content);
576
+ }
577
+ for (const fn of prebuilt.functions) {
578
+ await copyRel(fn.bundleFile);
579
+ for (const moduleFile of fn.moduleFiles) await copyRel(moduleFile);
580
+ }
581
+ for (const chunk of prebuilt.chunks) await copyRel(chunk.file);
582
+ for (const shared of prebuilt.sharedModules) await copyRel(shared.bundleFile);
583
+ }
584
+
585
+ export async function readTextDigestFile(path: string): Promise<{ bytes: number; sha256: string }> {
586
+ const data = await Deno.readFile(path);
587
+ const digest = await crypto.subtle.digest("SHA-256", data as BufferSource);
588
+ return {
589
+ bytes: data.byteLength,
590
+ sha256: Array.from(new Uint8Array(digest)).map((b) => b.toString(16).padStart(2, "0")).join(""),
591
+ };
592
+ }
593
+
594
+ export async function sourceFingerprint(files: readonly EditableSourceFile[]): Promise<string> {
595
+ const canonical = files
596
+ .map((f) => `${safeRelPath(f.path)}\0${f.content}`)
597
+ .sort()
598
+ .join("\0");
599
+ return await hashSha256Hex(canonical);
600
+ }
package/src/cli/build.ts CHANGED
@@ -32,7 +32,7 @@
32
32
  */
33
33
 
34
34
  import { ensureDir } from "jsr:@std/fs@^1/ensure-dir";
35
- import { isAbsolute, join, resolve as resolvePath } from "node:path";
35
+ import { dirname, isAbsolute, join, relative, resolve as resolvePath } from "node:path";
36
36
  import {
37
37
  bundleAllChunked,
38
38
  bundleSharedModule,
@@ -146,6 +146,37 @@ async function sha256Hex(bytes: Uint8Array): Promise<string> {
146
146
  return out;
147
147
  }
148
148
 
149
+ async function copySourceSnapshot(functionsDir: string, name: string, outDir: string): Promise<string[]> {
150
+ const sourceRoot = join(functionsDir, name);
151
+ const outRoot = join(outDir, "sources", name);
152
+ const files: string[] = [];
153
+ async function walk(dir: string): Promise<void> {
154
+ for await (const entry of Deno.readDir(dir)) {
155
+ if (entry.name === "node_modules" || entry.name === ".git") continue;
156
+ const abs = join(dir, entry.name);
157
+ if (entry.isDirectory) {
158
+ await walk(abs);
159
+ } else if (entry.isFile) {
160
+ const rel = relative(sourceRoot, abs).replaceAll("\\", "/");
161
+ files.push(rel);
162
+ const dest = join(outRoot, rel);
163
+ await ensureDir(dirname(dest));
164
+ await Deno.copyFile(abs, dest);
165
+ }
166
+ }
167
+ }
168
+ await walk(sourceRoot);
169
+ return files.sort((a, b) => a.localeCompare(b));
170
+ }
171
+
172
+ async function removeDirIfExists(path: string): Promise<void> {
173
+ try {
174
+ await Deno.remove(path, { recursive: true });
175
+ } catch (err) {
176
+ if (!(err instanceof Deno.errors.NotFound)) throw err;
177
+ }
178
+ }
179
+
149
180
  /**
150
181
  * Build every function under `functionsDir` into `outDir`. Idempotent
151
182
  * w.r.t. the on-disk artifact — running twice with the same inputs
@@ -180,6 +211,7 @@ async function buildOnce(opts: BuildOptions): Promise<BuildResult> {
180
211
  }
181
212
 
182
213
  await ensureDir(outDir);
214
+ await removeDirIfExists(join(outDir, "sources"));
183
215
 
184
216
  // Bundles live under `dist/functions/` to mirror the `.1tube`
185
217
  // firmware payload layout. Manifest stays at `dist/manifest.json`
@@ -274,6 +306,7 @@ async function buildOnce(opts: BuildOptions): Promise<BuildResult> {
274
306
  const bytes = await Deno.readFile(r.bundlePath);
275
307
  const sha = await sha256Hex(bytes);
276
308
  const manifest = await loadManifest(functionsDir, r.name);
309
+ const sourceFiles = await copySourceSnapshot(functionsDir, r.name, outDir);
277
310
  opts.onProgress?.({
278
311
  phase: "hash-function",
279
312
  current: idx + 1,
@@ -296,6 +329,7 @@ async function buildOnce(opts: BuildOptions): Promise<BuildResult> {
296
329
  bundleSha256: sha,
297
330
  moduleFiles: chunkedFn?.moduleFiles ?? [bundleFile],
298
331
  manifest,
332
+ sourceFiles,
299
333
  };
300
334
  }),
301
335
  );
@@ -242,6 +242,21 @@ async function collectDistFiles(
242
242
  if (!(err instanceof Deno.errors.NotFound)) throw err;
243
243
  }
244
244
 
245
+ for (const fn of manifest.functions) {
246
+ for (const sourceFile of fn.sourceFiles ?? []) {
247
+ const src = join(distDir, "sources", fn.name, sourceFile);
248
+ try {
249
+ const fileStat = await Deno.stat(src);
250
+ if (fileStat.isFile) {
251
+ const rel = `sources/${fn.name}/${sourceFile}`.replace(/\\/g, "/");
252
+ plan.push({ src, zipPath: `dist/${rel}`, size: fileStat.size });
253
+ }
254
+ } catch (err) {
255
+ if (!(err instanceof Deno.errors.NotFound)) throw err;
256
+ }
257
+ }
258
+ }
259
+
245
260
  const plannedZipPaths = new Set(plan.map((p) => p.zipPath));
246
261
  for (const fn of manifest.functions) {
247
262
  const expected = `dist/${fn.bundleFile}`;
package/src/server.ts CHANGED
@@ -16,7 +16,7 @@
16
16
  // cleanly when launched from a host project's node_modules — bare
17
17
  // specifiers would otherwise require the host's deno.json to mirror our
18
18
  // import map. Same convention applies to every src/ file.
19
- import { Hono } from "npm:hono@4";
19
+ import { Hono, type Context } from "npm:hono@4";
20
20
  import { bodyLimit } from "npm:hono@4/body-limit";
21
21
  import { relative, sep as SEPARATOR } from "node:path";
22
22
  import { currentRequestStorage, FunctionRegistry } from "./registry.ts";
@@ -149,6 +149,12 @@ interface CliOpts {
149
149
  * Off by default — workerd uses V8's defaults.
150
150
  */
151
151
  workerdMaxHeapMB?: number;
152
+ /** Workerd compatibility date passed to generated config.capnp files. */
153
+ workerdCompatibilityDate?: string;
154
+ /** Workerd compatibility flags passed to generated config.capnp files. */
155
+ workerdCompatibilityFlags?: readonly string[];
156
+ /** Pass `--experimental` to the workerd process itself. */
157
+ workerdExperimental?: boolean;
152
158
  /**
153
159
  * Path to a `1tube build`-produced artifact directory. When set,
154
160
  * the workerd backend skips esbuild entirely and serves the
@@ -219,6 +225,12 @@ function parseArgs(): CliOpts {
219
225
  const n = parseInt(v, 10);
220
226
  return Number.isFinite(n) && n > 0 ? n : undefined;
221
227
  })();
228
+ let workerdCompatibilityDate: string | undefined = Deno.env.get("1TUBE_WORKERD_COMPAT_DATE") || undefined;
229
+ const workerdCompatibilityFlags: string[] = (Deno.env.get("1TUBE_WORKERD_COMPAT_FLAGS") ?? "")
230
+ .split(",")
231
+ .map((s) => s.trim())
232
+ .filter((s) => s.length > 0);
233
+ let workerdExperimental = envFlag("1TUBE_WORKERD_EXPERIMENTAL");
222
234
  let port = parseInt(Deno.env.get("PORT") || "3100", 10);
223
235
  let host = (Deno.env.get("1TUBE_HOST") || "127.0.0.1").trim();
224
236
  let functionsPath = Deno.env.get("FUNCTIONS_PATH") || "./supabase/functions";
@@ -302,6 +314,18 @@ function parseArgs(): CliOpts {
302
314
  } else if (a.startsWith("--workerd-max-heap-mb=")) {
303
315
  const n = parseInt(a.slice("--workerd-max-heap-mb=".length), 10);
304
316
  if (Number.isFinite(n) && n > 0) workerdMaxHeapMB = n;
317
+ } else if (a === "--compat-date" && args[i + 1]) {
318
+ workerdCompatibilityDate = args[++i];
319
+ } else if (a.startsWith("--compat-date=")) {
320
+ workerdCompatibilityDate = a.slice("--compat-date=".length);
321
+ } else if (a === "--compat-flag" && args[i + 1]) {
322
+ workerdCompatibilityFlags.push(args[++i]);
323
+ } else if (a.startsWith("--compat-flag=")) {
324
+ workerdCompatibilityFlags.push(a.slice("--compat-flag=".length));
325
+ } else if (a === "--workerd-experimental" || a === "--workerd-experimental=true") {
326
+ workerdExperimental = true;
327
+ } else if (a === "--workerd-experimental=false" || a === "--no-workerd-experimental") {
328
+ workerdExperimental = false;
305
329
  } else if (a === "--kill-stale-workerd" || a === "--kill-stale-workerd=true") {
306
330
  // Boolean flag — accept both bare-flag and explicit `=true` forms
307
331
  // so we don't surprise scripts that programmatically generate
@@ -348,6 +372,9 @@ function parseArgs(): CliOpts {
348
372
  workerdBasePort,
349
373
  workerdInspector,
350
374
  workerdMaxHeapMB,
375
+ workerdCompatibilityDate,
376
+ workerdCompatibilityFlags,
377
+ workerdExperimental,
351
378
  killStaleWorkerd,
352
379
  prebuiltDir,
353
380
  };
@@ -424,6 +451,17 @@ function enforceProdSecrets() {
424
451
  const opts = parseArgs();
425
452
  const internalKey = Deno.env.get("INTERNAL_KEY")?.trim() || undefined;
426
453
 
454
+ function isInternalRequest(req: Request): boolean {
455
+ if (!internalKey) return false;
456
+ const header = req.headers.get("Authorization");
457
+ const match = header?.match(/^Bearer\s+(.+)$/i);
458
+ return match?.[1]?.trim() === internalKey;
459
+ }
460
+
461
+ function requireInternal(c: Context): Response | null {
462
+ return isInternalRequest(c.req.raw) ? null : c.json({ error: "Forbidden" }, 403);
463
+ }
464
+
427
465
  if (opts.dev) {
428
466
  applyDevDefaults();
429
467
  } else {
@@ -703,6 +741,15 @@ if (opts.backend === "workerd") {
703
741
  `[1tube] auto-kill leftover workerd: ON (--kill-stale-workerd)`,
704
742
  );
705
743
  }
744
+ if (opts.workerdCompatibilityDate || (opts.workerdCompatibilityFlags?.length ?? 0) > 0) {
745
+ console.log(
746
+ `[1tube] workerd compatibility: date=${opts.workerdCompatibilityDate ?? "default"} ` +
747
+ `flags=${(opts.workerdCompatibilityFlags ?? []).join(",") || "default"}`,
748
+ );
749
+ }
750
+ if (opts.workerdExperimental) {
751
+ console.log(`[1tube] workerd process experimental mode: ON (--experimental)`);
752
+ }
706
753
  workerdBackend = createWorkerdBackend({
707
754
  functionsDir: opts.functionsPath,
708
755
  // The bundler resolves npm: / jsr: / https: specifiers via Deno's
@@ -716,6 +763,11 @@ if (opts.backend === "workerd") {
716
763
  ...(opts.workerdBasePort ? { basePort: opts.workerdBasePort } : {}),
717
764
  ...(opts.workerdInspector ? { inspectorAddr: opts.workerdInspector } : {}),
718
765
  ...(opts.workerdMaxHeapMB ? { maxHeapMB: opts.workerdMaxHeapMB } : {}),
766
+ ...(opts.workerdCompatibilityDate ? { compatibilityDate: opts.workerdCompatibilityDate } : {}),
767
+ ...((opts.workerdCompatibilityFlags?.length ?? 0) > 0
768
+ ? { compatibilityFlags: opts.workerdCompatibilityFlags }
769
+ : {}),
770
+ ...(opts.workerdExperimental ? { experimental: true } : {}),
719
771
  ...(opts.killStaleWorkerd ? { killStaleWorkerd: true } : {}),
720
772
  ...(opts.prebuiltDir ? { prebuiltDir: opts.prebuiltDir } : {}),
721
773
  sourcemap: opts.dev ? "inline" : "linked",
@@ -1307,6 +1359,69 @@ app.get(
1307
1359
  }),
1308
1360
  );
1309
1361
 
1362
+ async function runFunctionAdmin(c: Context, action: (backend: WorkerdBackend) => Promise<Response>): Promise<Response> {
1363
+ const forbidden = requireInternal(c);
1364
+ if (forbidden) return forbidden;
1365
+ if (!workerdBackend) {
1366
+ return c.json({ error: "Edge function editor is available only with backend=workerd" }, 503);
1367
+ }
1368
+ try {
1369
+ return await action(workerdBackend);
1370
+ } catch (err) {
1371
+ const message = err instanceof Error ? err.message : String(err);
1372
+ return c.json({ error: message }, 400);
1373
+ }
1374
+ }
1375
+
1376
+ app.get("/1tube/api/functions", (c) =>
1377
+ runFunctionAdmin(c, async (backend) => c.json({
1378
+ functions: await backend.listEditableFunctions(),
1379
+ }))
1380
+ );
1381
+
1382
+ app.get("/1tube/api/functions/:name/source", (c) =>
1383
+ runFunctionAdmin(c, async (backend) => c.json(await backend.readEditableSource(c.req.param("name"))))
1384
+ );
1385
+
1386
+ app.post("/1tube/api/functions", (c) =>
1387
+ runFunctionAdmin(c, async (backend) => {
1388
+ const body = await c.req.json().catch(() => ({})) as { name?: unknown };
1389
+ if (typeof body.name !== "string" || body.name.trim().length === 0) {
1390
+ return c.json({ error: "Missing function name" }, 400);
1391
+ }
1392
+ const source = await backend.createEditableFunction(body.name);
1393
+ return c.json({ source, functions: await backend.listEditableFunctions() }, 201);
1394
+ })
1395
+ );
1396
+
1397
+ app.put("/1tube/api/functions/:name/source", (c) =>
1398
+ runFunctionAdmin(c, async (backend) => {
1399
+ const body = await c.req.json().catch(() => ({})) as { files?: unknown };
1400
+ if (!body.files || typeof body.files !== "object" || Array.isArray(body.files)) {
1401
+ return c.json({ error: "Expected JSON body { files: { path: content } }" }, 400);
1402
+ }
1403
+ const reload = await backend.saveEditableSource(
1404
+ c.req.param("name"),
1405
+ { files: body.files as Record<string, string> },
1406
+ );
1407
+ return c.json({ reload, functions: await backend.listEditableFunctions() });
1408
+ })
1409
+ );
1410
+
1411
+ app.delete("/1tube/api/functions/:name", (c) =>
1412
+ runFunctionAdmin(c, async (backend) => {
1413
+ const reload = await backend.deleteEditableFunction(c.req.param("name"));
1414
+ return c.json({ reload, functions: await backend.listEditableFunctions() });
1415
+ })
1416
+ );
1417
+
1418
+ app.post("/1tube/api/functions/:name/revert", (c) =>
1419
+ runFunctionAdmin(c, async (backend) => {
1420
+ const reload = await backend.revertEditableFunction(c.req.param("name"));
1421
+ return c.json({ reload, functions: await backend.listEditableFunctions() });
1422
+ })
1423
+ );
1424
+
1310
1425
  // Root liveness probe — intentionally minimal so we don't leak the registered
1311
1426
  // function count or endpoint map to unauthenticated callers.
1312
1427
  app.get("/", (c) => c.json({ status: "ok" }));