1tube 0.1.15 → 0.1.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/backends/workerd/backend.ts +147 -18
- package/src/backends/workerd/bundler.ts +2 -2
- package/src/backends/workerd/prebuilt.ts +5 -0
- package/src/backends/workerd/runtime-overrides.ts +600 -0
- package/src/cli/build.ts +35 -1
- package/src/cli/package.ts +15 -0
- package/src/server.ts +116 -1
package/package.json
CHANGED
|
@@ -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
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
1222
|
-
r.bundlePath.
|
|
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
|
-
...(
|
|
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
|
-
//
|
|
1787
|
-
//
|
|
1788
|
-
//
|
|
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
|
-
"
|
|
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
|
}
|
|
@@ -37,8 +37,8 @@
|
|
|
37
37
|
import { dirname, join, resolve as resolvePath } from "node:path";
|
|
38
38
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
39
39
|
import { ensureDir } from "jsr:@std/fs@^1/ensure-dir";
|
|
40
|
-
import * as esbuild from "esbuild";
|
|
41
|
-
import { denoPlugin } from "
|
|
40
|
+
import * as esbuild from "npm:esbuild@^0.28";
|
|
41
|
+
import { denoPlugin } from "jsr:@deno/esbuild-plugin@^1.2";
|
|
42
42
|
import {
|
|
43
43
|
SHARED_RUNTIME_TOKEN_ENV,
|
|
44
44
|
SHARED_RUNTIME_URL_ENV,
|
|
@@ -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
|
);
|
package/src/cli/package.ts
CHANGED
|
@@ -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" }));
|