@deepseek-ai/dsh-experimental-webworker-runtime 0.1.6-alpha.1
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/LICENSE +21 -0
- package/README.i18n.yaml +6 -0
- package/README.md +75 -0
- package/README.zh.md +75 -0
- package/lib/client.js +854 -0
- package/lib/index.js +3506 -0
- package/lib/types/client/apply-injections.d.ts +15 -0
- package/lib/types/client/client.d.ts +102 -0
- package/lib/types/client/index.d.ts +66 -0
- package/lib/types/client/source-chooser.d.ts +8 -0
- package/lib/types/compile/transform.d.ts +39 -0
- package/lib/types/fixture-manifest.d.ts +26 -0
- package/lib/types/image-layout.d.ts +43 -0
- package/lib/types/index.d.ts +23 -0
- package/lib/types/module-proxies.d.ts +25 -0
- package/lib/types/module-system/module-loader.d.ts +177 -0
- package/lib/types/module-system/posix-path.d.ts +98 -0
- package/lib/types/node/builtin_modules/implemented/abort-error.d.ts +11 -0
- package/lib/types/node/builtin_modules/implemented/async_hooks.d.ts +174 -0
- package/lib/types/node/builtin_modules/implemented/buffer.d.ts +28 -0
- package/lib/types/node/builtin_modules/implemented/child_process.d.ts +178 -0
- package/lib/types/node/builtin_modules/implemented/crypto.d.ts +53 -0
- package/lib/types/node/builtin_modules/implemented/events.d.ts +84 -0
- package/lib/types/node/builtin_modules/implemented/fs/promises.d.ts +39 -0
- package/lib/types/node/builtin_modules/implemented/fs-watch.d.ts +119 -0
- package/lib/types/node/builtin_modules/implemented/fs.d.ts +502 -0
- package/lib/types/node/builtin_modules/implemented/http.d.ts +113 -0
- package/lib/types/node/builtin_modules/implemented/module.d.ts +50 -0
- package/lib/types/node/builtin_modules/implemented/os.d.ts +115 -0
- package/lib/types/node/builtin_modules/implemented/path.d.ts +138 -0
- package/lib/types/node/builtin_modules/implemented/perf_hooks.d.ts +13 -0
- package/lib/types/node/builtin_modules/implemented/stream.d.ts +22 -0
- package/lib/types/node/builtin_modules/implemented/timers/promises.d.ts +36 -0
- package/lib/types/node/builtin_modules/implemented/tty.d.ts +18 -0
- package/lib/types/node/builtin_modules/implemented/url.d.ts +61 -0
- package/lib/types/node/builtin_modules/implemented/util/types.d.ts +12 -0
- package/lib/types/node/builtin_modules/implemented/util.d.ts +95 -0
- package/lib/types/node/builtin_modules/implemented/zlib.d.ts +65 -0
- package/lib/types/node/builtin_modules/mock/dns/promises.d.ts +10 -0
- package/lib/types/node/builtin_modules/mock/net.d.ts +61 -0
- package/lib/types/node/builtin_modules/mock/sqlite.d.ts +19 -0
- package/lib/types/node/builtin_modules/mock/vm.d.ts +25 -0
- package/lib/types/node/builtin_modules/mock/worker_threads.d.ts +34 -0
- package/lib/types/node/builtins.d.ts +13 -0
- package/lib/types/node/external_packages/koffi.d.ts +63 -0
- package/lib/types/node/external_packages/node-addon-system-flock.d.ts +19 -0
- package/lib/types/node/external_packages/node-pty.d.ts +13 -0
- package/lib/types/node/external_packages/pi-ai.d.ts +51 -0
- package/lib/types/node/external_packages/replaced-externals.d.ts +9 -0
- package/lib/types/node/external_packages/ripgrep.d.ts +16 -0
- package/lib/types/node/external_packages/sharp.d.ts +6 -0
- package/lib/types/node/external_packages/ws.d.ts +38 -0
- package/lib/types/node/globals/crypto.d.ts +3 -0
- package/lib/types/node/globals/process.d.ts +78 -0
- package/lib/types/node/globals/timers.d.ts +10 -0
- package/lib/types/node/notImplementedFail.d.ts +34 -0
- package/lib/types/node/process-table.d.ts +50 -0
- package/lib/types/polyfill/async-context/als-runtime.d.ts +45 -0
- package/lib/types/polyfill/async-context/async-context-hooks.d.ts +6 -0
- package/lib/types/shell/ast.d.ts +23 -0
- package/lib/types/shell/expand.d.ts +65 -0
- package/lib/types/shell/fs-access.d.ts +43 -0
- package/lib/types/shell/interpret.d.ts +54 -0
- package/lib/types/shell/process/child.d.ts +26 -0
- package/lib/types/shell/process/host.d.ts +58 -0
- package/lib/types/shell/process/landlock.d.ts +33 -0
- package/lib/types/shell/process/protocol.d.ts +74 -0
- package/lib/types/shell/process/virtual-executables.d.ts +49 -0
- package/lib/types/shell/programs/builtins.d.ts +9 -0
- package/lib/types/shell/programs/files.d.ts +11 -0
- package/lib/types/shell/programs/index.d.ts +14 -0
- package/lib/types/shell/programs/options.d.ts +40 -0
- package/lib/types/shell/programs/text.d.ts +9 -0
- package/lib/types/shell/types.d.ts +138 -0
- package/lib/types/storage/active.d.ts +18 -0
- package/lib/types/storage/image-gzip.d.ts +39 -0
- package/lib/types/storage/memory.d.ts +308 -0
- package/lib/types/storage/paths.d.ts +18 -0
- package/lib/types/storage/tar.d.ts +41 -0
- package/lib/types/storage/types.d.ts +279 -0
- package/lib/types/transport/frames.d.ts +108 -0
- package/lib/types/transport/synthetic-http.d.ts +50 -0
- package/lib/types/transport/tunnel.d.ts +132 -0
- package/lib/types/worker-host.d.ts +165 -0
- package/lib/types/worker.d.ts +2 -0
- package/lib/worker.js +21879 -0
- package/package.json +73 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,3506 @@
|
|
|
1
|
+
import { parse } from "acorn";
|
|
2
|
+
//#region \0rolldown/runtime.js
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __exportAll = (all, no_symbols) => {
|
|
5
|
+
let target = {};
|
|
6
|
+
for (var name in all) __defProp(target, name, {
|
|
7
|
+
get: all[name],
|
|
8
|
+
enumerable: true
|
|
9
|
+
});
|
|
10
|
+
if (!no_symbols) __defProp(target, Symbol.toStringTag, { value: "Module" });
|
|
11
|
+
return target;
|
|
12
|
+
};
|
|
13
|
+
//#endregion
|
|
14
|
+
//#region src/polyfill/async-context/als-runtime.ts
|
|
15
|
+
/**
|
|
16
|
+
* Build the runtime the rewritten code calls.
|
|
17
|
+
* @param causality - Snapshot face from the `node:async_hooks` proxy; omitted
|
|
18
|
+
* leaves the rewrite inert (it still hops a microtask, but moves no state).
|
|
19
|
+
* @returns Runtime object passed to every module wrapper.
|
|
20
|
+
*/
|
|
21
|
+
function createAlsRuntime(causality) {
|
|
22
|
+
const snapshot = () => causality?.snapshot();
|
|
23
|
+
const restore = (value) => {
|
|
24
|
+
causality?.restore(value);
|
|
25
|
+
};
|
|
26
|
+
return {
|
|
27
|
+
snapshot,
|
|
28
|
+
pause: (value) => {
|
|
29
|
+
const captured = snapshot();
|
|
30
|
+
return Promise.resolve(value).then((settled) => ({
|
|
31
|
+
ok: true,
|
|
32
|
+
value: settled,
|
|
33
|
+
snapshot: captured
|
|
34
|
+
}), (error) => ({
|
|
35
|
+
ok: false,
|
|
36
|
+
error,
|
|
37
|
+
snapshot: captured
|
|
38
|
+
}));
|
|
39
|
+
},
|
|
40
|
+
resume: (token) => {
|
|
41
|
+
restore(token.snapshot);
|
|
42
|
+
if (token.ok) return token.value;
|
|
43
|
+
throw token.error;
|
|
44
|
+
},
|
|
45
|
+
afterYield: (captured, sent) => {
|
|
46
|
+
restore(captured);
|
|
47
|
+
return sent;
|
|
48
|
+
},
|
|
49
|
+
iterator: (value) => {
|
|
50
|
+
const source = value;
|
|
51
|
+
const asyncFactory = source[Symbol.asyncIterator];
|
|
52
|
+
if (typeof asyncFactory === "function") return asyncFactory.call(source);
|
|
53
|
+
const syncFactory = source[Symbol.iterator];
|
|
54
|
+
if (typeof syncFactory !== "function") throw new TypeError("webworker als: for-await source is neither async nor sync iterable");
|
|
55
|
+
const inner = syncFactory.call(source);
|
|
56
|
+
return {
|
|
57
|
+
next: async (...args) => {
|
|
58
|
+
const step = inner.next(...args);
|
|
59
|
+
return {
|
|
60
|
+
done: step.done ?? false,
|
|
61
|
+
value: await step.value
|
|
62
|
+
};
|
|
63
|
+
},
|
|
64
|
+
return: async (sent) => {
|
|
65
|
+
const step = inner.return?.(sent) ?? {
|
|
66
|
+
done: true,
|
|
67
|
+
value: void 0
|
|
68
|
+
};
|
|
69
|
+
return {
|
|
70
|
+
done: step.done ?? true,
|
|
71
|
+
value: await step.value
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
},
|
|
76
|
+
close: async (iterator) => {
|
|
77
|
+
try {
|
|
78
|
+
return await iterator.return?.(void 0);
|
|
79
|
+
} catch {
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
//#endregion
|
|
86
|
+
//#region src/transport/frames.ts
|
|
87
|
+
/**
|
|
88
|
+
* Validate a `postMessage` payload as a tunnel frame.
|
|
89
|
+
* @param data - Message data received by the worker.
|
|
90
|
+
* @returns The frame.
|
|
91
|
+
*/
|
|
92
|
+
function parseInboundFrame(data) {
|
|
93
|
+
if (typeof data !== "object" || data === null) throw new Error(`webworker tunnel: message is not a frame: ${String(data)}`);
|
|
94
|
+
const frame = data;
|
|
95
|
+
if (frame.t === "init") {
|
|
96
|
+
if (typeof frame.image !== "string") throw new Error("webworker tunnel: init frame needs a string image url");
|
|
97
|
+
if (!Array.isArray(frame.overlays) || frame.overlays.some((overlay) => typeof overlay !== "string")) throw new Error("webworker tunnel: init frame needs an array of string overlay urls");
|
|
98
|
+
return {
|
|
99
|
+
t: "init",
|
|
100
|
+
image: frame.image,
|
|
101
|
+
overlays: frame.overlays
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
const id = frame.id;
|
|
105
|
+
if (typeof id !== "string" && typeof id !== "number") throw new Error(`webworker tunnel: frame has no usable id: ${JSON.stringify(frame.id)}`);
|
|
106
|
+
if (frame.t === "abort") return {
|
|
107
|
+
t: "abort",
|
|
108
|
+
id
|
|
109
|
+
};
|
|
110
|
+
if (frame.t === "stream-open") {
|
|
111
|
+
if (typeof frame.endpoint !== "string" || frame.endpoint.length === 0) throw new Error(`webworker tunnel: stream ${String(id)} needs a non-empty endpoint`);
|
|
112
|
+
return {
|
|
113
|
+
t: "stream-open",
|
|
114
|
+
id,
|
|
115
|
+
endpoint: frame.endpoint,
|
|
116
|
+
payload: frame.payload
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
if (frame.t !== "req") throw new Error(`webworker tunnel: unknown frame type ${JSON.stringify(frame.t)}`);
|
|
120
|
+
if (typeof frame.method !== "string" || typeof frame.url !== "string") throw new Error(`webworker tunnel: request ${String(id)} needs string method and url`);
|
|
121
|
+
if (typeof frame.headers !== "object" || frame.headers === null) throw new Error(`webworker tunnel: request ${String(id)} needs a headers object`);
|
|
122
|
+
const headers = {};
|
|
123
|
+
for (const [key, value] of Object.entries(frame.headers)) if (typeof value === "string") headers[key.toLowerCase()] = value;
|
|
124
|
+
const body = frame.body;
|
|
125
|
+
if (body !== void 0 && !(body instanceof ArrayBuffer) && !(body instanceof Blob) && !(body instanceof ReadableStream)) throw new Error(`webworker tunnel: request ${String(id)} body must be an ArrayBuffer, Blob, or ReadableStream`);
|
|
126
|
+
return {
|
|
127
|
+
t: "req",
|
|
128
|
+
id,
|
|
129
|
+
method: frame.method,
|
|
130
|
+
url: frame.url,
|
|
131
|
+
headers,
|
|
132
|
+
body
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
//#endregion
|
|
136
|
+
//#region src/module-system/posix-path.ts
|
|
137
|
+
var posix_path_exports = /* @__PURE__ */ __exportAll({
|
|
138
|
+
SEP: () => "/",
|
|
139
|
+
basename: () => basename,
|
|
140
|
+
dirname: () => dirname,
|
|
141
|
+
extname: () => extname,
|
|
142
|
+
fileUrlToPath: () => fileUrlToPath,
|
|
143
|
+
isAbsolute: () => isAbsolute,
|
|
144
|
+
join: () => join,
|
|
145
|
+
normalize: () => normalize,
|
|
146
|
+
parse: () => parse$1,
|
|
147
|
+
pathToFileUrl: () => pathToFileUrl,
|
|
148
|
+
relative: () => relative,
|
|
149
|
+
resolve: () => resolve,
|
|
150
|
+
toNamespacedPath: () => toNamespacedPath
|
|
151
|
+
});
|
|
152
|
+
/**
|
|
153
|
+
* Collapse `.` and `..` segments.
|
|
154
|
+
* @param path - Path with any number of separators.
|
|
155
|
+
* @returns Normalized path; a relative input keeps leading `..` segments.
|
|
156
|
+
*/
|
|
157
|
+
function normalize(path) {
|
|
158
|
+
const absolute = path.startsWith("/");
|
|
159
|
+
const trailing = path.length > 1 && path.endsWith("/");
|
|
160
|
+
const out = [];
|
|
161
|
+
for (const segment of path.split("/")) {
|
|
162
|
+
if (segment === "" || segment === ".") continue;
|
|
163
|
+
if (segment === ".." && out.length > 0 && out[out.length - 1] !== "..") {
|
|
164
|
+
out.pop();
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
if (segment === ".." && absolute) continue;
|
|
168
|
+
out.push(segment);
|
|
169
|
+
}
|
|
170
|
+
const body = out.join("/");
|
|
171
|
+
if (absolute) return "/" + body + (trailing && body !== "" ? "/" : "");
|
|
172
|
+
if (body === "") return trailing ? "./" : ".";
|
|
173
|
+
return body + (trailing ? "/" : "");
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Join segments and normalize the result.
|
|
177
|
+
* @param segments - Path segments.
|
|
178
|
+
* @returns Joined path, `.` when nothing remains.
|
|
179
|
+
*/
|
|
180
|
+
function join(...segments) {
|
|
181
|
+
const joined = segments.filter((segment) => segment !== "").join("/");
|
|
182
|
+
return joined === "" ? "." : normalize(joined);
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Resolve segments right to left against a base directory.
|
|
186
|
+
* @param segments - Path segments; the first absolute one wins.
|
|
187
|
+
* @returns Absolute normalized path.
|
|
188
|
+
*/
|
|
189
|
+
function resolve(...segments) {
|
|
190
|
+
let path = "";
|
|
191
|
+
for (const segment of [...segments].reverse()) {
|
|
192
|
+
if (segment === "") continue;
|
|
193
|
+
path = path === "" ? segment : `${segment}/${path}`;
|
|
194
|
+
if (segment.startsWith("/")) break;
|
|
195
|
+
}
|
|
196
|
+
return normalize(path.startsWith("/") ? path : `/${path}`);
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Directory part of a path, after normalization (see the module note).
|
|
200
|
+
* @param path - Path to inspect.
|
|
201
|
+
* @returns Parent path; `/` for root children and `.` for bare names.
|
|
202
|
+
*/
|
|
203
|
+
function dirname(path) {
|
|
204
|
+
const normalized = normalize(path).replace(/\/+$/, "");
|
|
205
|
+
const index = normalized.lastIndexOf("/");
|
|
206
|
+
if (index < 0) return ".";
|
|
207
|
+
if (index === 0) return "/";
|
|
208
|
+
return normalized.slice(0, index);
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Last segment of a path, after normalization (see the module note).
|
|
212
|
+
* @param path - Path to inspect.
|
|
213
|
+
* @param suffix - Optional suffix to strip.
|
|
214
|
+
* @returns Final segment.
|
|
215
|
+
*/
|
|
216
|
+
function basename(path, suffix) {
|
|
217
|
+
const normalized = normalize(path).replace(/\/+$/, "");
|
|
218
|
+
const name = normalized.slice(normalized.lastIndexOf("/") + 1);
|
|
219
|
+
if (suffix !== void 0 && suffix !== name && name.endsWith(suffix)) return name.slice(0, -suffix.length);
|
|
220
|
+
return name;
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Extension of the last segment, dot included.
|
|
224
|
+
* @param path - Path to inspect.
|
|
225
|
+
* @returns Extension, or an empty string when there is none.
|
|
226
|
+
*/
|
|
227
|
+
function extname(path) {
|
|
228
|
+
const name = basename(path);
|
|
229
|
+
const index = name.lastIndexOf(".");
|
|
230
|
+
return index <= 0 ? "" : name.slice(index);
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Report whether a path starts at the root.
|
|
234
|
+
* @param path - Path to inspect.
|
|
235
|
+
* @returns True for absolute paths.
|
|
236
|
+
*/
|
|
237
|
+
function isAbsolute(path) {
|
|
238
|
+
return path.startsWith("/");
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Relative path from one absolute path to another.
|
|
242
|
+
* @param from - Source directory.
|
|
243
|
+
* @param to - Target path.
|
|
244
|
+
* @returns Relative path using `..` segments.
|
|
245
|
+
*/
|
|
246
|
+
function relative(from, to) {
|
|
247
|
+
const source = resolve(from).split("/").filter((segment) => segment !== "");
|
|
248
|
+
const target = resolve(to).split("/").filter((segment) => segment !== "");
|
|
249
|
+
let shared = 0;
|
|
250
|
+
while (shared < source.length && shared < target.length && source[shared] === target[shared]) shared += 1;
|
|
251
|
+
return [...new Array(source.length - shared).fill(".."), ...target.slice(shared)].join("/");
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* Split a path into components, after normalization (see the module note).
|
|
255
|
+
* @param path - Path to split.
|
|
256
|
+
* @returns Root, directory, base name, extension, and stem.
|
|
257
|
+
*/
|
|
258
|
+
function parse$1(path) {
|
|
259
|
+
const root = isAbsolute(path) ? "/" : "";
|
|
260
|
+
const base = basename(path);
|
|
261
|
+
const ext = extname(path);
|
|
262
|
+
return {
|
|
263
|
+
root,
|
|
264
|
+
dir: dirname(path),
|
|
265
|
+
base,
|
|
266
|
+
ext,
|
|
267
|
+
name: ext === "" ? base : base.slice(0, -ext.length)
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* Node's Windows-only namespaced-path conversion.
|
|
272
|
+
* @param path - the path to convert.
|
|
273
|
+
* @returns The path unchanged; namespaced paths are a Windows concept.
|
|
274
|
+
*/
|
|
275
|
+
function toNamespacedPath(path) {
|
|
276
|
+
return path;
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* Convert a VFS path into a `file:` URL string.
|
|
280
|
+
* @param path - Absolute VFS path.
|
|
281
|
+
* @returns URL text with each segment percent-encoded.
|
|
282
|
+
*/
|
|
283
|
+
function pathToFileUrl(path) {
|
|
284
|
+
return `file://${resolve(path).split("/").map((segment) => encodeURIComponent(segment)).join("/")}`;
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* Convert a `file:` URL back into a VFS path.
|
|
288
|
+
* @param url - URL text or URL instance.
|
|
289
|
+
* @returns Absolute VFS path.
|
|
290
|
+
*/
|
|
291
|
+
function fileUrlToPath(url) {
|
|
292
|
+
const text = typeof url === "string" ? url : url.href;
|
|
293
|
+
if (!text.startsWith("file://")) throw new Error(`webworker vfs: not a file URL: ${text}`);
|
|
294
|
+
return decodeURIComponent(text.slice(7).replace(/[?#].*$/, "")) || "/";
|
|
295
|
+
}
|
|
296
|
+
//#endregion
|
|
297
|
+
//#region src/image-layout.ts
|
|
298
|
+
/**
|
|
299
|
+
* Image layout contract shared by the packer and the worker host: the virtual
|
|
300
|
+
* root, where the composed config and the manifest sit inside the image, and
|
|
301
|
+
* the working directories every image carries empty. One definition, two
|
|
302
|
+
* consumers — the packer writes this layout, the worker host mounts it.
|
|
303
|
+
*/
|
|
304
|
+
/** Default virtual root; the runtime mounts the image here unless told otherwise. */
|
|
305
|
+
const DEFAULT_ROOT = "/dsh";
|
|
306
|
+
/**
|
|
307
|
+
* Leaf name of the packed base image: one gzip member holding the ustar archive.
|
|
308
|
+
* The app build writes it beside the page and the page's boot fetches it from
|
|
309
|
+
* there, so the extension is part of what a deployment serves.
|
|
310
|
+
*/
|
|
311
|
+
const IMAGE_FILE_NAME = "vfs-image.tar.gz";
|
|
312
|
+
/** Image path the composed profile is written to; the runtime's Loader reads it. */
|
|
313
|
+
const IMAGE_CONFIG_PATH = "config/cordis.yml";
|
|
314
|
+
/** Image path of the manifest the runtime reads before it wraps a single module. */
|
|
315
|
+
const IMAGE_MANIFEST_PATH = "config/vfs-manifest.json";
|
|
316
|
+
/** Home directory under the root; the process shim's `DSH_HOME`/`HOME` default. */
|
|
317
|
+
const IMAGE_HOME_DIRECTORY = "home";
|
|
318
|
+
/** Working directories the host tree expects to exist, empty. */
|
|
319
|
+
const IMAGE_EMPTY_DIRECTORIES = [
|
|
320
|
+
"home/",
|
|
321
|
+
"workspace/",
|
|
322
|
+
"tmp/"
|
|
323
|
+
];
|
|
324
|
+
/**
|
|
325
|
+
* Top-level directories an overlay archive may populate. Runtime code,
|
|
326
|
+
* configuration, and the lowering manifest remain owned by the base image.
|
|
327
|
+
*/
|
|
328
|
+
const IMAGE_OVERLAY_DIRECTORIES = ["home", "workspace"];
|
|
329
|
+
/**
|
|
330
|
+
* Identity of the lowered code shape, recorded in the image manifest by the
|
|
331
|
+
* packer and required by the worker host: an image lowered by an older transform
|
|
332
|
+
* would otherwise run against newer wrapper semantics. Bump on any change to
|
|
333
|
+
* emitted code or to {@link WRAPPER_PARAMS}.
|
|
334
|
+
*/
|
|
335
|
+
const LOWERING_VERSION = "dsh-worker-transform/1";
|
|
336
|
+
/**
|
|
337
|
+
* Free variables a lowered body expects from its wrapper, in order.
|
|
338
|
+
*
|
|
339
|
+
* Part of the image layout rather than of the transform, because the loader
|
|
340
|
+
* wraps bodies it never parses: the packer emits against these names and the
|
|
341
|
+
* worker binds them, with no compiler in the worker bundle to agree with.
|
|
342
|
+
*/
|
|
343
|
+
const WRAPPER_PARAMS = [
|
|
344
|
+
"exports",
|
|
345
|
+
"require",
|
|
346
|
+
"module",
|
|
347
|
+
"__filename",
|
|
348
|
+
"__dirname",
|
|
349
|
+
"__dsh$meta",
|
|
350
|
+
"__als"
|
|
351
|
+
];
|
|
352
|
+
//#endregion
|
|
353
|
+
//#region src/module-system/module-loader.ts
|
|
354
|
+
/**
|
|
355
|
+
* CommonJS module loader over the worker VFS. It fills the `loader.internal`
|
|
356
|
+
* seam Cordis uses for every entry import, and backs the `node:module`
|
|
357
|
+
* `createRequire` proxy that `typert-loader`, `client-modules`, and the plugin
|
|
358
|
+
* package inventory resolve package metadata through.
|
|
359
|
+
*
|
|
360
|
+
* Resolution is a narrowed Node `require` algorithm: `exports` walk with a
|
|
361
|
+
* fixed condition order, extension probing, and one cache keyed by resolved
|
|
362
|
+
* absolute path. Module bodies are wrapped as the image holds them: lowering is
|
|
363
|
+
* the packer's job, so nothing here parses JavaScript.
|
|
364
|
+
* @module @deepseek-ai/dsh-experimental-webworker-runtime/src/module-system/module-loader
|
|
365
|
+
*/
|
|
366
|
+
/** Condition keys honoured in `exports`, in order; `node` is deliberately absent. */
|
|
367
|
+
const DEFAULT_CONDITIONS = [
|
|
368
|
+
"browser",
|
|
369
|
+
"require",
|
|
370
|
+
"import",
|
|
371
|
+
"default"
|
|
372
|
+
];
|
|
373
|
+
/** Extensions probed when a specifier has no usable one. */
|
|
374
|
+
const EXTENSIONS = [
|
|
375
|
+
".js",
|
|
376
|
+
".json",
|
|
377
|
+
".mjs",
|
|
378
|
+
".cjs"
|
|
379
|
+
];
|
|
380
|
+
function isRecord(value) {
|
|
381
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
382
|
+
}
|
|
383
|
+
/** Loader for one VFS mount; construct once per worker. */
|
|
384
|
+
var WorkerModuleLoader = class {
|
|
385
|
+
vfs;
|
|
386
|
+
root;
|
|
387
|
+
staticModules;
|
|
388
|
+
staticPrefixes;
|
|
389
|
+
conditions;
|
|
390
|
+
als;
|
|
391
|
+
modules = /* @__PURE__ */ new Map();
|
|
392
|
+
manifests = /* @__PURE__ */ new Map();
|
|
393
|
+
stack = [];
|
|
394
|
+
/**
|
|
395
|
+
* The Cordis module seam. `parentURL` positions relative specifiers;
|
|
396
|
+
* import attributes are ignored, as the client implementation does.
|
|
397
|
+
*/
|
|
398
|
+
internal;
|
|
399
|
+
constructor(options) {
|
|
400
|
+
this.vfs = options.vfs;
|
|
401
|
+
this.root = options.root ?? "/dsh";
|
|
402
|
+
this.staticModules = new Map(Object.entries(options.staticModules));
|
|
403
|
+
this.staticPrefixes = Object.entries(options.staticModulePrefixes ?? {}).sort(([left], [right]) => right.length - left.length);
|
|
404
|
+
this.conditions = new Set(options.conditions ?? DEFAULT_CONDITIONS);
|
|
405
|
+
this.als = createAlsRuntime(options.alsCausality);
|
|
406
|
+
const resolveInternal = (specifier, parentURL) => {
|
|
407
|
+
const from = parentURL === void 0 ? this.root : this.baseDirectoryOf(parentURL);
|
|
408
|
+
const resolution = this.resolve(specifier, from);
|
|
409
|
+
if (resolution.kind === "static") return {
|
|
410
|
+
format: "builtin",
|
|
411
|
+
url: resolution.specifier
|
|
412
|
+
};
|
|
413
|
+
return {
|
|
414
|
+
format: resolution.path.endsWith(".json") ? "json" : "commonjs",
|
|
415
|
+
url: pathToFileUrl(resolution.path)
|
|
416
|
+
};
|
|
417
|
+
};
|
|
418
|
+
this.internal = {
|
|
419
|
+
version: "worker",
|
|
420
|
+
import: async (specifier, parentURL) => {
|
|
421
|
+
const from = parentURL === void 0 ? this.root : this.baseDirectoryOf(parentURL);
|
|
422
|
+
return this.load(this.resolve(specifier, from));
|
|
423
|
+
},
|
|
424
|
+
resolve: async (specifier, parentURL) => resolveInternal(specifier, parentURL),
|
|
425
|
+
resolveSync: resolveInternal
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
fail(detail) {
|
|
429
|
+
const chain = this.stack.length === 0 ? "" : ` (importer chain: ${this.stack.join(" -> ")})`;
|
|
430
|
+
throw new Error(`webworker modules: ${detail}${chain}`);
|
|
431
|
+
}
|
|
432
|
+
/** @returns Directory a base path or URL resolves specifiers from. */
|
|
433
|
+
baseDirectoryOf(base) {
|
|
434
|
+
const text = typeof base === "string" ? base : base.href;
|
|
435
|
+
const path = text.startsWith("file://") ? fileUrlToPath(text) : text;
|
|
436
|
+
if (path.endsWith("/")) return resolve(path);
|
|
437
|
+
return this.vfs.existsSync(path) && this.vfs.statSync(path).isDirectory() ? resolve(path) : dirname(path);
|
|
438
|
+
}
|
|
439
|
+
manifestOf(directory) {
|
|
440
|
+
const cached = this.manifests.get(directory);
|
|
441
|
+
if (cached !== void 0) return cached;
|
|
442
|
+
const path = join(directory, "package.json");
|
|
443
|
+
const text = this.vfs.readFileSync(path, "utf8");
|
|
444
|
+
let parsed;
|
|
445
|
+
try {
|
|
446
|
+
parsed = JSON.parse(text);
|
|
447
|
+
} catch (reason) {
|
|
448
|
+
this.fail(`${path} is not valid JSON: ${reason.message}`);
|
|
449
|
+
}
|
|
450
|
+
if (!isRecord(parsed)) this.fail(`${path} does not hold an object`);
|
|
451
|
+
const manifest = parsed;
|
|
452
|
+
this.manifests.set(directory, manifest);
|
|
453
|
+
return manifest;
|
|
454
|
+
}
|
|
455
|
+
/** Walk one `exports` value against the condition set and requested subpath. */
|
|
456
|
+
selectExport(field, subpath, packageName) {
|
|
457
|
+
if (field === null) return void 0;
|
|
458
|
+
if (typeof field === "string") return subpath === "." ? field : void 0;
|
|
459
|
+
if (Array.isArray(field)) {
|
|
460
|
+
for (const candidate of field) {
|
|
461
|
+
const picked = this.selectExport(candidate, subpath, packageName);
|
|
462
|
+
if (picked !== void 0) return picked;
|
|
463
|
+
}
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
const entries = Object.entries(field);
|
|
467
|
+
if (!entries.some(([key]) => key === "." || key.startsWith("./"))) {
|
|
468
|
+
if (subpath !== ".") return void 0;
|
|
469
|
+
return this.selectCondition(field, packageName);
|
|
470
|
+
}
|
|
471
|
+
for (const [key, value] of entries) if (key === subpath) return typeof value === "string" ? value : this.selectCondition(value, packageName, subpath);
|
|
472
|
+
for (const [key, value] of entries) {
|
|
473
|
+
const star = key.indexOf("*");
|
|
474
|
+
if (star < 0) continue;
|
|
475
|
+
const prefix = key.slice(0, star);
|
|
476
|
+
const suffix = key.slice(star + 1);
|
|
477
|
+
if (!subpath.startsWith(prefix) || !subpath.endsWith(suffix)) continue;
|
|
478
|
+
const captured = subpath.slice(prefix.length, subpath.length - suffix.length);
|
|
479
|
+
const target = typeof value === "string" ? value : this.selectCondition(value, packageName, subpath);
|
|
480
|
+
if (target !== void 0) return target.replaceAll("*", captured);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
/** Pick the first condition branch this runtime satisfies. */
|
|
484
|
+
selectCondition(field, packageName, subpath = ".") {
|
|
485
|
+
if (field === null) return void 0;
|
|
486
|
+
if (typeof field === "string") return field;
|
|
487
|
+
if (Array.isArray(field)) {
|
|
488
|
+
for (const candidate of field) {
|
|
489
|
+
const picked = this.selectCondition(candidate, packageName, subpath);
|
|
490
|
+
if (picked !== void 0) return picked;
|
|
491
|
+
}
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
for (const [key, value] of Object.entries(field)) {
|
|
495
|
+
if (!this.conditions.has(key)) continue;
|
|
496
|
+
const picked = this.selectCondition(value, packageName, subpath);
|
|
497
|
+
if (picked !== void 0) return picked;
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
/** Extension and directory probing for a concrete path. */
|
|
501
|
+
probe(path, specifier) {
|
|
502
|
+
const candidates = [path, ...EXTENSIONS.map((extension) => path + extension)];
|
|
503
|
+
for (const candidate of candidates) if (this.vfs.existsSync(candidate) && this.vfs.statSync(candidate).isFile()) return candidate;
|
|
504
|
+
if (this.vfs.existsSync(path) && this.vfs.statSync(path).isDirectory()) {
|
|
505
|
+
if (this.vfs.existsSync(join(path, "package.json"))) {
|
|
506
|
+
const main = this.manifestOf(path).main;
|
|
507
|
+
if (main !== void 0) return this.probe(join(path, main), specifier);
|
|
508
|
+
}
|
|
509
|
+
return this.probe(join(path, "index"), specifier);
|
|
510
|
+
}
|
|
511
|
+
return this.fail(`cannot resolve "${specifier}": no file at ${candidates.join(", ")}`);
|
|
512
|
+
}
|
|
513
|
+
/** @returns The Worker-provided implementation of a static specifier. */
|
|
514
|
+
staticModule(specifier) {
|
|
515
|
+
const exact = this.staticModules.get(specifier);
|
|
516
|
+
if (exact !== void 0) return exact;
|
|
517
|
+
for (const [prefix, factory] of this.staticPrefixes) if (specifier.startsWith(prefix)) return factory;
|
|
518
|
+
return this.staticModules.get(`node:${specifier}`);
|
|
519
|
+
}
|
|
520
|
+
/**
|
|
521
|
+
* Resolve a specifier the way the module that requested it would.
|
|
522
|
+
* @param specifier - Bare name, relative path, absolute path, or file URL.
|
|
523
|
+
* @param fromDirectory - Directory of the requesting module.
|
|
524
|
+
* @returns Static module or the resolved VFS path.
|
|
525
|
+
*/
|
|
526
|
+
resolve(specifier, fromDirectory) {
|
|
527
|
+
const staticModule = this.staticModule(specifier);
|
|
528
|
+
if (staticModule !== void 0) return {
|
|
529
|
+
kind: "static",
|
|
530
|
+
specifier,
|
|
531
|
+
factory: staticModule
|
|
532
|
+
};
|
|
533
|
+
if (specifier.startsWith("cordis:") || specifier.startsWith("node:")) return this.fail(`no static module is registered for "${specifier}"`);
|
|
534
|
+
if (specifier.startsWith("file://")) return {
|
|
535
|
+
kind: "file",
|
|
536
|
+
path: this.probe(fileUrlToPath(specifier), specifier)
|
|
537
|
+
};
|
|
538
|
+
if (specifier.startsWith(".")) return {
|
|
539
|
+
kind: "file",
|
|
540
|
+
path: this.probe(join(fromDirectory, specifier), specifier)
|
|
541
|
+
};
|
|
542
|
+
if (isAbsolute(specifier)) return {
|
|
543
|
+
kind: "file",
|
|
544
|
+
path: this.probe(specifier, specifier)
|
|
545
|
+
};
|
|
546
|
+
const segments = specifier.split("/");
|
|
547
|
+
const packageName = specifier.startsWith("@") ? segments.slice(0, 2).join("/") : segments[0] ?? specifier;
|
|
548
|
+
const rest = specifier.slice(packageName.length).replace(/^\//, "");
|
|
549
|
+
const packageDirectory = join(this.root, "node_modules", packageName);
|
|
550
|
+
if (!this.vfs.existsSync(join(packageDirectory, "package.json"))) return this.fail(`cannot resolve "${specifier}": ${packageDirectory}/package.json is not in the image`);
|
|
551
|
+
const manifest = this.manifestOf(packageDirectory);
|
|
552
|
+
const subpath = rest === "" ? "." : `./${rest}`;
|
|
553
|
+
if (manifest.exports !== void 0) {
|
|
554
|
+
const target = this.selectExport(manifest.exports, subpath, packageName);
|
|
555
|
+
if (target === void 0) return this.fail(`"${packageName}" does not export "${subpath}" under conditions [${[...this.conditions].join(", ")}]`);
|
|
556
|
+
return {
|
|
557
|
+
kind: "file",
|
|
558
|
+
path: this.probe(join(packageDirectory, target), specifier)
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
const legacy = subpath === "." ? manifest.main ?? "index.js" : rest;
|
|
562
|
+
return {
|
|
563
|
+
kind: "file",
|
|
564
|
+
path: this.probe(join(packageDirectory, legacy), specifier)
|
|
565
|
+
};
|
|
566
|
+
}
|
|
567
|
+
/**
|
|
568
|
+
* Load a resolved module, reusing the cache and tolerating cycles with
|
|
569
|
+
* CommonJS partial-export semantics.
|
|
570
|
+
* @param resolution - Result of {@link resolve}.
|
|
571
|
+
* @returns The module's exports.
|
|
572
|
+
*/
|
|
573
|
+
load(resolution) {
|
|
574
|
+
if (resolution.kind === "static") return resolution.factory();
|
|
575
|
+
const path = resolution.path;
|
|
576
|
+
const cached = this.modules.get(path);
|
|
577
|
+
if (cached !== void 0) return cached.module.exports;
|
|
578
|
+
if (path.endsWith(".json")) {
|
|
579
|
+
const parsed = JSON.parse(this.vfs.readFileSync(path, "utf8"));
|
|
580
|
+
this.modules.set(path, { module: { exports: parsed } });
|
|
581
|
+
return parsed;
|
|
582
|
+
}
|
|
583
|
+
const record = { module: { exports: {} } };
|
|
584
|
+
this.modules.set(path, record);
|
|
585
|
+
this.stack.push(path);
|
|
586
|
+
try {
|
|
587
|
+
const source = this.vfs.readFileSync(path, "utf8");
|
|
588
|
+
const factory = this.compile(source, path);
|
|
589
|
+
const directory = dirname(path);
|
|
590
|
+
factory(record.module.exports, this.requireFrom(directory), record.module, path, directory, {
|
|
591
|
+
url: pathToFileUrl(path),
|
|
592
|
+
resolve: (specifier) => {
|
|
593
|
+
const resolution = this.resolve(specifier, directory);
|
|
594
|
+
return resolution.kind === "static" ? resolution.specifier : pathToFileUrl(resolution.path);
|
|
595
|
+
}
|
|
596
|
+
}, this.als);
|
|
597
|
+
return record.module.exports;
|
|
598
|
+
} catch (reason) {
|
|
599
|
+
this.modules.delete(path);
|
|
600
|
+
throw reason;
|
|
601
|
+
} finally {
|
|
602
|
+
this.stack.pop();
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
/**
|
|
606
|
+
* Compile a body the image already lowered.
|
|
607
|
+
*
|
|
608
|
+
* Module syntax reaching here means the image was packed by something other
|
|
609
|
+
* than the packer, or its collector missed the entry. The worker carries no
|
|
610
|
+
* transform to recover with, so it names the image as the thing to rebuild.
|
|
611
|
+
* @param code - Module body as the image holds it.
|
|
612
|
+
* @param path - Resolved VFS path.
|
|
613
|
+
* @returns The wrapper factory.
|
|
614
|
+
*/
|
|
615
|
+
compile(code, path) {
|
|
616
|
+
try {
|
|
617
|
+
return new Function(...WRAPPER_PARAMS, code);
|
|
618
|
+
} catch (reason) {
|
|
619
|
+
if (reason instanceof SyntaxError && /await/i.test(reason.message)) this.fail(`${path} uses top-level await, which cannot run as CommonJS in the worker: ${reason.message}`);
|
|
620
|
+
if (reason instanceof SyntaxError && /import|export/i.test(reason.message)) this.fail(`${path} still carries module syntax, so the image was not lowered by the packer (${reason.message}); rebuild the image`);
|
|
621
|
+
this.fail(`${path} failed to compile: ${reason.message}`);
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
/**
|
|
625
|
+
* Build a `require` bound to a directory.
|
|
626
|
+
* @param fromDirectory - Directory relative specifiers resolve against.
|
|
627
|
+
* @returns Callable require with `resolve`.
|
|
628
|
+
*/
|
|
629
|
+
requireFrom(fromDirectory) {
|
|
630
|
+
const require = (specifier) => this.load(this.resolve(specifier, fromDirectory));
|
|
631
|
+
const resolve$1 = ((specifier) => {
|
|
632
|
+
const resolution = this.resolve(specifier, fromDirectory);
|
|
633
|
+
if (resolution.kind === "static") return this.fail(`"${specifier}" is a worker-provided module and has no VFS path`);
|
|
634
|
+
return resolution.path;
|
|
635
|
+
});
|
|
636
|
+
resolve$1.paths = (specifier) => {
|
|
637
|
+
if (this.staticModule(specifier) !== void 0 || specifier.startsWith("node:")) return null;
|
|
638
|
+
if (specifier.startsWith(".")) return [resolve(fromDirectory, ".")];
|
|
639
|
+
return [join(this.root, "node_modules")];
|
|
640
|
+
};
|
|
641
|
+
return Object.assign(require, { resolve: resolve$1 });
|
|
642
|
+
}
|
|
643
|
+
/**
|
|
644
|
+
* `node:module` `createRequire` for the VFS.
|
|
645
|
+
* @param base - Module path, directory path, or `file:` URL.
|
|
646
|
+
* @returns Require bound to that base.
|
|
647
|
+
*/
|
|
648
|
+
createRequire(base) {
|
|
649
|
+
return this.requireFrom(this.baseDirectoryOf(base));
|
|
650
|
+
}
|
|
651
|
+
/**
|
|
652
|
+
* Report what this loader has done, for the host's boot diagnostics.
|
|
653
|
+
* @returns How many module bodies it has run.
|
|
654
|
+
*/
|
|
655
|
+
usage() {
|
|
656
|
+
return { modules: this.modules.size };
|
|
657
|
+
}
|
|
658
|
+
};
|
|
659
|
+
let active$1;
|
|
660
|
+
/**
|
|
661
|
+
* Publish the loader the `node:module` proxy resolves through.
|
|
662
|
+
* @param loader - Loader built by the worker entry.
|
|
663
|
+
*/
|
|
664
|
+
function setActiveModuleLoader(loader) {
|
|
665
|
+
active$1 = loader;
|
|
666
|
+
}
|
|
667
|
+
/**
|
|
668
|
+
* Read the published loader.
|
|
669
|
+
* @returns The active loader.
|
|
670
|
+
*/
|
|
671
|
+
function requireActiveModuleLoader() {
|
|
672
|
+
if (active$1 === void 0) throw new Error("webworker modules: no loader is mounted; the worker entry must call setActiveModuleLoader before any createRequire use");
|
|
673
|
+
return active$1;
|
|
674
|
+
}
|
|
675
|
+
//#endregion
|
|
676
|
+
//#region src/module-proxies.ts
|
|
677
|
+
/**
|
|
678
|
+
* The worker bundle's module proxy table: the ONLY platform fork of the host
|
|
679
|
+
* tree. Entries replace Node builtins, external npm packages, and the native
|
|
680
|
+
* flock subpath; other workspace and vendored modules are mounted as they ship.
|
|
681
|
+
*
|
|
682
|
+
* The build turns these into bundler aliases, and `node/builtins.ts` turns the
|
|
683
|
+
* same modules into the loader's static table — one list, two consumers.
|
|
684
|
+
*
|
|
685
|
+
* The replacement path states the classification. `./node/builtin_modules/implemented/<module>.ts`
|
|
686
|
+
* carries the module's real semantics over a worker-side data source (VFS, the
|
|
687
|
+
* tunnel, a wasm codec, a browser primitive); `./node/builtin_modules/mock/<module>.ts` is a
|
|
688
|
+
* structural placeholder that mounts silently and reports the missing capability
|
|
689
|
+
* when a call finally reaches it. External npm replacements live in
|
|
690
|
+
* `./externals/`, named after the package they stand in for.
|
|
691
|
+
* @module @deepseek-ai/dsh-experimental-webworker-runtime/src/module-proxies
|
|
692
|
+
*/
|
|
693
|
+
/**
|
|
694
|
+
* Module proxy table — the ONLY platform fork of the worker host. Keys are
|
|
695
|
+
* exact module specifiers; the native system package's Landlock entry stays
|
|
696
|
+
* unmodified while its flock subpath is replaced.
|
|
697
|
+
*/
|
|
698
|
+
const MODULE_PROXIES = {
|
|
699
|
+
"node:fs": "./node/builtin_modules/implemented/fs.ts",
|
|
700
|
+
"fs": "./node/builtin_modules/implemented/fs.ts",
|
|
701
|
+
"node:fs/promises": "./node/builtin_modules/implemented/fs/promises.ts",
|
|
702
|
+
"fs/promises": "./node/builtin_modules/implemented/fs/promises.ts",
|
|
703
|
+
"node:path": "./node/builtin_modules/implemented/path.ts",
|
|
704
|
+
"path": "./node/builtin_modules/implemented/path.ts",
|
|
705
|
+
"node:path/posix": "./node/builtin_modules/implemented/path.ts",
|
|
706
|
+
"node:os": "./node/builtin_modules/implemented/os.ts",
|
|
707
|
+
"os": "./node/builtin_modules/implemented/os.ts",
|
|
708
|
+
"node:url": "./node/builtin_modules/implemented/url.ts",
|
|
709
|
+
"node:module": "./node/builtin_modules/implemented/module.ts",
|
|
710
|
+
"node:crypto": "./node/builtin_modules/implemented/crypto.ts",
|
|
711
|
+
"crypto": "./node/builtin_modules/implemented/crypto.ts",
|
|
712
|
+
"node:buffer": "./node/builtin_modules/implemented/buffer.ts",
|
|
713
|
+
"node:http": "./node/builtin_modules/implemented/http.ts",
|
|
714
|
+
"node:async_hooks": "./node/builtin_modules/implemented/async_hooks.ts",
|
|
715
|
+
"node:util": "./node/builtin_modules/implemented/util.ts",
|
|
716
|
+
"node:util/types": "./node/builtin_modules/implemented/util/types.ts",
|
|
717
|
+
"node:events": "./node/builtin_modules/implemented/events.ts",
|
|
718
|
+
"node:timers/promises": "./node/builtin_modules/implemented/timers/promises.ts",
|
|
719
|
+
"node:perf_hooks": "./node/builtin_modules/implemented/perf_hooks.ts",
|
|
720
|
+
"node:tty": "./node/builtin_modules/implemented/tty.ts",
|
|
721
|
+
"tty": "./node/builtin_modules/implemented/tty.ts",
|
|
722
|
+
"node:zlib": "./node/builtin_modules/implemented/zlib.ts",
|
|
723
|
+
"node:child_process": "./node/builtin_modules/implemented/child_process.ts",
|
|
724
|
+
"node:dns/promises": "./node/builtin_modules/mock/dns/promises.ts",
|
|
725
|
+
"dns/promises": "./node/builtin_modules/mock/dns/promises.ts",
|
|
726
|
+
"node:net": "./node/builtin_modules/mock/net.ts",
|
|
727
|
+
"node:stream": "./node/builtin_modules/implemented/stream.ts",
|
|
728
|
+
"node:vm": "./node/builtin_modules/mock/vm.ts",
|
|
729
|
+
"node:worker_threads": "./node/builtin_modules/mock/worker_threads.ts",
|
|
730
|
+
"node:sqlite": "./node/builtin_modules/mock/sqlite.ts",
|
|
731
|
+
"@deepseek-ai/node-addon-system/flock": "./node/external_packages/node-addon-system-flock.ts",
|
|
732
|
+
"koffi": "./node/external_packages/koffi.ts",
|
|
733
|
+
"sharp": "./node/external_packages/sharp.ts",
|
|
734
|
+
"node-pty": "./node/external_packages/node-pty.ts",
|
|
735
|
+
"@vscode/ripgrep": "./node/external_packages/ripgrep.ts",
|
|
736
|
+
"@earendil-works/pi-ai": "./node/external_packages/pi-ai.ts",
|
|
737
|
+
"ws": "./node/external_packages/ws.ts"
|
|
738
|
+
};
|
|
739
|
+
/** pi-ai subpaths (`/providers/all`, `/api/*.lazy`) share the one structural stub. */
|
|
740
|
+
const MODULE_PROXY_PREFIXES = { "@earendil-works/pi-ai/": "./node/external_packages/pi-ai.ts" };
|
|
741
|
+
//#endregion
|
|
742
|
+
//#region src/node/external_packages/replaced-externals.ts
|
|
743
|
+
/**
|
|
744
|
+
* Exact package or subpath specifiers served from the worker bundle. Kept
|
|
745
|
+
* import-free for the runtime builtin table and the VFS image collector.
|
|
746
|
+
* Whole-package entries are omitted from the image; subpath entries leave
|
|
747
|
+
* their parent package available to other consumers.
|
|
748
|
+
*/
|
|
749
|
+
/** Package or subpath specifiers served from the worker bundle instead of the VFS. */
|
|
750
|
+
const REPLACED_EXTERNAL_PACKAGES = [
|
|
751
|
+
"@earendil-works/pi-ai",
|
|
752
|
+
"@vscode/ripgrep",
|
|
753
|
+
"@deepseek-ai/node-addon-system/flock",
|
|
754
|
+
"koffi",
|
|
755
|
+
"node-pty",
|
|
756
|
+
"sharp",
|
|
757
|
+
"ws"
|
|
758
|
+
];
|
|
759
|
+
//#endregion
|
|
760
|
+
//#region src/transport/synthetic-http.ts
|
|
761
|
+
const encoder$3 = new TextEncoder();
|
|
762
|
+
/**
|
|
763
|
+
* Build the request/response pair for one tunnel request.
|
|
764
|
+
*
|
|
765
|
+
* `res.end()` is the settle point: the captured listener returns void, so the
|
|
766
|
+
* response object itself reports completion. `write()` always returns true,
|
|
767
|
+
* which skips backpressure waiting the tunnel cannot observe anyway.
|
|
768
|
+
* @param frame - Validated request frame.
|
|
769
|
+
* @param sink - Frame emitter for the response.
|
|
770
|
+
* @returns The pair handed to the captured request listener.
|
|
771
|
+
*/
|
|
772
|
+
function createSyntheticExchange(frame, sink) {
|
|
773
|
+
const listeners = /* @__PURE__ */ new Map();
|
|
774
|
+
let status = 200;
|
|
775
|
+
let headers = {};
|
|
776
|
+
let streaming = false;
|
|
777
|
+
let finished = false;
|
|
778
|
+
let aborted = false;
|
|
779
|
+
const emit = (event) => {
|
|
780
|
+
for (const callback of [...listeners.get(event) ?? []]) callback();
|
|
781
|
+
};
|
|
782
|
+
const req = {
|
|
783
|
+
url: frame.url,
|
|
784
|
+
method: frame.method,
|
|
785
|
+
headers: frame.headers,
|
|
786
|
+
destroy: () => {
|
|
787
|
+
aborted = true;
|
|
788
|
+
},
|
|
789
|
+
async *[Symbol.asyncIterator]() {
|
|
790
|
+
if (frame.body === void 0) return;
|
|
791
|
+
if (frame.body instanceof Blob) {
|
|
792
|
+
for await (const chunk of frame.body.stream()) {
|
|
793
|
+
if (aborted) return;
|
|
794
|
+
if (chunk.byteLength > 0) yield chunk;
|
|
795
|
+
}
|
|
796
|
+
return;
|
|
797
|
+
}
|
|
798
|
+
if (frame.body instanceof ReadableStream) {
|
|
799
|
+
for await (const chunk of frame.body) {
|
|
800
|
+
if (aborted) return;
|
|
801
|
+
if (!(chunk instanceof Uint8Array)) throw new TypeError("webworker tunnel: request stream produced a non-Uint8Array chunk");
|
|
802
|
+
if (chunk.byteLength > 0) yield chunk;
|
|
803
|
+
}
|
|
804
|
+
return;
|
|
805
|
+
}
|
|
806
|
+
if (aborted || frame.body.byteLength === 0) return;
|
|
807
|
+
yield new Uint8Array(frame.body);
|
|
808
|
+
}
|
|
809
|
+
};
|
|
810
|
+
const res = {
|
|
811
|
+
writeHead: (nextStatus, nextHeaders) => {
|
|
812
|
+
status = nextStatus;
|
|
813
|
+
if (nextHeaders !== void 0) {
|
|
814
|
+
headers = {};
|
|
815
|
+
for (const [key, value] of Object.entries(nextHeaders)) headers[key.toLowerCase()] = String(value);
|
|
816
|
+
}
|
|
817
|
+
return res;
|
|
818
|
+
},
|
|
819
|
+
write: (chunk) => {
|
|
820
|
+
if (finished || aborted) return false;
|
|
821
|
+
if (!streaming) {
|
|
822
|
+
streaming = true;
|
|
823
|
+
sink.head(status, headers);
|
|
824
|
+
}
|
|
825
|
+
sink.chunk(typeof chunk === "string" ? encoder$3.encode(chunk) : chunk);
|
|
826
|
+
return true;
|
|
827
|
+
},
|
|
828
|
+
end: (body) => {
|
|
829
|
+
if (finished) return res;
|
|
830
|
+
finished = true;
|
|
831
|
+
const bytes = body === void 0 ? void 0 : typeof body === "string" ? encoder$3.encode(body) : body;
|
|
832
|
+
if (streaming) {
|
|
833
|
+
if (bytes !== void 0) sink.chunk(bytes);
|
|
834
|
+
sink.end();
|
|
835
|
+
} else sink.end({
|
|
836
|
+
status,
|
|
837
|
+
headers,
|
|
838
|
+
body: bytes
|
|
839
|
+
});
|
|
840
|
+
emit("close");
|
|
841
|
+
return res;
|
|
842
|
+
},
|
|
843
|
+
destroy: () => {
|
|
844
|
+
if (finished) return;
|
|
845
|
+
finished = true;
|
|
846
|
+
sink.fail(`response destroyed for ${frame.method} ${frame.url}`);
|
|
847
|
+
emit("close");
|
|
848
|
+
},
|
|
849
|
+
on: (event, callback) => {
|
|
850
|
+
const set = listeners.get(event) ?? /* @__PURE__ */ new Set();
|
|
851
|
+
set.add(callback);
|
|
852
|
+
listeners.set(event, set);
|
|
853
|
+
return res;
|
|
854
|
+
},
|
|
855
|
+
off: (event, callback) => {
|
|
856
|
+
listeners.get(event)?.delete(callback);
|
|
857
|
+
return res;
|
|
858
|
+
}
|
|
859
|
+
};
|
|
860
|
+
res.once = res.on;
|
|
861
|
+
Object.defineProperty(res, "headersSent", { get: () => streaming });
|
|
862
|
+
Object.defineProperty(res, "writableEnded", { get: () => finished });
|
|
863
|
+
return {
|
|
864
|
+
req,
|
|
865
|
+
res,
|
|
866
|
+
get aborted() {
|
|
867
|
+
return aborted;
|
|
868
|
+
},
|
|
869
|
+
abort: () => {
|
|
870
|
+
if (finished) return;
|
|
871
|
+
aborted = true;
|
|
872
|
+
finished = true;
|
|
873
|
+
emit("aborted");
|
|
874
|
+
emit("close");
|
|
875
|
+
}
|
|
876
|
+
};
|
|
877
|
+
}
|
|
878
|
+
//#endregion
|
|
879
|
+
//#region src/compile/transform.ts
|
|
880
|
+
/**
|
|
881
|
+
* The worker's module transform: one acorn parse turns an ES module into a
|
|
882
|
+
* CommonJS body **and** routes every suspension point through the ambient-store
|
|
883
|
+
* protocol.
|
|
884
|
+
*
|
|
885
|
+
* Both jobs live in one pass because they are two edits over one syntax tree;
|
|
886
|
+
* running a lexer first and a parser second meant two scanners, two sets of
|
|
887
|
+
* blind spots, and a second pass reading the first pass's output. Editing is
|
|
888
|
+
* interval-based — the original text is sliced and spliced, never reprinted —
|
|
889
|
+
* so **line numbers survive**: a stack frame in a transformed module points at
|
|
890
|
+
* the same line as the built artifact it came from.
|
|
891
|
+
*
|
|
892
|
+
* The image packer is this transform's only caller: it lowers every JavaScript
|
|
893
|
+
* entry it packs and records `LOWERING_VERSION` in the image manifest, so the
|
|
894
|
+
* worker wraps those bodies without carrying a compiler of its own.
|
|
895
|
+
* @module @deepseek-ai/dsh-experimental-webworker-runtime/src/compile/transform
|
|
896
|
+
*/
|
|
897
|
+
const HELPER_SOURCE = {
|
|
898
|
+
def: "const __dsh$def=(t,k,get)=>Object.defineProperty(t,k,{enumerable:true,configurable:true,get});",
|
|
899
|
+
default: "const __dsh$default=(m)=>(m&&m.__esModule?m.default:m);",
|
|
900
|
+
ns: "const __dsh$ns=(m)=>(m&&m.__esModule?m:Object.assign({},m,{default:m}));",
|
|
901
|
+
exportAll: "const __dsh$exportAll=(t,m)=>{for(const k of Object.keys(m))if(k!==\"default\"&&!(k in t))__dsh$def(t,k,()=>m[k]);};",
|
|
902
|
+
dynImport: "const __dsh$dynImport=(s)=>Promise.resolve().then(()=>__dsh$ns(require(s)));"
|
|
903
|
+
};
|
|
904
|
+
const HELPER_DEPENDENCIES = {
|
|
905
|
+
exportAll: ["def"],
|
|
906
|
+
dynImport: ["ns"]
|
|
907
|
+
};
|
|
908
|
+
/** Runtime identifier the suspension protocol reaches. */
|
|
909
|
+
const ALS = "__als";
|
|
910
|
+
/** @returns Number of line breaks in a slice. */
|
|
911
|
+
function countNewlines(text) {
|
|
912
|
+
let count = 0;
|
|
913
|
+
for (let index = text.indexOf("\n"); index >= 0; index = text.indexOf("\n", index + 1)) count += 1;
|
|
914
|
+
return count;
|
|
915
|
+
}
|
|
916
|
+
var Transformer = class {
|
|
917
|
+
path;
|
|
918
|
+
edits = [];
|
|
919
|
+
source;
|
|
920
|
+
helpers = /* @__PURE__ */ new Set();
|
|
921
|
+
bindings = [];
|
|
922
|
+
modules = 0;
|
|
923
|
+
temporaries = 0;
|
|
924
|
+
moduleSyntax = false;
|
|
925
|
+
moduleRequests = /* @__PURE__ */ new Set();
|
|
926
|
+
metaResolveRequests = /* @__PURE__ */ new Set();
|
|
927
|
+
createRequireBindings = /* @__PURE__ */ new Set();
|
|
928
|
+
constructor(source, path) {
|
|
929
|
+
this.path = path;
|
|
930
|
+
this.source = source.startsWith("#!") ? `//${source.slice(2)}` : source;
|
|
931
|
+
}
|
|
932
|
+
fail(detail, index) {
|
|
933
|
+
const line = this.source.slice(0, index).split("\n").length;
|
|
934
|
+
throw new Error(`webworker transform: ${detail} (${this.path}:${line})`);
|
|
935
|
+
}
|
|
936
|
+
helper(name) {
|
|
937
|
+
for (const dependency of HELPER_DEPENDENCIES[name] ?? []) this.helper(dependency);
|
|
938
|
+
this.helpers.add(name);
|
|
939
|
+
return `__dsh$${name}`;
|
|
940
|
+
}
|
|
941
|
+
moduleTemp() {
|
|
942
|
+
this.modules += 1;
|
|
943
|
+
return `__dsh$m${this.modules}`;
|
|
944
|
+
}
|
|
945
|
+
alsTemp() {
|
|
946
|
+
this.temporaries += 1;
|
|
947
|
+
return `__als$${this.temporaries}`;
|
|
948
|
+
}
|
|
949
|
+
/**
|
|
950
|
+
* Replace a range, keeping the module's line count.
|
|
951
|
+
*
|
|
952
|
+
* The padding is the newlines the original range held **minus** the ones the
|
|
953
|
+
* replacement re-emits: a rewrite that splices the original body back in
|
|
954
|
+
* (a desugared loop) already carries that body's newlines, and padding by the
|
|
955
|
+
* whole range again would push every later line down.
|
|
956
|
+
*/
|
|
957
|
+
edit(start, end, build) {
|
|
958
|
+
const original = countNewlines(this.source.slice(start, end));
|
|
959
|
+
this.edits.push({
|
|
960
|
+
start,
|
|
961
|
+
end,
|
|
962
|
+
render: (inner) => {
|
|
963
|
+
const text = build(inner);
|
|
964
|
+
return text + "\n".repeat(Math.max(0, original - countNewlines(text)));
|
|
965
|
+
}
|
|
966
|
+
});
|
|
967
|
+
}
|
|
968
|
+
replace(start, end, text) {
|
|
969
|
+
this.edit(start, end, () => text);
|
|
970
|
+
}
|
|
971
|
+
insert(at, text) {
|
|
972
|
+
this.edits.push({
|
|
973
|
+
start: at,
|
|
974
|
+
end: at,
|
|
975
|
+
render: () => text
|
|
976
|
+
});
|
|
977
|
+
}
|
|
978
|
+
structural(start, end, render) {
|
|
979
|
+
this.edit(start, end, render);
|
|
980
|
+
}
|
|
981
|
+
literal(node) {
|
|
982
|
+
const value = node.value;
|
|
983
|
+
if (typeof value !== "string") this.fail("a module specifier must be a string literal", node.start);
|
|
984
|
+
this.moduleRequests.add(value);
|
|
985
|
+
return JSON.stringify(value);
|
|
986
|
+
}
|
|
987
|
+
/** @returns Static module requests the body makes, in first-appearance order. */
|
|
988
|
+
requests() {
|
|
989
|
+
return [...this.moduleRequests];
|
|
990
|
+
}
|
|
991
|
+
/** @returns Literal `import.meta.resolve()` requests, in first-appearance order. */
|
|
992
|
+
metaRequests() {
|
|
993
|
+
return [...this.metaResolveRequests];
|
|
994
|
+
}
|
|
995
|
+
importDeclaration(node) {
|
|
996
|
+
this.moduleSyntax = true;
|
|
997
|
+
if (Array.isArray(node.attributes) && node.attributes.length > 0) this.fail("import attributes are not supported", node.start);
|
|
998
|
+
const source = node.source;
|
|
999
|
+
const request = `require(${this.literal(source)})`;
|
|
1000
|
+
const specifiers = node.specifiers;
|
|
1001
|
+
if (specifiers.length === 0) {
|
|
1002
|
+
this.replace(node.start, node.end, `${request};`);
|
|
1003
|
+
return;
|
|
1004
|
+
}
|
|
1005
|
+
const held = this.moduleTemp();
|
|
1006
|
+
const lines = [`const ${held}=${request};`];
|
|
1007
|
+
for (const specifier of specifiers) {
|
|
1008
|
+
const local = specifier.local.name;
|
|
1009
|
+
if (specifier.type === "ImportDefaultSpecifier") {
|
|
1010
|
+
lines.push(`const ${local}=${this.helper("default")}(${held});`);
|
|
1011
|
+
continue;
|
|
1012
|
+
}
|
|
1013
|
+
if (specifier.type === "ImportNamespaceSpecifier") {
|
|
1014
|
+
lines.push(`const ${local}=${this.helper("ns")}(${held});`);
|
|
1015
|
+
continue;
|
|
1016
|
+
}
|
|
1017
|
+
const imported = specifier.imported;
|
|
1018
|
+
const name = imported.type === "Identifier" ? imported.name : imported.value;
|
|
1019
|
+
lines.push(`const ${local}=${held}[${JSON.stringify(name)}];`);
|
|
1020
|
+
}
|
|
1021
|
+
this.replace(node.start, node.end, lines.join(""));
|
|
1022
|
+
}
|
|
1023
|
+
exportNamed(node) {
|
|
1024
|
+
this.moduleSyntax = true;
|
|
1025
|
+
const declaration = node.declaration;
|
|
1026
|
+
const source = node.source;
|
|
1027
|
+
const specifiers = node.specifiers;
|
|
1028
|
+
if (declaration !== null) {
|
|
1029
|
+
this.replace(node.start, declaration.start, "");
|
|
1030
|
+
for (const { exported, local } of declaredBindings(declaration, (detail) => this.fail(detail, declaration.start))) this.bindings.push({
|
|
1031
|
+
exported,
|
|
1032
|
+
local
|
|
1033
|
+
});
|
|
1034
|
+
return;
|
|
1035
|
+
}
|
|
1036
|
+
if (source !== null) {
|
|
1037
|
+
const held = this.moduleTemp();
|
|
1038
|
+
const define = this.helper("def");
|
|
1039
|
+
const lines = [`const ${held}=require(${this.literal(source)});`];
|
|
1040
|
+
for (const specifier of specifiers) {
|
|
1041
|
+
const local = nameOf(specifier.local);
|
|
1042
|
+
const exported = nameOf(specifier.exported);
|
|
1043
|
+
lines.push(`${define}(exports,${JSON.stringify(exported)},()=>${held}[${JSON.stringify(local)}]);`);
|
|
1044
|
+
}
|
|
1045
|
+
this.replace(node.start, node.end, lines.join(""));
|
|
1046
|
+
return;
|
|
1047
|
+
}
|
|
1048
|
+
for (const specifier of specifiers) this.bindings.push({
|
|
1049
|
+
exported: nameOf(specifier.exported),
|
|
1050
|
+
local: nameOf(specifier.local)
|
|
1051
|
+
});
|
|
1052
|
+
this.replace(node.start, node.end, "");
|
|
1053
|
+
}
|
|
1054
|
+
exportDefault(node) {
|
|
1055
|
+
this.moduleSyntax = true;
|
|
1056
|
+
const declaration = node.declaration;
|
|
1057
|
+
this.replace(node.start, declaration.start, "exports.default = ");
|
|
1058
|
+
}
|
|
1059
|
+
exportAll(node) {
|
|
1060
|
+
this.moduleSyntax = true;
|
|
1061
|
+
const request = `require(${this.literal(node.source)})`;
|
|
1062
|
+
const exported = node.exported;
|
|
1063
|
+
if (exported === null) {
|
|
1064
|
+
this.replace(node.start, node.end, `${this.helper("exportAll")}(exports,${request});`);
|
|
1065
|
+
return;
|
|
1066
|
+
}
|
|
1067
|
+
const held = this.moduleTemp();
|
|
1068
|
+
const define = this.helper("def");
|
|
1069
|
+
this.replace(node.start, node.end, `const ${held}=${this.helper("ns")}(${request});${define}(exports,${JSON.stringify(nameOf(exported))},()=>${held});`);
|
|
1070
|
+
}
|
|
1071
|
+
awaitExpression(node) {
|
|
1072
|
+
const keywordEnd = node.start + 5;
|
|
1073
|
+
if (this.source.slice(node.start, keywordEnd) !== "await") this.fail("unexpected await layout", node.start);
|
|
1074
|
+
this.replace(node.start, keywordEnd, `${ALS}.resume(await ${ALS}.pause(`);
|
|
1075
|
+
this.insert(node.end, "))");
|
|
1076
|
+
}
|
|
1077
|
+
/**
|
|
1078
|
+
* `for await (L of R) B` becomes an explicit loop over the same protocol.
|
|
1079
|
+
* `iterator.return` runs only on abrupt completion, as the language says, and
|
|
1080
|
+
* is awaited so teardown still orders before the loop exits.
|
|
1081
|
+
*/
|
|
1082
|
+
forAwait(node) {
|
|
1083
|
+
const left = node.left;
|
|
1084
|
+
const right = node.right;
|
|
1085
|
+
const body = node.body;
|
|
1086
|
+
const iterator = this.alsTemp();
|
|
1087
|
+
const step = this.alsTemp();
|
|
1088
|
+
const exhausted = this.alsTemp();
|
|
1089
|
+
const binding = (inner) => {
|
|
1090
|
+
if (left.type !== "VariableDeclaration") return `(${inner(left.start, left.end)})=${step}.value;`;
|
|
1091
|
+
const declarations = left.declarations;
|
|
1092
|
+
const pattern = declarations[0]?.id;
|
|
1093
|
+
if (declarations.length !== 1 || pattern === void 0) this.fail("for-await must declare exactly one binding", left.start);
|
|
1094
|
+
return `${String(left.kind)} ${inner(pattern.start, pattern.end)}=${step}.value;`;
|
|
1095
|
+
};
|
|
1096
|
+
this.structural(node.start, node.end, (inner) => [
|
|
1097
|
+
`{const ${iterator}=${ALS}.iterator(${inner(right.start, right.end)});`,
|
|
1098
|
+
`let ${step};let ${exhausted}=false;`,
|
|
1099
|
+
`try{for(;;){${step}=${ALS}.resume(await ${ALS}.pause(${iterator}.next()));`,
|
|
1100
|
+
`if(${step}.done){${exhausted}=true;break}`,
|
|
1101
|
+
`{${binding(inner)}${body.type === "BlockStatement" ? inner(body.start, body.end) : `{${inner(body.start, body.end)}}`}}}}`,
|
|
1102
|
+
`finally{if(!${exhausted})${ALS}.resume(await ${ALS}.pause(${ALS}.close(${iterator})))}}`
|
|
1103
|
+
].join(""));
|
|
1104
|
+
}
|
|
1105
|
+
/**
|
|
1106
|
+
* `yield` resumes with whatever the consumer sent, so the snapshot is taken
|
|
1107
|
+
* before suspending and restored when the call completes. `yield*` delegates,
|
|
1108
|
+
* which has no expression form here: it is desugared as a statement, and a
|
|
1109
|
+
* consumer's `throw()` is not forwarded into the inner iterator (`next` and
|
|
1110
|
+
* `return` are).
|
|
1111
|
+
*/
|
|
1112
|
+
yieldExpression(node, statement) {
|
|
1113
|
+
if (node.delegate !== true) {
|
|
1114
|
+
this.insert(node.start, `${ALS}.afterYield(${ALS}.snapshot(),`);
|
|
1115
|
+
this.insert(node.end, ")");
|
|
1116
|
+
return;
|
|
1117
|
+
}
|
|
1118
|
+
const argument = node.argument;
|
|
1119
|
+
if (argument === null) this.fail("yield* without an operand", node.start);
|
|
1120
|
+
if (statement === void 0) this.fail("yield* is only supported as a statement", node.start);
|
|
1121
|
+
if (statement.expression !== node) this.fail("yield* is only supported as the whole statement expression", node.start);
|
|
1122
|
+
const iterator = this.alsTemp();
|
|
1123
|
+
const step = this.alsTemp();
|
|
1124
|
+
const sent = this.alsTemp();
|
|
1125
|
+
const exhausted = this.alsTemp();
|
|
1126
|
+
this.structural(statement.start, statement.end, (inner) => [
|
|
1127
|
+
`{const ${iterator}=${ALS}.iterator(${inner(argument.start, argument.end)});`,
|
|
1128
|
+
`let ${sent};let ${exhausted}=false;`,
|
|
1129
|
+
`try{for(;;){const ${step}=${ALS}.resume(await ${ALS}.pause(${iterator}.next(${sent})));`,
|
|
1130
|
+
`if(${step}.done){${exhausted}=true;break}`,
|
|
1131
|
+
`${sent}=${ALS}.afterYield(${ALS}.snapshot(),yield ${step}.value)}}`,
|
|
1132
|
+
`finally{if(!${exhausted})${ALS}.resume(await ${ALS}.pause(${ALS}.close(${iterator})))}}`
|
|
1133
|
+
].join(""));
|
|
1134
|
+
}
|
|
1135
|
+
visit(node, context) {
|
|
1136
|
+
if (node === null || typeof node !== "object") return;
|
|
1137
|
+
if (Array.isArray(node)) {
|
|
1138
|
+
for (const child of node) this.visit(child, context);
|
|
1139
|
+
return;
|
|
1140
|
+
}
|
|
1141
|
+
const record = node;
|
|
1142
|
+
if (typeof record.type !== "string") return;
|
|
1143
|
+
let next = context;
|
|
1144
|
+
switch (record.type) {
|
|
1145
|
+
case "ImportDeclaration":
|
|
1146
|
+
this.importDeclaration(record);
|
|
1147
|
+
break;
|
|
1148
|
+
case "ExportNamedDeclaration":
|
|
1149
|
+
this.exportNamed(record);
|
|
1150
|
+
break;
|
|
1151
|
+
case "ExportDefaultDeclaration":
|
|
1152
|
+
this.exportDefault(record);
|
|
1153
|
+
break;
|
|
1154
|
+
case "ExportAllDeclaration":
|
|
1155
|
+
this.exportAll(record);
|
|
1156
|
+
break;
|
|
1157
|
+
case "ImportExpression": {
|
|
1158
|
+
this.moduleSyntax = true;
|
|
1159
|
+
if (!this.source.startsWith("import", record.start)) this.fail("unexpected dynamic import layout", record.start);
|
|
1160
|
+
this.replace(record.start, record.start + 6, this.helper("dynImport"));
|
|
1161
|
+
const argument = record.source;
|
|
1162
|
+
if (argument !== void 0 && typeof argument.value === "string") this.moduleRequests.add(argument.value);
|
|
1163
|
+
break;
|
|
1164
|
+
}
|
|
1165
|
+
case "CallExpression": {
|
|
1166
|
+
const callee = record.callee;
|
|
1167
|
+
const callArguments = record.arguments;
|
|
1168
|
+
if (this.isRequireCall(callee, context.moduleScope) && callArguments.length === 1 && typeof callArguments[0]?.value === "string") this.moduleRequests.add(callArguments[0].value);
|
|
1169
|
+
if (callee.type === "MemberExpression") {
|
|
1170
|
+
const object = callee.object;
|
|
1171
|
+
const property = callee.property;
|
|
1172
|
+
if (object.type === "MetaProperty" && object.meta.name === "import" && property.type === "Identifier" && property.name === "resolve" && typeof callArguments[0]?.value === "string") this.metaResolveRequests.add(callArguments[0].value);
|
|
1173
|
+
}
|
|
1174
|
+
break;
|
|
1175
|
+
}
|
|
1176
|
+
case "MetaProperty":
|
|
1177
|
+
if (record.meta.name === "import") {
|
|
1178
|
+
this.moduleSyntax = true;
|
|
1179
|
+
this.replace(record.start, record.end, "__dsh$meta");
|
|
1180
|
+
}
|
|
1181
|
+
break;
|
|
1182
|
+
case "AwaitExpression":
|
|
1183
|
+
if (context.functionDepth === 0) this.fail("top-level await cannot run as CommonJS in the worker", record.start);
|
|
1184
|
+
this.awaitExpression(record);
|
|
1185
|
+
break;
|
|
1186
|
+
case "ForOfStatement":
|
|
1187
|
+
if (record.await === true) {
|
|
1188
|
+
if (context.functionDepth === 0) this.fail("a top-level for-await loop cannot run as CommonJS", record.start);
|
|
1189
|
+
this.forAwait(record);
|
|
1190
|
+
}
|
|
1191
|
+
next = {
|
|
1192
|
+
...next,
|
|
1193
|
+
moduleScope: false
|
|
1194
|
+
};
|
|
1195
|
+
break;
|
|
1196
|
+
case "LabeledStatement": {
|
|
1197
|
+
const body = record.body;
|
|
1198
|
+
if (body.type === "ForOfStatement" && body.await === true) this.fail("a labeled for-await loop is not supported", record.start);
|
|
1199
|
+
break;
|
|
1200
|
+
}
|
|
1201
|
+
case "YieldExpression":
|
|
1202
|
+
if (context.asyncGenerator) this.yieldExpression(record, context.statement);
|
|
1203
|
+
break;
|
|
1204
|
+
case "FunctionDeclaration":
|
|
1205
|
+
case "FunctionExpression":
|
|
1206
|
+
case "ArrowFunctionExpression":
|
|
1207
|
+
next = {
|
|
1208
|
+
asyncGenerator: record.async === true && record.generator === true,
|
|
1209
|
+
functionDepth: context.functionDepth + 1,
|
|
1210
|
+
moduleScope: false
|
|
1211
|
+
};
|
|
1212
|
+
break;
|
|
1213
|
+
case "BlockStatement":
|
|
1214
|
+
case "CatchClause":
|
|
1215
|
+
case "ClassBody":
|
|
1216
|
+
case "ForStatement":
|
|
1217
|
+
case "ForInStatement":
|
|
1218
|
+
case "SwitchStatement":
|
|
1219
|
+
next = {
|
|
1220
|
+
...next,
|
|
1221
|
+
moduleScope: false
|
|
1222
|
+
};
|
|
1223
|
+
break;
|
|
1224
|
+
default: break;
|
|
1225
|
+
}
|
|
1226
|
+
if (record.type === "ExpressionStatement") next = {
|
|
1227
|
+
...next,
|
|
1228
|
+
statement: record
|
|
1229
|
+
};
|
|
1230
|
+
for (const [key, value] of Object.entries(record)) {
|
|
1231
|
+
if (key === "type" || key === "start" || key === "end") continue;
|
|
1232
|
+
this.visit(value, next);
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
isCreateRequireCall(node) {
|
|
1236
|
+
if (node.type !== "CallExpression") return false;
|
|
1237
|
+
const callee = node.callee;
|
|
1238
|
+
const args = node.arguments;
|
|
1239
|
+
if (callee.type !== "Identifier" || !this.createRequireBindings.has(nameOf(callee)) || args.length !== 1) return false;
|
|
1240
|
+
const base = args[0];
|
|
1241
|
+
if (base.type !== "MemberExpression" || base.computed === true) return false;
|
|
1242
|
+
const object = base.object;
|
|
1243
|
+
const property = base.property;
|
|
1244
|
+
return object.type === "MetaProperty" && object.meta.name === "import" && property.type === "Identifier" && property.name === "url";
|
|
1245
|
+
}
|
|
1246
|
+
isRequireCall(callee, moduleScope) {
|
|
1247
|
+
return callee.type === "Identifier" && callee.name === "require" || moduleScope && this.isCreateRequireCall(callee);
|
|
1248
|
+
}
|
|
1249
|
+
indexCreateRequireImports(program) {
|
|
1250
|
+
for (const statement of program.body) {
|
|
1251
|
+
if (statement.type !== "ImportDeclaration") continue;
|
|
1252
|
+
const source = statement.source;
|
|
1253
|
+
if (source.value !== "node:module" && source.value !== "module") continue;
|
|
1254
|
+
for (const specifier of statement.specifiers) {
|
|
1255
|
+
if (specifier.type !== "ImportSpecifier" || nameOf(specifier.imported) !== "createRequire") continue;
|
|
1256
|
+
this.createRequireBindings.add(nameOf(specifier.local));
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
run() {
|
|
1261
|
+
if (this.source.includes(`${ALS}.pause(`) || this.source.includes("__als$")) this.fail("the module is already lowered; check the image manifest wiring", 0);
|
|
1262
|
+
let program;
|
|
1263
|
+
try {
|
|
1264
|
+
program = parse(this.source, {
|
|
1265
|
+
ecmaVersion: "latest",
|
|
1266
|
+
sourceType: "module",
|
|
1267
|
+
allowAwaitOutsideFunction: true
|
|
1268
|
+
});
|
|
1269
|
+
} catch (reason) {
|
|
1270
|
+
this.fail(`parse failed: ${reason.message}`, 0);
|
|
1271
|
+
}
|
|
1272
|
+
this.indexCreateRequireImports(program);
|
|
1273
|
+
this.visit(program, {
|
|
1274
|
+
asyncGenerator: false,
|
|
1275
|
+
functionDepth: 0,
|
|
1276
|
+
moduleScope: true
|
|
1277
|
+
});
|
|
1278
|
+
if (this.edits.length === 0 && !this.moduleSyntax) return this.source;
|
|
1279
|
+
const prologue = [];
|
|
1280
|
+
if (this.moduleSyntax) prologue.push("\"use strict\";Object.defineProperty(exports,\"__esModule\",{value:true});");
|
|
1281
|
+
if (this.bindings.length > 0) this.helper("def");
|
|
1282
|
+
for (const [name, source] of Object.entries(HELPER_SOURCE)) if (this.helpers.has(name)) prologue.push(source);
|
|
1283
|
+
for (const { exported, local } of this.bindings) prologue.push(`__dsh$def(exports,${JSON.stringify(exported)},()=>${local});`);
|
|
1284
|
+
const sorted = [...this.edits].sort((left, right) => left.start - right.start || left.end - right.end);
|
|
1285
|
+
const render = (from, to) => {
|
|
1286
|
+
let cursor = from;
|
|
1287
|
+
let out = "";
|
|
1288
|
+
for (const edit of sorted) {
|
|
1289
|
+
if (edit.start < cursor || edit.end > to) continue;
|
|
1290
|
+
out += this.source.slice(cursor, edit.start) + edit.render(render);
|
|
1291
|
+
cursor = edit.end;
|
|
1292
|
+
}
|
|
1293
|
+
return out + this.source.slice(cursor, to);
|
|
1294
|
+
};
|
|
1295
|
+
const code = prologue.join("") + render(0, this.source.length);
|
|
1296
|
+
try {
|
|
1297
|
+
parse(code, {
|
|
1298
|
+
ecmaVersion: "latest",
|
|
1299
|
+
sourceType: "script",
|
|
1300
|
+
allowAwaitOutsideFunction: false
|
|
1301
|
+
});
|
|
1302
|
+
} catch (reason) {
|
|
1303
|
+
this.fail(`the transform produced code that does not parse: ${reason.message}`, 0);
|
|
1304
|
+
}
|
|
1305
|
+
return code;
|
|
1306
|
+
}
|
|
1307
|
+
};
|
|
1308
|
+
/** @returns The name a specifier or identifier node carries. */
|
|
1309
|
+
function nameOf(node) {
|
|
1310
|
+
return node.type === "Identifier" ? node.name : String(node.value);
|
|
1311
|
+
}
|
|
1312
|
+
/** Every binding an exported declaration introduces, including patterns. */
|
|
1313
|
+
function declaredBindings(declaration, fail) {
|
|
1314
|
+
if (declaration.type === "FunctionDeclaration" || declaration.type === "ClassDeclaration") {
|
|
1315
|
+
const id = declaration.id;
|
|
1316
|
+
if (id === null) fail("an exported declaration must be named");
|
|
1317
|
+
const name = id.name;
|
|
1318
|
+
return [{
|
|
1319
|
+
exported: name,
|
|
1320
|
+
local: name
|
|
1321
|
+
}];
|
|
1322
|
+
}
|
|
1323
|
+
if (declaration.type !== "VariableDeclaration") fail(`unsupported exported declaration ${declaration.type}`);
|
|
1324
|
+
const bindings = [];
|
|
1325
|
+
const collect = (pattern) => {
|
|
1326
|
+
switch (pattern.type) {
|
|
1327
|
+
case "Identifier":
|
|
1328
|
+
bindings.push({
|
|
1329
|
+
exported: pattern.name,
|
|
1330
|
+
local: pattern.name
|
|
1331
|
+
});
|
|
1332
|
+
return;
|
|
1333
|
+
case "ObjectPattern":
|
|
1334
|
+
for (const property of pattern.properties) collect(property.type === "RestElement" ? property.argument : property.value);
|
|
1335
|
+
return;
|
|
1336
|
+
case "ArrayPattern":
|
|
1337
|
+
for (const element of pattern.elements) if (element !== null) collect(element);
|
|
1338
|
+
return;
|
|
1339
|
+
case "AssignmentPattern":
|
|
1340
|
+
collect(pattern.left);
|
|
1341
|
+
return;
|
|
1342
|
+
case "RestElement":
|
|
1343
|
+
collect(pattern.argument);
|
|
1344
|
+
return;
|
|
1345
|
+
default: fail(`unsupported binding pattern ${pattern.type}`);
|
|
1346
|
+
}
|
|
1347
|
+
};
|
|
1348
|
+
for (const declarator of declaration.declarations) collect(declarator.id);
|
|
1349
|
+
return bindings;
|
|
1350
|
+
}
|
|
1351
|
+
const cache = /* @__PURE__ */ new Map();
|
|
1352
|
+
/**
|
|
1353
|
+
* Transform one module into a body for the worker wrapper.
|
|
1354
|
+
*
|
|
1355
|
+
* Results are cached by source text, so a module reached through two paths, or
|
|
1356
|
+
* a repeated build, parses once.
|
|
1357
|
+
* @param source - Module source, ESM or CommonJS.
|
|
1358
|
+
* @param path - Path used in diagnostics.
|
|
1359
|
+
* @returns The lowered body and the module requests found in it.
|
|
1360
|
+
*/
|
|
1361
|
+
function transformDetailed(source, path) {
|
|
1362
|
+
const cached = cache.get(source);
|
|
1363
|
+
if (cached !== void 0) return cached;
|
|
1364
|
+
const transformer = new Transformer(source, path);
|
|
1365
|
+
const transformed = {
|
|
1366
|
+
code: transformer.run(),
|
|
1367
|
+
moduleRequests: transformer.requests(),
|
|
1368
|
+
metaResolveRequests: transformer.metaRequests()
|
|
1369
|
+
};
|
|
1370
|
+
cache.set(source, transformed);
|
|
1371
|
+
return transformed;
|
|
1372
|
+
}
|
|
1373
|
+
/**
|
|
1374
|
+
* Lower one module at image-pack time.
|
|
1375
|
+
*
|
|
1376
|
+
* The collector calls this for every JavaScript entry it packs and records
|
|
1377
|
+
* `LOWERING_VERSION` in the image manifest; the loader then wraps those entries
|
|
1378
|
+
* without parsing them. `lowered: false` reports that the transform would have
|
|
1379
|
+
* returned the input verbatim (already CommonJS, no suspension point), so the
|
|
1380
|
+
* entry may be packed as it is.
|
|
1381
|
+
*
|
|
1382
|
+
* Throwing is the intended failure mode: a module this transform cannot express
|
|
1383
|
+
* must fail the build rather than ship an image that breaks at load.
|
|
1384
|
+
* @param options - Virtual path inside the image and the module source.
|
|
1385
|
+
* @returns The code to pack and whether it changed.
|
|
1386
|
+
*/
|
|
1387
|
+
function lowerModuleSource(options) {
|
|
1388
|
+
const { code, moduleRequests, metaResolveRequests } = transformDetailed(options.source, options.filename);
|
|
1389
|
+
return {
|
|
1390
|
+
code,
|
|
1391
|
+
lowered: code !== options.source,
|
|
1392
|
+
moduleRequests,
|
|
1393
|
+
metaResolveRequests
|
|
1394
|
+
};
|
|
1395
|
+
}
|
|
1396
|
+
//#endregion
|
|
1397
|
+
//#region src/transport/tunnel.ts
|
|
1398
|
+
/**
|
|
1399
|
+
* Worker end of the postMessage tunnel. It owns the dispatch lanes and the queue
|
|
1400
|
+
* that holds requests until the host tree is serving:
|
|
1401
|
+
*
|
|
1402
|
+
* - `GET /__boot__` answers from tunnel glue, never from the host API surface,
|
|
1403
|
+
* because the page needs the boot payload before its Cordis tree exists.
|
|
1404
|
+
* - Privileged `/api` methods take that same direct entry. The method set is not
|
|
1405
|
+
* restated here: a 401 or 403 from the route lane is retried on the direct
|
|
1406
|
+
* lane because the page owns the worker and needs no network authentication.
|
|
1407
|
+
* - Everything else is fed into the real webserver route table through the
|
|
1408
|
+
* request listener the app's fake `node:http` captured, keeping the trust
|
|
1409
|
+
* fences, byte limits, and status semantics intact.
|
|
1410
|
+
*
|
|
1411
|
+
* A boot failure rejects the whole queue with 503 rather than leaving the page
|
|
1412
|
+
* waiting.
|
|
1413
|
+
* @module @deepseek-ai/dsh-experimental-webworker-runtime/src/transport/tunnel
|
|
1414
|
+
*/
|
|
1415
|
+
/** Prefix owning the API methods. */
|
|
1416
|
+
const API_PREFIX = "/api";
|
|
1417
|
+
/** Host header the synthesized requests carry; the API trust fence requires one. */
|
|
1418
|
+
const SYNTHETIC_HOST = "127.0.0.1";
|
|
1419
|
+
const encoder$2 = new TextEncoder();
|
|
1420
|
+
/**
|
|
1421
|
+
* Render a failure with everything nested inside it.
|
|
1422
|
+
*
|
|
1423
|
+
* A boot failure is usually an `AggregateError` of per-entry failures, each
|
|
1424
|
+
* wrapping the plugin's own error as `cause`; only the outermost message names
|
|
1425
|
+
* "loader entries failed to apply", which says nothing about which row broke.
|
|
1426
|
+
*
|
|
1427
|
+
* The page logs the rendered text verbatim for refusals; keep it stable for
|
|
1428
|
+
* anyone matching boot-failure output.
|
|
1429
|
+
* @param reason - Thrown value.
|
|
1430
|
+
* @returns One line per nested failure, indented by depth.
|
|
1431
|
+
*/
|
|
1432
|
+
function describeFailure(reason) {
|
|
1433
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1434
|
+
const lines = [];
|
|
1435
|
+
const walk = (value, depth) => {
|
|
1436
|
+
if (value === null || value === void 0 || seen.has(value) || depth > 6) return;
|
|
1437
|
+
seen.add(value);
|
|
1438
|
+
const indent = " ".repeat(depth);
|
|
1439
|
+
if (!(value instanceof Error)) {
|
|
1440
|
+
const rendered = typeof value === "string" ? value : JSON.stringify(value);
|
|
1441
|
+
lines.push(`${indent}${rendered ?? typeof value}`);
|
|
1442
|
+
return;
|
|
1443
|
+
}
|
|
1444
|
+
lines.push(`${indent}${value.name}: ${value.message}`);
|
|
1445
|
+
if (value instanceof AggregateError) for (const inner of value.errors) walk(inner, depth + 1);
|
|
1446
|
+
walk(value.cause, depth + 1);
|
|
1447
|
+
};
|
|
1448
|
+
walk(reason, 0);
|
|
1449
|
+
return lines.join("\n");
|
|
1450
|
+
}
|
|
1451
|
+
/**
|
|
1452
|
+
* Copy bytes into an exact-size ArrayBuffer so it can be transferred.
|
|
1453
|
+
*
|
|
1454
|
+
* Sliced on the ArrayBuffer, not the view: `Uint8Array.prototype.slice` copies,
|
|
1455
|
+
* but a Node-style Buffer overrides `slice()` with view semantics, and the fs
|
|
1456
|
+
* bridge hands VFS reads over as Buffer views into the whole mounted image —
|
|
1457
|
+
* `bytes.slice().buffer` would then post the entire image as the body.
|
|
1458
|
+
*/
|
|
1459
|
+
function toTransferable(bytes) {
|
|
1460
|
+
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
|
|
1461
|
+
}
|
|
1462
|
+
/** Recorded response frames, so a route-lane authentication refusal can be discarded. */
|
|
1463
|
+
var BufferedSink = class {
|
|
1464
|
+
calls = [];
|
|
1465
|
+
target;
|
|
1466
|
+
settle;
|
|
1467
|
+
/** Resolves when the exchange either starts streaming or answers in one frame. */
|
|
1468
|
+
settled = new Promise((resolve) => {
|
|
1469
|
+
this.settle = resolve;
|
|
1470
|
+
});
|
|
1471
|
+
sink = {
|
|
1472
|
+
head: (status, headers) => {
|
|
1473
|
+
this.record(() => {
|
|
1474
|
+
this.target?.head(status, headers);
|
|
1475
|
+
});
|
|
1476
|
+
this.settle?.({
|
|
1477
|
+
streamed: true,
|
|
1478
|
+
status
|
|
1479
|
+
});
|
|
1480
|
+
},
|
|
1481
|
+
chunk: (bytes) => {
|
|
1482
|
+
this.record(() => {
|
|
1483
|
+
this.target?.chunk(bytes);
|
|
1484
|
+
});
|
|
1485
|
+
},
|
|
1486
|
+
end: (payload) => {
|
|
1487
|
+
this.record(() => {
|
|
1488
|
+
this.target?.end(payload);
|
|
1489
|
+
});
|
|
1490
|
+
this.settle?.({
|
|
1491
|
+
streamed: payload === void 0,
|
|
1492
|
+
status: payload?.status ?? 200
|
|
1493
|
+
});
|
|
1494
|
+
},
|
|
1495
|
+
fail: (message) => {
|
|
1496
|
+
this.record(() => {
|
|
1497
|
+
this.target?.fail(message);
|
|
1498
|
+
});
|
|
1499
|
+
this.settle?.({
|
|
1500
|
+
streamed: true,
|
|
1501
|
+
status: 500
|
|
1502
|
+
});
|
|
1503
|
+
}
|
|
1504
|
+
};
|
|
1505
|
+
record(call) {
|
|
1506
|
+
if (this.target === void 0) this.calls.push(call);
|
|
1507
|
+
else call();
|
|
1508
|
+
}
|
|
1509
|
+
/**
|
|
1510
|
+
* Send everything recorded so far to a real sink and pass later calls through.
|
|
1511
|
+
* @param target - Sink receiving the frames.
|
|
1512
|
+
*/
|
|
1513
|
+
flushTo(target) {
|
|
1514
|
+
this.target = target;
|
|
1515
|
+
for (const call of this.calls.splice(0)) call();
|
|
1516
|
+
}
|
|
1517
|
+
};
|
|
1518
|
+
/** One tunnel per worker; wire {@link TunnelServer.handleMessage} to `onmessage` first. */
|
|
1519
|
+
var TunnelServer = class {
|
|
1520
|
+
port;
|
|
1521
|
+
requestListener;
|
|
1522
|
+
privilegedMethods;
|
|
1523
|
+
unaryApiLane;
|
|
1524
|
+
queue = [];
|
|
1525
|
+
inFlight = /* @__PURE__ */ new Map();
|
|
1526
|
+
seams;
|
|
1527
|
+
failure;
|
|
1528
|
+
listener;
|
|
1529
|
+
constructor(options) {
|
|
1530
|
+
this.port = options.port;
|
|
1531
|
+
this.requestListener = options.requestListener;
|
|
1532
|
+
this.privilegedMethods = options.privilegedMethods;
|
|
1533
|
+
this.unaryApiLane = options.unaryApiLane ?? "route";
|
|
1534
|
+
}
|
|
1535
|
+
/**
|
|
1536
|
+
* Accept one `postMessage` payload.
|
|
1537
|
+
* @param data - Message data from the page.
|
|
1538
|
+
*/
|
|
1539
|
+
handleMessage(data) {
|
|
1540
|
+
const frame = parseInboundFrame(data);
|
|
1541
|
+
if (frame.t === "init") throw new Error("webworker tunnel: duplicate init frame; the tunnel is already open");
|
|
1542
|
+
if (frame.t === "abort") {
|
|
1543
|
+
this.inFlight.get(frame.id)?.abort();
|
|
1544
|
+
this.inFlight.delete(frame.id);
|
|
1545
|
+
const queued = this.queue.findIndex((request) => request.id === frame.id);
|
|
1546
|
+
if (queued !== -1) this.queue.splice(queued, 1);
|
|
1547
|
+
return;
|
|
1548
|
+
}
|
|
1549
|
+
if (this.failure !== void 0) {
|
|
1550
|
+
this.refuse(frame, this.failure);
|
|
1551
|
+
return;
|
|
1552
|
+
}
|
|
1553
|
+
if (this.seams === void 0) {
|
|
1554
|
+
this.queue.push(frame);
|
|
1555
|
+
return;
|
|
1556
|
+
}
|
|
1557
|
+
this.dispatchFrame(frame);
|
|
1558
|
+
}
|
|
1559
|
+
/**
|
|
1560
|
+
* Start serving: drains everything queued during boot.
|
|
1561
|
+
* @param seams - Faces that exist only after the host tree is up.
|
|
1562
|
+
*/
|
|
1563
|
+
serve(seams) {
|
|
1564
|
+
this.seams = seams;
|
|
1565
|
+
console.info(`webworker tunnel: serving (unary /api lane=${this.unaryApiLane}${this.unaryApiLane === "route" ? " with 401/403 retry" : ""}, privileged set=${this.privilegedMethods === void 0 ? "none" : String(this.privilegedMethods.size)}, queued=${String(this.queue.length)})`);
|
|
1566
|
+
for (const frame of this.queue.splice(0)) this.dispatchFrame(frame);
|
|
1567
|
+
}
|
|
1568
|
+
/**
|
|
1569
|
+
* Refuse every queued and future request; the page renders this like a server
|
|
1570
|
+
* that failed to start.
|
|
1571
|
+
* @param reason - Boot failure to report.
|
|
1572
|
+
*/
|
|
1573
|
+
fail(reason) {
|
|
1574
|
+
const message = describeFailure(reason);
|
|
1575
|
+
this.failure = message;
|
|
1576
|
+
for (const frame of this.queue.splice(0)) this.refuse(frame, message);
|
|
1577
|
+
}
|
|
1578
|
+
send(frame, transfer) {
|
|
1579
|
+
this.port.postMessage(frame, transfer);
|
|
1580
|
+
}
|
|
1581
|
+
refuse(frame, message) {
|
|
1582
|
+
if (frame.t === "stream-open") {
|
|
1583
|
+
this.send({
|
|
1584
|
+
t: "stream-error",
|
|
1585
|
+
id: frame.id,
|
|
1586
|
+
failure: {
|
|
1587
|
+
kind: "carrier",
|
|
1588
|
+
message
|
|
1589
|
+
}
|
|
1590
|
+
});
|
|
1591
|
+
return;
|
|
1592
|
+
}
|
|
1593
|
+
const body = toTransferable(encoder$2.encode(message));
|
|
1594
|
+
this.send({
|
|
1595
|
+
t: "res",
|
|
1596
|
+
id: frame.id,
|
|
1597
|
+
status: 503,
|
|
1598
|
+
headers: { "content-type": "text/plain; charset=utf-8" },
|
|
1599
|
+
body,
|
|
1600
|
+
message
|
|
1601
|
+
}, [body]);
|
|
1602
|
+
}
|
|
1603
|
+
dispatchFrame(frame) {
|
|
1604
|
+
if (frame.t === "stream-open") this.serveStream(frame);
|
|
1605
|
+
else this.serveRequest(frame);
|
|
1606
|
+
}
|
|
1607
|
+
async serveStream(frame) {
|
|
1608
|
+
if (this.seams === void 0) {
|
|
1609
|
+
this.refuse(frame, "webworker tunnel: Remote stream requested before the host tree is serving");
|
|
1610
|
+
return;
|
|
1611
|
+
}
|
|
1612
|
+
const seams = this.seams;
|
|
1613
|
+
const controller = new AbortController();
|
|
1614
|
+
this.inFlight.set(frame.id, { abort: () => {
|
|
1615
|
+
controller.abort();
|
|
1616
|
+
} });
|
|
1617
|
+
try {
|
|
1618
|
+
const source = await seams.openStream(frame.endpoint, frame.payload, controller.signal);
|
|
1619
|
+
for await (const value of source) {
|
|
1620
|
+
if (controller.signal.aborted) return;
|
|
1621
|
+
this.send({
|
|
1622
|
+
t: "stream-item",
|
|
1623
|
+
id: frame.id,
|
|
1624
|
+
value
|
|
1625
|
+
});
|
|
1626
|
+
}
|
|
1627
|
+
if (!controller.signal.aborted) this.send({
|
|
1628
|
+
t: "stream-end",
|
|
1629
|
+
id: frame.id
|
|
1630
|
+
});
|
|
1631
|
+
} catch (error) {
|
|
1632
|
+
if (!controller.signal.aborted) {
|
|
1633
|
+
const failure = seams.streamFailure(error);
|
|
1634
|
+
this.send({
|
|
1635
|
+
t: "stream-error",
|
|
1636
|
+
id: frame.id,
|
|
1637
|
+
failure: {
|
|
1638
|
+
kind: "remote",
|
|
1639
|
+
...failure
|
|
1640
|
+
}
|
|
1641
|
+
});
|
|
1642
|
+
}
|
|
1643
|
+
} finally {
|
|
1644
|
+
this.inFlight.delete(frame.id);
|
|
1645
|
+
}
|
|
1646
|
+
}
|
|
1647
|
+
sinkFor(id) {
|
|
1648
|
+
const send = this.send.bind(this);
|
|
1649
|
+
const inFlight = this.inFlight;
|
|
1650
|
+
return {
|
|
1651
|
+
head(status, headers) {
|
|
1652
|
+
send({
|
|
1653
|
+
t: "res-head",
|
|
1654
|
+
id,
|
|
1655
|
+
status,
|
|
1656
|
+
headers
|
|
1657
|
+
});
|
|
1658
|
+
},
|
|
1659
|
+
chunk(bytes) {
|
|
1660
|
+
const buffer = toTransferable(bytes);
|
|
1661
|
+
send({
|
|
1662
|
+
t: "res-chunk",
|
|
1663
|
+
id,
|
|
1664
|
+
chunk: buffer
|
|
1665
|
+
}, [buffer]);
|
|
1666
|
+
},
|
|
1667
|
+
end(payload) {
|
|
1668
|
+
if (payload === void 0) send({
|
|
1669
|
+
t: "res-end",
|
|
1670
|
+
id
|
|
1671
|
+
});
|
|
1672
|
+
else {
|
|
1673
|
+
const body = payload.body === void 0 ? void 0 : toTransferable(payload.body);
|
|
1674
|
+
send({
|
|
1675
|
+
t: "res",
|
|
1676
|
+
id,
|
|
1677
|
+
status: payload.status,
|
|
1678
|
+
headers: payload.headers,
|
|
1679
|
+
body
|
|
1680
|
+
}, body === void 0 ? void 0 : [body]);
|
|
1681
|
+
}
|
|
1682
|
+
inFlight.delete(id);
|
|
1683
|
+
},
|
|
1684
|
+
fail(message) {
|
|
1685
|
+
send({
|
|
1686
|
+
t: "res-err",
|
|
1687
|
+
id,
|
|
1688
|
+
message
|
|
1689
|
+
});
|
|
1690
|
+
inFlight.delete(id);
|
|
1691
|
+
}
|
|
1692
|
+
};
|
|
1693
|
+
}
|
|
1694
|
+
/** The page sends an absolute URL; route handlers read `req.url` as a path. */
|
|
1695
|
+
pathFrame(frame) {
|
|
1696
|
+
const url = new URL(frame.url, `http://${SYNTHETIC_HOST}`);
|
|
1697
|
+
return {
|
|
1698
|
+
frame: {
|
|
1699
|
+
...frame,
|
|
1700
|
+
url: `${url.pathname}${url.search}`,
|
|
1701
|
+
headers: {
|
|
1702
|
+
...frame.headers,
|
|
1703
|
+
host: SYNTHETIC_HOST
|
|
1704
|
+
}
|
|
1705
|
+
},
|
|
1706
|
+
path: url.pathname
|
|
1707
|
+
};
|
|
1708
|
+
}
|
|
1709
|
+
async serveRequest(frame) {
|
|
1710
|
+
const sink = this.sinkFor(frame.id);
|
|
1711
|
+
try {
|
|
1712
|
+
const { frame: routed, path } = this.pathFrame(frame);
|
|
1713
|
+
if (path === "/__boot__") {
|
|
1714
|
+
this.serveBoot(frame, sink);
|
|
1715
|
+
return;
|
|
1716
|
+
}
|
|
1717
|
+
if (path.startsWith(`/api/`)) {
|
|
1718
|
+
await this.serveApi(frame, routed, path, sink);
|
|
1719
|
+
return;
|
|
1720
|
+
}
|
|
1721
|
+
this.dispatch(routed, sink);
|
|
1722
|
+
} catch (reason) {
|
|
1723
|
+
sink.fail(reason instanceof Error ? reason.message : String(reason));
|
|
1724
|
+
}
|
|
1725
|
+
}
|
|
1726
|
+
/**
|
|
1727
|
+
* The listener is captured once and reused, so only requests that arrive
|
|
1728
|
+
* before the web server binds pay an await.
|
|
1729
|
+
* @returns The webserver request listener.
|
|
1730
|
+
*/
|
|
1731
|
+
async whenListener() {
|
|
1732
|
+
this.listener ??= await this.requestListener();
|
|
1733
|
+
return this.listener;
|
|
1734
|
+
}
|
|
1735
|
+
/** Feed the real route table through the captured listener. */
|
|
1736
|
+
dispatch(frame, sink, into) {
|
|
1737
|
+
const exchange = createSyntheticExchange(frame, into ?? sink);
|
|
1738
|
+
this.inFlight.set(frame.id, exchange);
|
|
1739
|
+
const listener = this.listener;
|
|
1740
|
+
if (listener !== void 0) {
|
|
1741
|
+
listener(exchange.req, exchange.res);
|
|
1742
|
+
return exchange;
|
|
1743
|
+
}
|
|
1744
|
+
this.whenListener().then((resolved) => {
|
|
1745
|
+
if (!exchange.aborted) resolved(exchange.req, exchange.res);
|
|
1746
|
+
}, (reason) => {
|
|
1747
|
+
sink.fail(reason instanceof Error ? reason.message : String(reason));
|
|
1748
|
+
});
|
|
1749
|
+
return exchange;
|
|
1750
|
+
}
|
|
1751
|
+
/**
|
|
1752
|
+
* Unary `/api`: keep the route lane's fences, but fall back to the direct lane
|
|
1753
|
+
* when network authentication or trust rejects the worker-owning page.
|
|
1754
|
+
*/
|
|
1755
|
+
async serveApi(original, routed, path, sink) {
|
|
1756
|
+
const method = path.slice(5);
|
|
1757
|
+
if (this.unaryApiLane === "direct" || this.privilegedMethods?.has(method) === true) {
|
|
1758
|
+
await this.serveDirect(original, sink);
|
|
1759
|
+
return;
|
|
1760
|
+
}
|
|
1761
|
+
const buffered = new BufferedSink();
|
|
1762
|
+
const exchange = this.dispatch(routed, sink, buffered.sink);
|
|
1763
|
+
let settleAborted = () => {};
|
|
1764
|
+
const aborted = new Promise((resolve) => {
|
|
1765
|
+
settleAborted = () => {
|
|
1766
|
+
resolve("aborted");
|
|
1767
|
+
};
|
|
1768
|
+
});
|
|
1769
|
+
this.inFlight.set(routed.id, { abort: () => {
|
|
1770
|
+
exchange.abort();
|
|
1771
|
+
settleAborted();
|
|
1772
|
+
} });
|
|
1773
|
+
const outcome = await Promise.race([buffered.settled, aborted]);
|
|
1774
|
+
if (outcome === "aborted" || exchange.aborted) return;
|
|
1775
|
+
if (outcome.status === 401 || outcome.status === 403) {
|
|
1776
|
+
console.debug(`webworker tunnel: route lane refused ${method} with ${String(outcome.status)}; answering on the direct lane`);
|
|
1777
|
+
await this.serveDirect(original, sink);
|
|
1778
|
+
return;
|
|
1779
|
+
}
|
|
1780
|
+
buffered.flushTo(sink);
|
|
1781
|
+
}
|
|
1782
|
+
serveBoot(frame, sink) {
|
|
1783
|
+
if (this.seams === void 0) throw new Error("webworker tunnel: boot payload requested before the host tree is serving");
|
|
1784
|
+
if (frame.method !== "GET") {
|
|
1785
|
+
sink.end({
|
|
1786
|
+
status: 405,
|
|
1787
|
+
headers: { allow: "GET" }
|
|
1788
|
+
});
|
|
1789
|
+
return;
|
|
1790
|
+
}
|
|
1791
|
+
const body = encoder$2.encode(JSON.stringify(this.seams.bootPayload()));
|
|
1792
|
+
sink.end({
|
|
1793
|
+
status: 200,
|
|
1794
|
+
headers: {
|
|
1795
|
+
"content-type": "application/json; charset=utf-8",
|
|
1796
|
+
"cache-control": "no-store"
|
|
1797
|
+
},
|
|
1798
|
+
body
|
|
1799
|
+
});
|
|
1800
|
+
}
|
|
1801
|
+
async serveDirect(frame, sink) {
|
|
1802
|
+
if (this.seams === void 0) throw new Error("webworker tunnel: direct fetch requested before the host tree is serving");
|
|
1803
|
+
const controller = new AbortController();
|
|
1804
|
+
this.inFlight.set(frame.id, { abort: () => {
|
|
1805
|
+
controller.abort();
|
|
1806
|
+
} });
|
|
1807
|
+
const headers = new Headers();
|
|
1808
|
+
for (const [key, value] of Object.entries(frame.headers)) try {
|
|
1809
|
+
headers.set(key, value);
|
|
1810
|
+
} catch {}
|
|
1811
|
+
const request = new Request(new URL(frame.url, `http://${SYNTHETIC_HOST}`), {
|
|
1812
|
+
method: frame.method,
|
|
1813
|
+
headers,
|
|
1814
|
+
body: frame.body === void 0 || frame.method === "GET" || frame.method === "HEAD" ? null : frame.body,
|
|
1815
|
+
signal: controller.signal
|
|
1816
|
+
});
|
|
1817
|
+
const response = await this.seams.directFetch(request);
|
|
1818
|
+
const responseHeaders = {};
|
|
1819
|
+
response.headers.forEach((value, key) => {
|
|
1820
|
+
responseHeaders[key] = value;
|
|
1821
|
+
});
|
|
1822
|
+
if (!(response.body !== null && (responseHeaders["content-type"] ?? "").startsWith("text/event-stream"))) {
|
|
1823
|
+
const buffer = await response.arrayBuffer();
|
|
1824
|
+
sink.end({
|
|
1825
|
+
status: response.status,
|
|
1826
|
+
headers: responseHeaders,
|
|
1827
|
+
body: buffer.byteLength === 0 ? void 0 : new Uint8Array(buffer)
|
|
1828
|
+
});
|
|
1829
|
+
return;
|
|
1830
|
+
}
|
|
1831
|
+
sink.head(response.status, responseHeaders);
|
|
1832
|
+
const reader = response.body.getReader();
|
|
1833
|
+
this.inFlight.set(frame.id, { abort: () => {
|
|
1834
|
+
controller.abort();
|
|
1835
|
+
reader.cancel().catch(() => {});
|
|
1836
|
+
} });
|
|
1837
|
+
try {
|
|
1838
|
+
for (;;) {
|
|
1839
|
+
const { done, value } = await reader.read();
|
|
1840
|
+
if (done) break;
|
|
1841
|
+
sink.chunk(value);
|
|
1842
|
+
}
|
|
1843
|
+
sink.end();
|
|
1844
|
+
} catch (reason) {
|
|
1845
|
+
sink.fail(reason instanceof Error ? reason.message : String(reason));
|
|
1846
|
+
} finally {
|
|
1847
|
+
reader.releaseLock();
|
|
1848
|
+
}
|
|
1849
|
+
}
|
|
1850
|
+
};
|
|
1851
|
+
//#endregion
|
|
1852
|
+
//#region src/node/process-table.ts
|
|
1853
|
+
const entries = /* @__PURE__ */ new Map();
|
|
1854
|
+
/**
|
|
1855
|
+
* Whether a command with this pid is still running.
|
|
1856
|
+
* @param pid - pid to look up; a negative value addresses the group, which here
|
|
1857
|
+
* holds exactly the one process that leads it.
|
|
1858
|
+
* @returns true while the entry is in the table.
|
|
1859
|
+
*/
|
|
1860
|
+
function processAlive(pid) {
|
|
1861
|
+
return entries.has(Math.abs(pid));
|
|
1862
|
+
}
|
|
1863
|
+
/**
|
|
1864
|
+
* Deliver a signal to one running command.
|
|
1865
|
+
*
|
|
1866
|
+
* `SIGKILL` stops the command whatever it is doing; every other signal asks it
|
|
1867
|
+
* to stop at its next command boundary. That distinction is real only for a
|
|
1868
|
+
* worker-backed process — see {@link RunningProcess.destroy}.
|
|
1869
|
+
* @param pid - pid or negative process-group id.
|
|
1870
|
+
* @param signal - the signal name to record and deliver.
|
|
1871
|
+
* @returns true when an entry received it, false when no such process exists.
|
|
1872
|
+
*/
|
|
1873
|
+
function signalProcess(pid, signal) {
|
|
1874
|
+
const entry = entries.get(Math.abs(pid));
|
|
1875
|
+
if (entry === void 0) return false;
|
|
1876
|
+
entry.signal ??= signal;
|
|
1877
|
+
if (signal === "SIGKILL") entry.process?.destroy();
|
|
1878
|
+
else entry.process?.interrupt();
|
|
1879
|
+
return true;
|
|
1880
|
+
}
|
|
1881
|
+
//#endregion
|
|
1882
|
+
//#region src/node/globals/process.ts
|
|
1883
|
+
/**
|
|
1884
|
+
* The `process` global the worker needs before any VFS module runs. Cordis
|
|
1885
|
+
* reads `process.env` and `process.versions.node` while the Loader is
|
|
1886
|
+
* constructed, and `cordis.yml` keeps its `!!js process.*` expressions, so the
|
|
1887
|
+
* configuration bytes stay identical to the Node deployment. Third-party Node
|
|
1888
|
+
* packages use the presence of `process.title` to avoid browser-only globals.
|
|
1889
|
+
* @module @deepseek-ai/dsh-experimental-webworker-runtime/src/node/globals/process
|
|
1890
|
+
*/
|
|
1891
|
+
/**
|
|
1892
|
+
* Publish `globalThis.process`.
|
|
1893
|
+
*
|
|
1894
|
+
* `versions.node` is `0.0.0` on purpose: it makes Cordis's
|
|
1895
|
+
* `ModuleLoader.fromInternal()` return undefined instead of reaching for Node
|
|
1896
|
+
* internals, which is what lets the worker install its own module seam.
|
|
1897
|
+
* @param options - Root, environment, and argument vector.
|
|
1898
|
+
* @returns The published object, for the module proxy table.
|
|
1899
|
+
*/
|
|
1900
|
+
function installProcessGlobal(options) {
|
|
1901
|
+
const start = performance.now();
|
|
1902
|
+
const write = (target) => (chunk) => {
|
|
1903
|
+
console[target](chunk.replace(/\n$/, ""));
|
|
1904
|
+
return true;
|
|
1905
|
+
};
|
|
1906
|
+
const shim = {
|
|
1907
|
+
env: { ...options.env },
|
|
1908
|
+
argv: [...options.argv ?? ["node", "dsh-webworker"]],
|
|
1909
|
+
execArgv: [],
|
|
1910
|
+
execPath: "/dsh/bin/node",
|
|
1911
|
+
title: "dsh-webworker",
|
|
1912
|
+
platform: "linux",
|
|
1913
|
+
arch: "x64",
|
|
1914
|
+
pid: 1,
|
|
1915
|
+
version: "v0.0.0",
|
|
1916
|
+
versions: { node: "0.0.0" },
|
|
1917
|
+
cwd: () => options.cwd,
|
|
1918
|
+
getBuiltinModule: (id) => {
|
|
1919
|
+
let resolution;
|
|
1920
|
+
try {
|
|
1921
|
+
resolution = requireActiveModuleLoader().resolve(id, "/");
|
|
1922
|
+
} catch {
|
|
1923
|
+
return;
|
|
1924
|
+
}
|
|
1925
|
+
return resolution.kind === "static" ? resolution.factory() : void 0;
|
|
1926
|
+
},
|
|
1927
|
+
kill: (pid, signal = "SIGTERM") => {
|
|
1928
|
+
if (signal === 0) {
|
|
1929
|
+
if (processAlive(pid)) return true;
|
|
1930
|
+
const error = /* @__PURE__ */ new Error("kill ESRCH");
|
|
1931
|
+
error.code = "ESRCH";
|
|
1932
|
+
error.syscall = "kill";
|
|
1933
|
+
throw error;
|
|
1934
|
+
}
|
|
1935
|
+
return signalProcess(pid, signal);
|
|
1936
|
+
},
|
|
1937
|
+
nextTick: (callback, ...args) => {
|
|
1938
|
+
queueMicrotask(() => {
|
|
1939
|
+
callback(...args);
|
|
1940
|
+
});
|
|
1941
|
+
},
|
|
1942
|
+
stdout: { write: write("log") },
|
|
1943
|
+
stderr: { write: write("error") },
|
|
1944
|
+
on: () => shim,
|
|
1945
|
+
off: () => shim,
|
|
1946
|
+
once: () => shim,
|
|
1947
|
+
prependListener: () => shim,
|
|
1948
|
+
prependOnceListener: () => shim,
|
|
1949
|
+
removeListener: () => shim,
|
|
1950
|
+
removeAllListeners: () => shim,
|
|
1951
|
+
listeners: () => [],
|
|
1952
|
+
listenerCount: () => 0,
|
|
1953
|
+
setMaxListeners: () => shim,
|
|
1954
|
+
emit: () => false,
|
|
1955
|
+
hrtime: { bigint: () => BigInt(Math.round((performance.now() - start) * 1e6)) },
|
|
1956
|
+
uptime: () => (performance.now() - start) / 1e3,
|
|
1957
|
+
exit: (code) => {
|
|
1958
|
+
console.warn(`webworker process: exit(${String(code ?? 0)}) requested; the worker keeps running`);
|
|
1959
|
+
}
|
|
1960
|
+
};
|
|
1961
|
+
globalThis.process = shim;
|
|
1962
|
+
return shim;
|
|
1963
|
+
}
|
|
1964
|
+
//#endregion
|
|
1965
|
+
//#region src/storage/image-gzip.ts
|
|
1966
|
+
/**
|
|
1967
|
+
* The image byte envelope. The packer writes one gzip member holding the ustar
|
|
1968
|
+
* archive, and the worker inflates it with the platform's own decompressor before
|
|
1969
|
+
* the tar reader sees a byte — `storage/tar.ts` stays a pure ustar reader with no
|
|
1970
|
+
* codec in it.
|
|
1971
|
+
*
|
|
1972
|
+
* Inflation runs on the fetch stream rather than on downloaded bytes: the
|
|
1973
|
+
* decompressor consumes each chunk as it lands, so unpacking overlaps the
|
|
1974
|
+
* download instead of following it, and the compressed copy never has to be held
|
|
1975
|
+
* whole in memory beside the archive it produces.
|
|
1976
|
+
*
|
|
1977
|
+
* One format, no negotiation: a body that does not start a gzip member is refused
|
|
1978
|
+
* by name, in the stream, before the decompressor sees it. Without that check a
|
|
1979
|
+
* plain tar, a truncated download, or a proxy's HTML error page would reach
|
|
1980
|
+
* `parseTar` and fail as a corrupt header field, which says nothing about what
|
|
1981
|
+
* the deployment actually served.
|
|
1982
|
+
* @module @deepseek-ai/dsh-experimental-webworker-runtime/src/storage/image-gzip
|
|
1983
|
+
*/
|
|
1984
|
+
/** gzip member identification bytes (RFC 1952 §2.3.1). */
|
|
1985
|
+
const GZIP_MAGIC = [31, 139];
|
|
1986
|
+
/** Bytes of a refused body quoted in the failure, enough to recognize text served in its place. */
|
|
1987
|
+
const QUOTED_BYTES = 8;
|
|
1988
|
+
const hex = (bytes) => [...bytes.slice(0, QUOTED_BYTES)].map((byte) => byte.toString(16).padStart(2, "0")).join(" ");
|
|
1989
|
+
/**
|
|
1990
|
+
* A pass-through that refuses a body which is not a gzip member.
|
|
1991
|
+
*
|
|
1992
|
+
* The check spans chunks: a transport may deliver the first byte alone, so the
|
|
1993
|
+
* head is held until it can be judged and then forwarded intact. A body that ends
|
|
1994
|
+
* before two bytes arrive is refused in `flush`, where "too short" is the only
|
|
1995
|
+
* thing left to report.
|
|
1996
|
+
* @param source - the image URL, or how the bytes arrived; named in a refusal.
|
|
1997
|
+
* @returns The transform to pipe the body through before the decompressor.
|
|
1998
|
+
*/
|
|
1999
|
+
function requireGzipMember(source) {
|
|
2000
|
+
let head = new Uint8Array(0);
|
|
2001
|
+
let judged = false;
|
|
2002
|
+
const refuse = (read) => /* @__PURE__ */ new Error(`webworker image: ${source} is not the gzip-compressed tar this deployment serves as its image (expected a member starting 1f 8b, read ${read.byteLength === 0 ? "an empty body" : hex(read)}); a host that answered with a Content-Encoding the transport already decoded, or a build that wrote the archive uncompressed, arrives exactly this way`);
|
|
2003
|
+
return new TransformStream({
|
|
2004
|
+
transform: (chunk, controller) => {
|
|
2005
|
+
if (judged) {
|
|
2006
|
+
controller.enqueue(chunk);
|
|
2007
|
+
return;
|
|
2008
|
+
}
|
|
2009
|
+
const merged = new Uint8Array(head.byteLength + chunk.byteLength);
|
|
2010
|
+
merged.set(head);
|
|
2011
|
+
merged.set(chunk, head.byteLength);
|
|
2012
|
+
head = merged;
|
|
2013
|
+
if (head.byteLength < GZIP_MAGIC.length) return;
|
|
2014
|
+
if (GZIP_MAGIC.some((byte, at) => head[at] !== byte)) throw refuse(head);
|
|
2015
|
+
judged = true;
|
|
2016
|
+
controller.enqueue(head);
|
|
2017
|
+
},
|
|
2018
|
+
flush: () => {
|
|
2019
|
+
if (!judged) throw refuse(head);
|
|
2020
|
+
}
|
|
2021
|
+
});
|
|
2022
|
+
}
|
|
2023
|
+
/**
|
|
2024
|
+
* Inflate a packed VFS image as it arrives.
|
|
2025
|
+
* @param body - the image body, straight from `fetch` or wrapped around bytes.
|
|
2026
|
+
* @param source - the image URL, or how the bytes arrived; named in a refusal.
|
|
2027
|
+
* @returns the ustar archive the image carries.
|
|
2028
|
+
* @throws When the body does not start a gzip member, or the member is corrupt.
|
|
2029
|
+
*/
|
|
2030
|
+
async function inflateImageStream(body, source) {
|
|
2031
|
+
const inflated = body.pipeThrough(requireGzipMember(source)).pipeThrough(new DecompressionStream("gzip"));
|
|
2032
|
+
return new Uint8Array(await new Response(inflated).arrayBuffer());
|
|
2033
|
+
}
|
|
2034
|
+
/**
|
|
2035
|
+
* Inflate a packed VFS image held in memory.
|
|
2036
|
+
*
|
|
2037
|
+
* The bytes become a body so both entries run the same stream: one decompression
|
|
2038
|
+
* path, one refusal, whether the image came off the network or out of a caller's
|
|
2039
|
+
* buffer.
|
|
2040
|
+
* @param bytes - the image bytes.
|
|
2041
|
+
* @param source - how the bytes arrived; named in a refusal.
|
|
2042
|
+
* @returns the ustar archive the image carries.
|
|
2043
|
+
* @throws When the bytes do not start a gzip member, or the member is corrupt.
|
|
2044
|
+
*/
|
|
2045
|
+
async function inflateImage(bytes, source) {
|
|
2046
|
+
const body = new Response(bytes).body;
|
|
2047
|
+
if (body === null) throw new Error(`webworker image: ${source} produced no readable body`);
|
|
2048
|
+
return await inflateImageStream(body, source);
|
|
2049
|
+
}
|
|
2050
|
+
//#endregion
|
|
2051
|
+
//#region src/storage/tar.ts
|
|
2052
|
+
/**
|
|
2053
|
+
* Uncompressed ustar archive: the VFS image format. One fetch delivers the
|
|
2054
|
+
* whole tree, and the reader hands out subarray views into the fetched buffer,
|
|
2055
|
+
* so mounting copies nothing and no inflate step runs inside the worker.
|
|
2056
|
+
*
|
|
2057
|
+
* Hand-rolled on purpose: both sides need synchronous in-memory operation and
|
|
2058
|
+
* the reader ships inside the worker bundle, where the streaming tar packages
|
|
2059
|
+
* would drag Node stream shims back in. The subset is plain ustar — regular
|
|
2060
|
+
* files and directories, names up to 255 bytes via the name-prefix split — and
|
|
2061
|
+
* anything outside it fails loud on either side.
|
|
2062
|
+
* @module @deepseek-ai/dsh-experimental-webworker-runtime/src/storage/tar
|
|
2063
|
+
*/
|
|
2064
|
+
const encoder$1 = new TextEncoder();
|
|
2065
|
+
const decoder$1 = new TextDecoder();
|
|
2066
|
+
const BLOCK = 512;
|
|
2067
|
+
/** Write an octal field: zero-padded digits with a terminating NUL. */
|
|
2068
|
+
function writeOctal(header, offset, length, value) {
|
|
2069
|
+
header.set(encoder$1.encode(value.toString(8).padStart(length - 1, "0")), offset);
|
|
2070
|
+
}
|
|
2071
|
+
/**
|
|
2072
|
+
* Split an entry name into the ustar name and prefix fields.
|
|
2073
|
+
* @param name - Full entry name.
|
|
2074
|
+
* @returns The two fields; the prefix is empty when the name fits directly.
|
|
2075
|
+
* @throws When no slash yields name ≤ 100 and prefix ≤ 155 bytes: the entry
|
|
2076
|
+
* cannot be archived and a silently truncated name would corrupt the image.
|
|
2077
|
+
*/
|
|
2078
|
+
function splitName(name) {
|
|
2079
|
+
if (encoder$1.encode(name).length <= 100) return {
|
|
2080
|
+
name,
|
|
2081
|
+
prefix: ""
|
|
2082
|
+
};
|
|
2083
|
+
for (let index = name.length - 1; index > 0; index -= 1) {
|
|
2084
|
+
if (name[index] !== "/") continue;
|
|
2085
|
+
const prefix = name.slice(0, index);
|
|
2086
|
+
const rest = name.slice(index + 1);
|
|
2087
|
+
if (encoder$1.encode(rest).length <= 100 && encoder$1.encode(prefix).length <= 155) return {
|
|
2088
|
+
name: rest,
|
|
2089
|
+
prefix
|
|
2090
|
+
};
|
|
2091
|
+
}
|
|
2092
|
+
throw new Error(`vfs tar: entry name does not fit the ustar name+prefix split: ${name}`);
|
|
2093
|
+
}
|
|
2094
|
+
/**
|
|
2095
|
+
* Pack entries into one uncompressed ustar archive.
|
|
2096
|
+
*
|
|
2097
|
+
* Entries keep their given order; names ending in a slash become directory
|
|
2098
|
+
* entries. Contents are written verbatim — compression belongs to the HTTP
|
|
2099
|
+
* transport, not to the archive.
|
|
2100
|
+
* @param files - Entry name to content bytes.
|
|
2101
|
+
* @returns The archive bytes.
|
|
2102
|
+
*/
|
|
2103
|
+
function packTar(files) {
|
|
2104
|
+
const chunks = [];
|
|
2105
|
+
for (const [entryName, bytes] of Object.entries(files)) {
|
|
2106
|
+
const directory = entryName.endsWith("/");
|
|
2107
|
+
const size = directory ? 0 : bytes.length;
|
|
2108
|
+
const { name, prefix } = splitName(entryName);
|
|
2109
|
+
const header = new Uint8Array(BLOCK);
|
|
2110
|
+
header.set(encoder$1.encode(name), 0);
|
|
2111
|
+
writeOctal(header, 100, 8, directory ? 493 : 420);
|
|
2112
|
+
writeOctal(header, 108, 8, 0);
|
|
2113
|
+
writeOctal(header, 116, 8, 0);
|
|
2114
|
+
writeOctal(header, 124, 12, size);
|
|
2115
|
+
writeOctal(header, 136, 12, 0);
|
|
2116
|
+
header.fill(32, 148, 156);
|
|
2117
|
+
header[156] = directory ? 53 : 48;
|
|
2118
|
+
header.set(encoder$1.encode("ustar"), 257);
|
|
2119
|
+
header.set(encoder$1.encode("00"), 263);
|
|
2120
|
+
header.set(encoder$1.encode(prefix), 345);
|
|
2121
|
+
let checksum = 0;
|
|
2122
|
+
for (const byte of header) checksum += byte;
|
|
2123
|
+
header.set(encoder$1.encode(checksum.toString(8).padStart(6, "0")), 148);
|
|
2124
|
+
header[154] = 0;
|
|
2125
|
+
header[155] = 32;
|
|
2126
|
+
chunks.push(header);
|
|
2127
|
+
if (size > 0) {
|
|
2128
|
+
chunks.push(bytes);
|
|
2129
|
+
const padding = size % BLOCK;
|
|
2130
|
+
if (padding !== 0) chunks.push(new Uint8Array(BLOCK - padding));
|
|
2131
|
+
}
|
|
2132
|
+
}
|
|
2133
|
+
chunks.push(new Uint8Array(BLOCK * 2));
|
|
2134
|
+
const total = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
|
|
2135
|
+
const archive = new Uint8Array(total);
|
|
2136
|
+
let offset = 0;
|
|
2137
|
+
for (const chunk of chunks) {
|
|
2138
|
+
archive.set(chunk, offset);
|
|
2139
|
+
offset += chunk.length;
|
|
2140
|
+
}
|
|
2141
|
+
return archive;
|
|
2142
|
+
}
|
|
2143
|
+
/** @returns The NUL-terminated string in one header field. */
|
|
2144
|
+
function readField(header, offset, length) {
|
|
2145
|
+
let end = offset;
|
|
2146
|
+
while (end < offset + length && header[end] !== 0) end += 1;
|
|
2147
|
+
return decoder$1.decode(header.subarray(offset, end));
|
|
2148
|
+
}
|
|
2149
|
+
/**
|
|
2150
|
+
* Parse an uncompressed ustar archive.
|
|
2151
|
+
*
|
|
2152
|
+
* File bytes are subarray views into `archive`, not copies; callers own the
|
|
2153
|
+
* aliasing. Entry kinds outside the written subset (links, PAX extensions)
|
|
2154
|
+
* fail loud instead of being skipped.
|
|
2155
|
+
* @param archive - Archive bytes.
|
|
2156
|
+
* @returns Entries in archive order.
|
|
2157
|
+
*/
|
|
2158
|
+
function parseTar(archive) {
|
|
2159
|
+
const entries = [];
|
|
2160
|
+
let offset = 0;
|
|
2161
|
+
while (offset + BLOCK <= archive.length) {
|
|
2162
|
+
const header = archive.subarray(offset, offset + BLOCK);
|
|
2163
|
+
if (header.every((byte) => byte === 0)) break;
|
|
2164
|
+
const short = readField(header, 0, 100);
|
|
2165
|
+
const prefix = readField(header, 345, 155);
|
|
2166
|
+
const name = prefix === "" ? short : `${prefix}/${short}`;
|
|
2167
|
+
const size = Number.parseInt(readField(header, 124, 12).trim() || "0", 8);
|
|
2168
|
+
const mode = Number.parseInt(readField(header, 100, 8).trim() || "0", 8) & 511;
|
|
2169
|
+
const typeflag = header[156];
|
|
2170
|
+
const directory = typeflag === 53 || name.endsWith("/");
|
|
2171
|
+
if (typeflag !== 48 && typeflag !== 0 && typeflag !== 53) throw new Error(`vfs tar: unsupported entry type ${String.fromCharCode(typeflag ?? 0)} for "${name}"`);
|
|
2172
|
+
const dataStart = offset + BLOCK;
|
|
2173
|
+
entries.push({
|
|
2174
|
+
name,
|
|
2175
|
+
bytes: archive.subarray(dataStart, dataStart + size),
|
|
2176
|
+
directory,
|
|
2177
|
+
mode
|
|
2178
|
+
});
|
|
2179
|
+
offset = dataStart + Math.ceil(size / BLOCK) * BLOCK;
|
|
2180
|
+
}
|
|
2181
|
+
return entries;
|
|
2182
|
+
}
|
|
2183
|
+
//#endregion
|
|
2184
|
+
//#region src/storage/memory.ts
|
|
2185
|
+
/**
|
|
2186
|
+
* In-memory filesystem behind the worker's `node:fs` proxy. Contents come from
|
|
2187
|
+
* the build-time image (see {@link loadVfsImage}); this remains the synchronous
|
|
2188
|
+
* authority when an asynchronous durable sink mirrors selected subtrees.
|
|
2189
|
+
* @module @deepseek-ai/dsh-experimental-webworker-runtime/src/storage/memory
|
|
2190
|
+
*/
|
|
2191
|
+
const decoder = new TextDecoder();
|
|
2192
|
+
const encoder = new TextEncoder();
|
|
2193
|
+
/** Creation default for files, Node's `0o666` under the classic `022` umask. */
|
|
2194
|
+
const DEFAULT_FILE_MODE = 420;
|
|
2195
|
+
/** Creation default for directories, Node's `0o777` under the classic `022` umask. */
|
|
2196
|
+
const DEFAULT_DIRECTORY_MODE = 493;
|
|
2197
|
+
function fail(code, syscall, path, detail) {
|
|
2198
|
+
const error = /* @__PURE__ */ new Error(`${code}: ${detail ?? syscall} failed, ${syscall} '${path}'`);
|
|
2199
|
+
error.code = code;
|
|
2200
|
+
error.path = path;
|
|
2201
|
+
error.syscall = syscall;
|
|
2202
|
+
throw error;
|
|
2203
|
+
}
|
|
2204
|
+
function encodingOf(options) {
|
|
2205
|
+
if (options === null || options === void 0) return void 0;
|
|
2206
|
+
if (typeof options === "string") return options;
|
|
2207
|
+
return options.encoding ?? void 0;
|
|
2208
|
+
}
|
|
2209
|
+
function statsOf(size, mtimeMs, directory, ino, mode) {
|
|
2210
|
+
return {
|
|
2211
|
+
size,
|
|
2212
|
+
ino: Number(ino),
|
|
2213
|
+
mtimeMs,
|
|
2214
|
+
ctimeMs: mtimeMs,
|
|
2215
|
+
atimeMs: mtimeMs,
|
|
2216
|
+
birthtimeMs: mtimeMs,
|
|
2217
|
+
mtime: new Date(mtimeMs),
|
|
2218
|
+
mode: (directory ? 16384 : 32768) | mode & 511,
|
|
2219
|
+
isFile: () => !directory,
|
|
2220
|
+
isDirectory: () => directory,
|
|
2221
|
+
isSymbolicLink: () => false,
|
|
2222
|
+
isFIFO: () => false,
|
|
2223
|
+
isSocket: () => false,
|
|
2224
|
+
isBlockDevice: () => false,
|
|
2225
|
+
isCharacterDevice: () => false
|
|
2226
|
+
};
|
|
2227
|
+
}
|
|
2228
|
+
/**
|
|
2229
|
+
* The same entry as {@link statsOf}, in the BigInt shape.
|
|
2230
|
+
*
|
|
2231
|
+
* Timestamps carry millisecond resolution scaled to nanoseconds, which is what
|
|
2232
|
+
* the underlying `mtimeMs` holds; the VFS keeps that value strictly increasing
|
|
2233
|
+
* per entry so two writes inside one millisecond still differ.
|
|
2234
|
+
* @param size - Byte length; zero for a directory.
|
|
2235
|
+
* @param mtimeMs - Modification time the entry carries.
|
|
2236
|
+
* @param directory - Whether the entry is a directory.
|
|
2237
|
+
* @param ino - Identity of the entry at this path.
|
|
2238
|
+
* @param mode - Stored permission bits of the entry.
|
|
2239
|
+
* @returns Stats in the shape Node returns under `{ bigint: true }`.
|
|
2240
|
+
*/
|
|
2241
|
+
function bigIntStatsOf(size, mtimeMs, directory, ino, mode, nlink = 1) {
|
|
2242
|
+
const milliseconds = BigInt(Math.trunc(mtimeMs));
|
|
2243
|
+
const nanoseconds = milliseconds * 1000000n;
|
|
2244
|
+
const time = new Date(mtimeMs);
|
|
2245
|
+
return {
|
|
2246
|
+
size: BigInt(size),
|
|
2247
|
+
mode: BigInt((directory ? 16384 : 32768) | mode & 511),
|
|
2248
|
+
dev: 1n,
|
|
2249
|
+
ino,
|
|
2250
|
+
nlink: BigInt(nlink),
|
|
2251
|
+
mtimeMs: milliseconds,
|
|
2252
|
+
mtimeNs: nanoseconds,
|
|
2253
|
+
ctimeMs: milliseconds,
|
|
2254
|
+
ctimeNs: nanoseconds,
|
|
2255
|
+
atimeMs: milliseconds,
|
|
2256
|
+
atimeNs: nanoseconds,
|
|
2257
|
+
birthtimeMs: milliseconds,
|
|
2258
|
+
birthtimeNs: nanoseconds,
|
|
2259
|
+
mtime: time,
|
|
2260
|
+
ctime: time,
|
|
2261
|
+
atime: time,
|
|
2262
|
+
birthtime: time,
|
|
2263
|
+
isFile: () => !directory,
|
|
2264
|
+
isDirectory: () => directory,
|
|
2265
|
+
isSymbolicLink: () => false,
|
|
2266
|
+
isFIFO: () => false,
|
|
2267
|
+
isSocket: () => false,
|
|
2268
|
+
isBlockDevice: () => false,
|
|
2269
|
+
isCharacterDevice: () => false
|
|
2270
|
+
};
|
|
2271
|
+
}
|
|
2272
|
+
/** Parse the Node string flags supported by the compatibility filesystem. */
|
|
2273
|
+
function openMode(flags) {
|
|
2274
|
+
const base = flags[0];
|
|
2275
|
+
const suffix = flags.slice(1).split("");
|
|
2276
|
+
const validSuffix = suffix.every((flag) => flag === "+" || flag === "x" || flag === "s");
|
|
2277
|
+
const uniqueSuffix = new Set(suffix).size === suffix.length;
|
|
2278
|
+
if (base !== "r" && base !== "w" && base !== "a" || !validSuffix || !uniqueSuffix || base === "r" && flags.includes("x")) {
|
|
2279
|
+
const error = /* @__PURE__ */ new TypeError(`The argument 'flags' is invalid. Received '${flags}'`);
|
|
2280
|
+
error.code = "ERR_INVALID_ARG_VALUE";
|
|
2281
|
+
throw error;
|
|
2282
|
+
}
|
|
2283
|
+
return {
|
|
2284
|
+
readable: base === "r" || flags.includes("+"),
|
|
2285
|
+
writable: base !== "r" || flags.includes("+"),
|
|
2286
|
+
append: base === "a",
|
|
2287
|
+
create: base === "w" || base === "a",
|
|
2288
|
+
truncate: base === "w",
|
|
2289
|
+
exclusive: flags.includes("x")
|
|
2290
|
+
};
|
|
2291
|
+
}
|
|
2292
|
+
/** Resize bytes exactly, preserving the prefix and zero-filling growth. */
|
|
2293
|
+
function resize(bytes, length) {
|
|
2294
|
+
if (!Number.isSafeInteger(length) || length < 0) {
|
|
2295
|
+
const error = /* @__PURE__ */ new RangeError(`The value of "len" is out of range. It must be >= 0. Received ${String(length)}`);
|
|
2296
|
+
error.code = "ERR_OUT_OF_RANGE";
|
|
2297
|
+
throw error;
|
|
2298
|
+
}
|
|
2299
|
+
const resized = new Uint8Array(length);
|
|
2300
|
+
resized.set(bytes.subarray(0, length));
|
|
2301
|
+
return resized;
|
|
2302
|
+
}
|
|
2303
|
+
/**
|
|
2304
|
+
* Filesystem held in two maps: one for file bytes, one for directories.
|
|
2305
|
+
* Every path is normalized to an absolute POSIX path without a trailing
|
|
2306
|
+
* separator, so callers may pass either form.
|
|
2307
|
+
*/
|
|
2308
|
+
var MemoryVfs = class {
|
|
2309
|
+
files = /* @__PURE__ */ new Map();
|
|
2310
|
+
directories = new Set(["/"]);
|
|
2311
|
+
/** Directory permission bits; absence means {@link DEFAULT_DIRECTORY_MODE}. */
|
|
2312
|
+
directoryModes = /* @__PURE__ */ new Map();
|
|
2313
|
+
/** Directory mtimes advance when their immediate entry set changes. */
|
|
2314
|
+
directoryMtimes = /* @__PURE__ */ new Map();
|
|
2315
|
+
mutationListeners = /* @__PURE__ */ new Set();
|
|
2316
|
+
sink;
|
|
2317
|
+
temporaries = 0;
|
|
2318
|
+
identities = /* @__PURE__ */ new Map();
|
|
2319
|
+
lastIdentity = 0n;
|
|
2320
|
+
/**
|
|
2321
|
+
* Build the synchronous filesystem authority.
|
|
2322
|
+
* @param options - Optional durable write-behind sink.
|
|
2323
|
+
*/
|
|
2324
|
+
constructor(options = {}) {
|
|
2325
|
+
this.sink = options.sink;
|
|
2326
|
+
}
|
|
2327
|
+
/**
|
|
2328
|
+
* Settle the durable sink without changing in-memory success.
|
|
2329
|
+
* @returns A promise that resolves when all recorded mutations are stored.
|
|
2330
|
+
*/
|
|
2331
|
+
async flush() {
|
|
2332
|
+
await this.sink?.flush();
|
|
2333
|
+
}
|
|
2334
|
+
/**
|
|
2335
|
+
* Observe committed runtime mutations. Image seeding is deliberately silent.
|
|
2336
|
+
* @param listener - Consumer called after each successful mutation.
|
|
2337
|
+
* @returns A disposer that prevents future calls.
|
|
2338
|
+
*/
|
|
2339
|
+
subscribe(listener) {
|
|
2340
|
+
this.mutationListeners.add(listener);
|
|
2341
|
+
return () => {
|
|
2342
|
+
this.mutationListeners.delete(listener);
|
|
2343
|
+
};
|
|
2344
|
+
}
|
|
2345
|
+
/** Publish after state changes; one faulty observer cannot roll back a write. */
|
|
2346
|
+
publish(mutation) {
|
|
2347
|
+
const observers = [...this.sink === void 0 ? [] : [(change) => {
|
|
2348
|
+
this.sink?.record(change);
|
|
2349
|
+
}], ...this.mutationListeners];
|
|
2350
|
+
for (const listener of observers) try {
|
|
2351
|
+
listener(mutation);
|
|
2352
|
+
} catch (error) {
|
|
2353
|
+
console.error("webworker vfs: mutation observer failed", error);
|
|
2354
|
+
}
|
|
2355
|
+
}
|
|
2356
|
+
/** Promise face mirroring `node:fs/promises` for the methods the roster uses. */
|
|
2357
|
+
promises = {
|
|
2358
|
+
readFile: async (path, options) => this.readFileSync(path, options),
|
|
2359
|
+
writeFile: async (path, data, options) => {
|
|
2360
|
+
this.writeFileSync(path, data, options);
|
|
2361
|
+
},
|
|
2362
|
+
appendFile: async (path, data) => {
|
|
2363
|
+
this.appendFileSync(path, data);
|
|
2364
|
+
},
|
|
2365
|
+
mkdir: async (path, options) => this.mkdirSync(path, options),
|
|
2366
|
+
readdir: async (path, options) => this.readdirSync(path, options),
|
|
2367
|
+
stat: async (path, options) => this.statSync(path, options),
|
|
2368
|
+
lstat: async (path, options) => this.statSync(path, options),
|
|
2369
|
+
realpath: async (path) => this.realpathSync(path),
|
|
2370
|
+
rename: async (from, to) => {
|
|
2371
|
+
this.renameSync(from, to);
|
|
2372
|
+
},
|
|
2373
|
+
unlink: async (path) => {
|
|
2374
|
+
this.unlinkSync(path);
|
|
2375
|
+
},
|
|
2376
|
+
rm: async (path, options) => {
|
|
2377
|
+
this.rmSync(path, options);
|
|
2378
|
+
},
|
|
2379
|
+
mkdtemp: async (prefix) => this.mkdtempSync(prefix),
|
|
2380
|
+
link: async (existing, next) => {
|
|
2381
|
+
this.linkSync(existing, next);
|
|
2382
|
+
},
|
|
2383
|
+
truncate: async (path, length) => {
|
|
2384
|
+
this.truncateSync(path, length);
|
|
2385
|
+
},
|
|
2386
|
+
chmod: async (path, mode) => {
|
|
2387
|
+
this.chmodSync(path, mode);
|
|
2388
|
+
},
|
|
2389
|
+
opendir: async (path) => this.opendir(path),
|
|
2390
|
+
open: async (path, flags, mode) => this.open(path, flags, mode),
|
|
2391
|
+
/** Resolves for any existing path: the VFS grants read and write to everything it holds. */
|
|
2392
|
+
access: async (path) => {
|
|
2393
|
+
const target = normalize(resolve(path));
|
|
2394
|
+
if (!this.files.has(target) && !this.directories.has(target)) fail("ENOENT", "access", target);
|
|
2395
|
+
}
|
|
2396
|
+
};
|
|
2397
|
+
/** @returns Absolute path with no trailing separator. */
|
|
2398
|
+
key(path) {
|
|
2399
|
+
const absolute = normalize(resolve(path));
|
|
2400
|
+
return absolute.length > 1 && absolute.endsWith("/") ? absolute.slice(0, -1) : absolute;
|
|
2401
|
+
}
|
|
2402
|
+
/**
|
|
2403
|
+
* Read a file.
|
|
2404
|
+
* @param path - File path.
|
|
2405
|
+
* @param options - `'utf8'` or `{encoding}` for text; omitted for bytes.
|
|
2406
|
+
* @returns Text or a copy-free view of the stored bytes.
|
|
2407
|
+
*/
|
|
2408
|
+
readFileSync(path, options) {
|
|
2409
|
+
const target = this.key(path);
|
|
2410
|
+
const node = this.files.get(target);
|
|
2411
|
+
if (node === void 0) {
|
|
2412
|
+
if (this.directories.has(target)) fail("EISDIR", "read", target);
|
|
2413
|
+
fail("ENOENT", "open", target);
|
|
2414
|
+
}
|
|
2415
|
+
return encodingOf(options) === void 0 ? node.bytes : decoder.decode(node.bytes);
|
|
2416
|
+
}
|
|
2417
|
+
/**
|
|
2418
|
+
* Report whether a path exists.
|
|
2419
|
+
* @param path - Path to test.
|
|
2420
|
+
* @returns True for files and directories.
|
|
2421
|
+
*/
|
|
2422
|
+
existsSync(path) {
|
|
2423
|
+
const target = this.key(path);
|
|
2424
|
+
return this.files.has(target) || this.directories.has(target);
|
|
2425
|
+
}
|
|
2426
|
+
/**
|
|
2427
|
+
* Stat a path.
|
|
2428
|
+
* @param path - Path to stat.
|
|
2429
|
+
* @param options - `bigint` selects the BigInt stats Node returns for it.
|
|
2430
|
+
* @returns Stats for the file or directory.
|
|
2431
|
+
*/
|
|
2432
|
+
statSync(path, options) {
|
|
2433
|
+
const target = this.key(path);
|
|
2434
|
+
const node = this.files.get(target);
|
|
2435
|
+
const [size, mtimeMs, directory, mode] = node !== void 0 ? [
|
|
2436
|
+
node.bytes.length,
|
|
2437
|
+
node.mtimeMs,
|
|
2438
|
+
false,
|
|
2439
|
+
node.mode
|
|
2440
|
+
] : this.directories.has(target) ? [
|
|
2441
|
+
0,
|
|
2442
|
+
this.directoryMtimes.get(target) ?? 0,
|
|
2443
|
+
true,
|
|
2444
|
+
this.directoryModes.get(target) ?? DEFAULT_DIRECTORY_MODE
|
|
2445
|
+
] : fail("ENOENT", "stat", target);
|
|
2446
|
+
const identity = node === void 0 ? this.identityOf(target) : this.identityOfFile(node);
|
|
2447
|
+
return options?.bigint === true ? bigIntStatsOf(size, mtimeMs, directory, identity, mode, node === void 0 ? 1 : this.fileLinkCount(node)) : statsOf(size, mtimeMs, directory, identity, mode);
|
|
2448
|
+
}
|
|
2449
|
+
/** @returns Stats in the plain shape, for internal callers that read `size`/`mtimeMs`. */
|
|
2450
|
+
plainStats(path) {
|
|
2451
|
+
return this.statSync(path);
|
|
2452
|
+
}
|
|
2453
|
+
/** @returns The stable identity of an existing path, assigning one on first observation. */
|
|
2454
|
+
identityOf(target) {
|
|
2455
|
+
const existing = this.identities.get(target);
|
|
2456
|
+
if (existing !== void 0) return existing;
|
|
2457
|
+
this.lastIdentity += 1n;
|
|
2458
|
+
this.identities.set(target, this.lastIdentity);
|
|
2459
|
+
return this.lastIdentity;
|
|
2460
|
+
}
|
|
2461
|
+
/** @returns The inode-like identity retained by a file node across names. */
|
|
2462
|
+
identityOfFile(node) {
|
|
2463
|
+
if (node.identity !== void 0) return node.identity;
|
|
2464
|
+
this.lastIdentity += 1n;
|
|
2465
|
+
node.identity = this.lastIdentity;
|
|
2466
|
+
return node.identity;
|
|
2467
|
+
}
|
|
2468
|
+
/** @returns The number of names currently linked to one file node. */
|
|
2469
|
+
fileLinkCount(node) {
|
|
2470
|
+
return typeof node.paths === "string" ? 1 : node.paths?.size ?? 0;
|
|
2471
|
+
}
|
|
2472
|
+
/** Add one map name, promoting the rare hard-link case to a Set. */
|
|
2473
|
+
addFilePath(node, path) {
|
|
2474
|
+
if (node.paths === void 0) node.paths = path;
|
|
2475
|
+
else if (typeof node.paths === "string") node.paths = new Set([node.paths, path]);
|
|
2476
|
+
else node.paths.add(path);
|
|
2477
|
+
}
|
|
2478
|
+
/** Remove one map name, collapsing a remaining single link back to a string. */
|
|
2479
|
+
removeFilePath(node, path) {
|
|
2480
|
+
if (typeof node.paths === "string") {
|
|
2481
|
+
node.paths = void 0;
|
|
2482
|
+
return;
|
|
2483
|
+
}
|
|
2484
|
+
if (node.paths === void 0) return;
|
|
2485
|
+
node.paths.delete(path);
|
|
2486
|
+
if (node.paths.size === 1) {
|
|
2487
|
+
const [remaining] = node.paths;
|
|
2488
|
+
node.paths = remaining;
|
|
2489
|
+
}
|
|
2490
|
+
}
|
|
2491
|
+
/** Set one file-map entry while maintaining both nodes' reverse path indexes. */
|
|
2492
|
+
setFile(path, node) {
|
|
2493
|
+
const previous = this.files.get(path);
|
|
2494
|
+
if (previous === node) return;
|
|
2495
|
+
if (previous !== void 0) this.removeFilePath(previous, path);
|
|
2496
|
+
this.files.set(path, node);
|
|
2497
|
+
this.addFilePath(node, path);
|
|
2498
|
+
}
|
|
2499
|
+
/** Delete one file-map entry while retaining an unlinked node held by a descriptor. */
|
|
2500
|
+
deleteFile(path) {
|
|
2501
|
+
const node = this.files.get(path);
|
|
2502
|
+
if (node === void 0) return void 0;
|
|
2503
|
+
this.files.delete(path);
|
|
2504
|
+
this.removeFilePath(node, path);
|
|
2505
|
+
return node;
|
|
2506
|
+
}
|
|
2507
|
+
/** Publish one linked name after a content or metadata write. */
|
|
2508
|
+
publishFilePath(node, path, appendedFrom) {
|
|
2509
|
+
this.publish({
|
|
2510
|
+
kind: "write",
|
|
2511
|
+
path,
|
|
2512
|
+
bytes: node.bytes,
|
|
2513
|
+
mode: node.mode,
|
|
2514
|
+
entryChanged: false,
|
|
2515
|
+
...appendedFrom === void 0 ? {} : { appendedFrom }
|
|
2516
|
+
});
|
|
2517
|
+
}
|
|
2518
|
+
/** Publish a content or metadata write for every hard link to one node. */
|
|
2519
|
+
publishFile(node, appendedFrom) {
|
|
2520
|
+
if (typeof node.paths === "string") {
|
|
2521
|
+
this.publishFilePath(node, node.paths, appendedFrom);
|
|
2522
|
+
return;
|
|
2523
|
+
}
|
|
2524
|
+
if (node.paths === void 0) return;
|
|
2525
|
+
for (const path of node.paths) this.publishFilePath(node, path, appendedFrom);
|
|
2526
|
+
}
|
|
2527
|
+
/** Replace bytes on one file identity and notify all linked paths. */
|
|
2528
|
+
replaceFile(node, bytes, appendedFrom) {
|
|
2529
|
+
node.bytes = bytes;
|
|
2530
|
+
node.mtimeMs = this.touchNode(node);
|
|
2531
|
+
this.publishFile(node, appendedFrom);
|
|
2532
|
+
}
|
|
2533
|
+
/** Write at one offset, zero-filling any gap. */
|
|
2534
|
+
writeFileNode(node, position, data) {
|
|
2535
|
+
const offset = Math.max(0, position);
|
|
2536
|
+
const previousLength = node.bytes.length;
|
|
2537
|
+
const bytes = new Uint8Array(Math.max(previousLength, offset + data.length));
|
|
2538
|
+
bytes.set(node.bytes);
|
|
2539
|
+
bytes.set(data, offset);
|
|
2540
|
+
this.replaceFile(node, bytes, offset === previousLength ? previousLength : void 0);
|
|
2541
|
+
return data.length;
|
|
2542
|
+
}
|
|
2543
|
+
/** Resize one file identity and notify all linked paths. */
|
|
2544
|
+
truncateFile(node, length) {
|
|
2545
|
+
this.replaceFile(node, resize(node.bytes, length));
|
|
2546
|
+
}
|
|
2547
|
+
/** Change one file identity's permission bits and notify every linked path. */
|
|
2548
|
+
chmodFile(node, mode) {
|
|
2549
|
+
node.mode = mode & 511;
|
|
2550
|
+
if (typeof node.paths === "string") this.publish({
|
|
2551
|
+
kind: "chmod",
|
|
2552
|
+
path: node.paths,
|
|
2553
|
+
mode: node.mode
|
|
2554
|
+
});
|
|
2555
|
+
else if (node.paths !== void 0) for (const path of node.paths) this.publish({
|
|
2556
|
+
kind: "chmod",
|
|
2557
|
+
path,
|
|
2558
|
+
mode: node.mode
|
|
2559
|
+
});
|
|
2560
|
+
}
|
|
2561
|
+
/** @returns Plain stats for an open file, including after its last name is removed. */
|
|
2562
|
+
fileStats(node) {
|
|
2563
|
+
return statsOf(node.bytes.length, node.mtimeMs, false, this.identityOfFile(node), node.mode);
|
|
2564
|
+
}
|
|
2565
|
+
/** @returns BigInt stats for an open file, including its device and inode identity. */
|
|
2566
|
+
fileBigIntStats(node) {
|
|
2567
|
+
return bigIntStatsOf(node.bytes.length, node.mtimeMs, false, this.identityOfFile(node), node.mode, this.fileLinkCount(node));
|
|
2568
|
+
}
|
|
2569
|
+
/** Forget removed directory identities, so recreated paths report new ones. */
|
|
2570
|
+
forgetIdentity(target) {
|
|
2571
|
+
this.identities.delete(target);
|
|
2572
|
+
const prefix = `${target}/`;
|
|
2573
|
+
for (const known of [...this.identities.keys()]) if (known.startsWith(prefix)) this.identities.delete(known);
|
|
2574
|
+
}
|
|
2575
|
+
/**
|
|
2576
|
+
* Modification time for a write, strictly after the entry's previous one.
|
|
2577
|
+
*
|
|
2578
|
+
* The clock has millisecond resolution and these writes are in memory, so two
|
|
2579
|
+
* revisions of one file routinely land in the same millisecond. The filesystem
|
|
2580
|
+
* service's stale-write guard compares timestamps, so an equal one would let a
|
|
2581
|
+
* stale overwrite through.
|
|
2582
|
+
* @param target - Normalized path being written.
|
|
2583
|
+
* @returns Now, or one millisecond past the entry's current time.
|
|
2584
|
+
*/
|
|
2585
|
+
touch(target) {
|
|
2586
|
+
return this.touchNode(this.files.get(target));
|
|
2587
|
+
}
|
|
2588
|
+
/** @returns A modification time strictly newer than one file node's current value. */
|
|
2589
|
+
touchNode(node) {
|
|
2590
|
+
const previous = node?.mtimeMs;
|
|
2591
|
+
const now = Date.now();
|
|
2592
|
+
return previous === void 0 ? now : Math.max(now, previous + 1);
|
|
2593
|
+
}
|
|
2594
|
+
/** Advance a directory's mtime after its immediate children change. */
|
|
2595
|
+
touchDirectory(target) {
|
|
2596
|
+
const previous = this.directoryMtimes.get(target);
|
|
2597
|
+
const now = Date.now();
|
|
2598
|
+
this.directoryMtimes.set(target, previous === void 0 ? now : Math.max(now, previous + 1));
|
|
2599
|
+
}
|
|
2600
|
+
/**
|
|
2601
|
+
* List a directory.
|
|
2602
|
+
* @param path - Directory path.
|
|
2603
|
+
* @param options - `withFileTypes` returns {@link VfsDirent} objects instead of names.
|
|
2604
|
+
* @returns Immediate entry names, or directory entries.
|
|
2605
|
+
*/
|
|
2606
|
+
readdirSync(path, options) {
|
|
2607
|
+
const target = this.key(path);
|
|
2608
|
+
if (!this.directories.has(target)) {
|
|
2609
|
+
if (this.files.has(target)) fail("ENOTDIR", "scandir", target);
|
|
2610
|
+
fail("ENOENT", "scandir", target);
|
|
2611
|
+
}
|
|
2612
|
+
const prefix = target === "/" ? "/" : `${target}/`;
|
|
2613
|
+
const names = /* @__PURE__ */ new Set();
|
|
2614
|
+
for (const candidate of [...this.files.keys(), ...this.directories]) {
|
|
2615
|
+
if (!candidate.startsWith(prefix) || candidate === target) continue;
|
|
2616
|
+
const rest = candidate.slice(prefix.length);
|
|
2617
|
+
if (rest === "") continue;
|
|
2618
|
+
const [head = rest] = rest.split("/");
|
|
2619
|
+
names.add(head);
|
|
2620
|
+
}
|
|
2621
|
+
const sorted = [...names].sort();
|
|
2622
|
+
if (options?.withFileTypes !== true) return sorted;
|
|
2623
|
+
return sorted.map((name) => this.direntOf(target, name));
|
|
2624
|
+
}
|
|
2625
|
+
/** @returns Directory entry for one child of `directory`. */
|
|
2626
|
+
direntOf(directory, name) {
|
|
2627
|
+
const stats = this.plainStats(join(directory, name));
|
|
2628
|
+
return {
|
|
2629
|
+
name,
|
|
2630
|
+
parentPath: directory,
|
|
2631
|
+
isFile: () => stats.isFile(),
|
|
2632
|
+
isDirectory: () => stats.isDirectory(),
|
|
2633
|
+
isSymbolicLink: () => false
|
|
2634
|
+
};
|
|
2635
|
+
}
|
|
2636
|
+
/**
|
|
2637
|
+
* Resolve a path; the VFS has no symlinks, so this only normalizes.
|
|
2638
|
+
* @param path - Path to resolve.
|
|
2639
|
+
* @returns Absolute path.
|
|
2640
|
+
*/
|
|
2641
|
+
realpathSync(path) {
|
|
2642
|
+
const target = this.key(path);
|
|
2643
|
+
if (!this.existsSync(target)) fail("ENOENT", "realpath", target);
|
|
2644
|
+
return target;
|
|
2645
|
+
}
|
|
2646
|
+
/**
|
|
2647
|
+
* Create a directory.
|
|
2648
|
+
* @param path - Directory path.
|
|
2649
|
+
* @param options - `recursive` creates missing parents.
|
|
2650
|
+
* @returns First created path when recursive, otherwise undefined.
|
|
2651
|
+
*/
|
|
2652
|
+
mkdirSync(path, options) {
|
|
2653
|
+
const target = this.key(path);
|
|
2654
|
+
if (this.files.has(target)) fail("EEXIST", "mkdir", target);
|
|
2655
|
+
if (this.directories.has(target)) {
|
|
2656
|
+
if (options?.recursive === true) return void 0;
|
|
2657
|
+
fail("EEXIST", "mkdir", target);
|
|
2658
|
+
}
|
|
2659
|
+
const parent = dirname(target);
|
|
2660
|
+
if (!this.directories.has(parent)) {
|
|
2661
|
+
if (options?.recursive !== true) fail("ENOENT", "mkdir", target);
|
|
2662
|
+
this.mkdirSync(parent, options);
|
|
2663
|
+
}
|
|
2664
|
+
this.directories.add(target);
|
|
2665
|
+
this.touchDirectory(target);
|
|
2666
|
+
this.touchDirectory(parent);
|
|
2667
|
+
const mode = (options?.mode ?? DEFAULT_DIRECTORY_MODE) & 511;
|
|
2668
|
+
if (mode !== DEFAULT_DIRECTORY_MODE) this.directoryModes.set(target, mode);
|
|
2669
|
+
this.publish({
|
|
2670
|
+
kind: "mkdir",
|
|
2671
|
+
path: target,
|
|
2672
|
+
mode
|
|
2673
|
+
});
|
|
2674
|
+
return target;
|
|
2675
|
+
}
|
|
2676
|
+
/**
|
|
2677
|
+
* Write a file, replacing existing contents.
|
|
2678
|
+
* @param path - File path; its parent directory must exist.
|
|
2679
|
+
* @param data - Text or bytes.
|
|
2680
|
+
* @param options - `flag` `wx` refuses an existing file, `a` appends.
|
|
2681
|
+
*/
|
|
2682
|
+
writeFileSync(path, data, options) {
|
|
2683
|
+
const target = this.key(path);
|
|
2684
|
+
if (this.directories.has(target)) fail("EISDIR", "open", target);
|
|
2685
|
+
if (!this.directories.has(dirname(target))) fail("ENOENT", "open", target);
|
|
2686
|
+
const flag = options?.flag ?? "w";
|
|
2687
|
+
if (flag.startsWith("wx") && this.files.has(target)) fail("EEXIST", "open", target);
|
|
2688
|
+
if (flag.startsWith("a")) {
|
|
2689
|
+
this.appendFileSync(target, data);
|
|
2690
|
+
return;
|
|
2691
|
+
}
|
|
2692
|
+
const previous = this.files.get(target);
|
|
2693
|
+
const mode = previous?.mode ?? (options?.mode !== void 0 ? options.mode & 511 : DEFAULT_FILE_MODE);
|
|
2694
|
+
const bytes = typeof data === "string" ? encoder.encode(data) : data;
|
|
2695
|
+
if (previous !== void 0) {
|
|
2696
|
+
this.replaceFile(previous, bytes);
|
|
2697
|
+
return;
|
|
2698
|
+
}
|
|
2699
|
+
const node = {
|
|
2700
|
+
bytes,
|
|
2701
|
+
mtimeMs: this.touch(target),
|
|
2702
|
+
mode,
|
|
2703
|
+
paths: void 0
|
|
2704
|
+
};
|
|
2705
|
+
this.setFile(target, node);
|
|
2706
|
+
this.touchDirectory(dirname(target));
|
|
2707
|
+
this.publish({
|
|
2708
|
+
kind: "write",
|
|
2709
|
+
path: target,
|
|
2710
|
+
bytes,
|
|
2711
|
+
mode,
|
|
2712
|
+
entryChanged: true
|
|
2713
|
+
});
|
|
2714
|
+
}
|
|
2715
|
+
/**
|
|
2716
|
+
* Open a directory; consumers enumerate entries or just prove it is one.
|
|
2717
|
+
* @param path - Directory path.
|
|
2718
|
+
* @returns Directory handle.
|
|
2719
|
+
*/
|
|
2720
|
+
opendir(path) {
|
|
2721
|
+
const target = this.key(path);
|
|
2722
|
+
const names = this.readdirSync(target);
|
|
2723
|
+
let cursor = 0;
|
|
2724
|
+
const direntOf = (name) => this.direntOf(target, name);
|
|
2725
|
+
return {
|
|
2726
|
+
path: target,
|
|
2727
|
+
close: async () => {},
|
|
2728
|
+
read: async () => {
|
|
2729
|
+
const name = names[cursor];
|
|
2730
|
+
cursor += 1;
|
|
2731
|
+
return name === void 0 ? null : direntOf(name);
|
|
2732
|
+
},
|
|
2733
|
+
async *[Symbol.asyncIterator]() {
|
|
2734
|
+
for (const name of names) yield direntOf(name);
|
|
2735
|
+
}
|
|
2736
|
+
};
|
|
2737
|
+
}
|
|
2738
|
+
/**
|
|
2739
|
+
* Open a file handle.
|
|
2740
|
+
* @param path - File path.
|
|
2741
|
+
* @param flags - Node open flags; `r` requires the file, `wx` refuses an existing one.
|
|
2742
|
+
* @param mode - Permission bits applied when the open creates the file.
|
|
2743
|
+
* @returns File handle.
|
|
2744
|
+
*/
|
|
2745
|
+
open(path, flags = "r", mode) {
|
|
2746
|
+
const target = this.key(path);
|
|
2747
|
+
if (this.directories.has(target)) {
|
|
2748
|
+
if (!flags.startsWith("r")) fail("EISDIR", "open", target);
|
|
2749
|
+
return {
|
|
2750
|
+
write: async () => fail("EISDIR", "write", target),
|
|
2751
|
+
writeFile: async () => fail("EISDIR", "write", target),
|
|
2752
|
+
readFile: async () => fail("EISDIR", "read", target),
|
|
2753
|
+
truncate: async () => fail("EISDIR", "ftruncate", target),
|
|
2754
|
+
...this.handleTail(target)
|
|
2755
|
+
};
|
|
2756
|
+
}
|
|
2757
|
+
const file = this.openFileSync(target, flags, mode);
|
|
2758
|
+
let position = 0;
|
|
2759
|
+
let closed = false;
|
|
2760
|
+
const current = (syscall) => {
|
|
2761
|
+
if (closed) fail("EBADF", syscall, target);
|
|
2762
|
+
return file;
|
|
2763
|
+
};
|
|
2764
|
+
return {
|
|
2765
|
+
write: async (data) => {
|
|
2766
|
+
const bytes = typeof data === "string" ? encoder.encode(data) : data;
|
|
2767
|
+
const descriptor = current("write");
|
|
2768
|
+
const offset = descriptor.append ? descriptor.stat().size : position;
|
|
2769
|
+
const bytesWritten = descriptor.write(offset, bytes);
|
|
2770
|
+
position = offset + bytesWritten;
|
|
2771
|
+
return { bytesWritten };
|
|
2772
|
+
},
|
|
2773
|
+
writeFile: async (data) => {
|
|
2774
|
+
const bytes = typeof data === "string" ? encoder.encode(data) : data;
|
|
2775
|
+
const descriptor = current("write");
|
|
2776
|
+
const offset = descriptor.append ? descriptor.stat().size : position;
|
|
2777
|
+
position = offset + descriptor.write(offset, bytes);
|
|
2778
|
+
},
|
|
2779
|
+
readFile: async (options) => {
|
|
2780
|
+
const descriptor = current("read");
|
|
2781
|
+
const bytes = descriptor.read(position, Math.max(0, descriptor.stat().size - position));
|
|
2782
|
+
position += bytes.length;
|
|
2783
|
+
return encodingOf(options) === void 0 ? bytes : decoder.decode(bytes);
|
|
2784
|
+
},
|
|
2785
|
+
truncate: async (length = 0) => {
|
|
2786
|
+
current("ftruncate").truncate(length);
|
|
2787
|
+
},
|
|
2788
|
+
chmod: async (mode) => {
|
|
2789
|
+
current("fchmod").chmod(mode);
|
|
2790
|
+
},
|
|
2791
|
+
stat: async (options) => options?.bigint === true ? current("fstat").statBigInt() : current("fstat").stat(),
|
|
2792
|
+
sync: async () => {
|
|
2793
|
+
current("fsync");
|
|
2794
|
+
await this.flush();
|
|
2795
|
+
},
|
|
2796
|
+
datasync: async () => {
|
|
2797
|
+
current("fdatasync");
|
|
2798
|
+
await this.flush();
|
|
2799
|
+
},
|
|
2800
|
+
close: async () => {
|
|
2801
|
+
closed = true;
|
|
2802
|
+
}
|
|
2803
|
+
};
|
|
2804
|
+
}
|
|
2805
|
+
/**
|
|
2806
|
+
* Open one synchronous descriptor over a stable file identity.
|
|
2807
|
+
* @param path - File path.
|
|
2808
|
+
* @param flags - Node open flags.
|
|
2809
|
+
* @param mode - Permission bits applied only when a file is created.
|
|
2810
|
+
* @returns An open file that survives path rename, replacement, and unlink.
|
|
2811
|
+
*/
|
|
2812
|
+
openFileSync(path, flags = "r", mode) {
|
|
2813
|
+
const target = this.key(path);
|
|
2814
|
+
const access = openMode(flags);
|
|
2815
|
+
const existing = this.files.get(target);
|
|
2816
|
+
if (this.directories.has(target)) fail("EISDIR", "open", target);
|
|
2817
|
+
if (access.exclusive && existing !== void 0) fail("EEXIST", "open", target);
|
|
2818
|
+
if (!access.create && existing === void 0) fail("ENOENT", "open", target);
|
|
2819
|
+
if (access.create && existing === void 0) this.writeFileSync(target, new Uint8Array(), mode === void 0 ? void 0 : { mode });
|
|
2820
|
+
else if (access.truncate && existing !== void 0) this.truncateFile(existing, 0);
|
|
2821
|
+
const node = this.files.get(target);
|
|
2822
|
+
if (node === void 0) fail("ENOENT", "open", target);
|
|
2823
|
+
return {
|
|
2824
|
+
readable: access.readable,
|
|
2825
|
+
writable: access.writable,
|
|
2826
|
+
append: access.append,
|
|
2827
|
+
read: (position, length) => {
|
|
2828
|
+
if (!access.readable) fail("EBADF", "read", target);
|
|
2829
|
+
return node.bytes.subarray(position, position + length);
|
|
2830
|
+
},
|
|
2831
|
+
write: (position, data) => {
|
|
2832
|
+
if (!access.writable) fail("EBADF", "write", target);
|
|
2833
|
+
return this.writeFileNode(node, access.append ? node.bytes.length : position, data);
|
|
2834
|
+
},
|
|
2835
|
+
truncate: (length) => {
|
|
2836
|
+
if (!access.writable) fail("EINVAL", "ftruncate", target);
|
|
2837
|
+
this.truncateFile(node, length);
|
|
2838
|
+
},
|
|
2839
|
+
chmod: (mode) => {
|
|
2840
|
+
this.chmodFile(node, mode);
|
|
2841
|
+
},
|
|
2842
|
+
stat: () => this.fileStats(node),
|
|
2843
|
+
statBigInt: () => this.fileBigIntStats(node)
|
|
2844
|
+
};
|
|
2845
|
+
}
|
|
2846
|
+
/**
|
|
2847
|
+
* Directory-handle members for metadata, durability, and release.
|
|
2848
|
+
* `sync`/`datasync` settle an attached durable sink; an ephemeral filesystem
|
|
2849
|
+
* resolves immediately and `close` releases nothing.
|
|
2850
|
+
* @param target - Normalized path the handle was opened on.
|
|
2851
|
+
* @returns Metadata plus the no-op durability and release calls.
|
|
2852
|
+
*/
|
|
2853
|
+
handleTail(target) {
|
|
2854
|
+
return {
|
|
2855
|
+
chmod: async (mode) => {
|
|
2856
|
+
this.chmodSync(target, mode);
|
|
2857
|
+
},
|
|
2858
|
+
stat: async (options) => this.statSync(target, options),
|
|
2859
|
+
sync: async () => {
|
|
2860
|
+
await this.flush();
|
|
2861
|
+
},
|
|
2862
|
+
datasync: async () => {
|
|
2863
|
+
await this.flush();
|
|
2864
|
+
},
|
|
2865
|
+
close: async () => {}
|
|
2866
|
+
};
|
|
2867
|
+
}
|
|
2868
|
+
/**
|
|
2869
|
+
* Append to a file, creating it when absent.
|
|
2870
|
+
* @param path - File path.
|
|
2871
|
+
* @param data - Text or bytes.
|
|
2872
|
+
*/
|
|
2873
|
+
appendFileSync(path, data) {
|
|
2874
|
+
const target = this.key(path);
|
|
2875
|
+
const existing = this.files.get(target);
|
|
2876
|
+
const addition = typeof data === "string" ? encoder.encode(data) : data;
|
|
2877
|
+
if (existing === void 0) {
|
|
2878
|
+
this.writeFileSync(target, addition);
|
|
2879
|
+
return;
|
|
2880
|
+
}
|
|
2881
|
+
this.writeFileNode(existing, existing.bytes.length, addition);
|
|
2882
|
+
}
|
|
2883
|
+
/**
|
|
2884
|
+
* Move a file or directory subtree.
|
|
2885
|
+
* @param from - Source path.
|
|
2886
|
+
* @param to - Destination path.
|
|
2887
|
+
*/
|
|
2888
|
+
renameSync(from, to) {
|
|
2889
|
+
const source = this.key(from);
|
|
2890
|
+
const destination = this.key(to);
|
|
2891
|
+
if (source === destination) return;
|
|
2892
|
+
const node = this.files.get(source);
|
|
2893
|
+
if (node !== void 0) {
|
|
2894
|
+
if (this.directories.has(destination)) fail("EISDIR", "rename", destination);
|
|
2895
|
+
if (!this.directories.has(dirname(destination))) fail("ENOENT", "rename", destination);
|
|
2896
|
+
if (this.files.get(destination) === node) return;
|
|
2897
|
+
this.deleteFile(source);
|
|
2898
|
+
this.setFile(destination, node);
|
|
2899
|
+
this.forgetIdentity(source);
|
|
2900
|
+
this.forgetIdentity(destination);
|
|
2901
|
+
this.touchDirectory(dirname(source));
|
|
2902
|
+
this.touchDirectory(dirname(destination));
|
|
2903
|
+
this.publish({
|
|
2904
|
+
kind: "remove",
|
|
2905
|
+
path: source
|
|
2906
|
+
});
|
|
2907
|
+
this.publish({
|
|
2908
|
+
kind: "write",
|
|
2909
|
+
path: destination,
|
|
2910
|
+
bytes: node.bytes,
|
|
2911
|
+
mode: node.mode,
|
|
2912
|
+
entryChanged: true
|
|
2913
|
+
});
|
|
2914
|
+
return;
|
|
2915
|
+
}
|
|
2916
|
+
if (!this.directories.has(source)) fail("ENOENT", "rename", source);
|
|
2917
|
+
if (this.files.has(destination)) fail("ENOTDIR", "rename", destination);
|
|
2918
|
+
if (!this.directories.has(dirname(destination))) fail("ENOENT", "rename", destination);
|
|
2919
|
+
if (this.directories.has(destination)) {
|
|
2920
|
+
if (this.readdirSync(destination).length > 0) fail("ENOTEMPTY", "rename", destination);
|
|
2921
|
+
this.directories.delete(destination);
|
|
2922
|
+
this.directoryModes.delete(destination);
|
|
2923
|
+
this.directoryMtimes.delete(destination);
|
|
2924
|
+
}
|
|
2925
|
+
const prefix = `${source}/`;
|
|
2926
|
+
const movedFiles = [];
|
|
2927
|
+
for (const [candidate, value] of [...this.files]) {
|
|
2928
|
+
if (!candidate.startsWith(prefix)) continue;
|
|
2929
|
+
this.deleteFile(candidate);
|
|
2930
|
+
const target = join(destination, candidate.slice(prefix.length));
|
|
2931
|
+
this.setFile(target, value);
|
|
2932
|
+
movedFiles.push({
|
|
2933
|
+
path: target,
|
|
2934
|
+
bytes: value.bytes,
|
|
2935
|
+
mode: value.mode
|
|
2936
|
+
});
|
|
2937
|
+
}
|
|
2938
|
+
const movedDirectories = [];
|
|
2939
|
+
for (const candidate of [...this.directories]) {
|
|
2940
|
+
if (!candidate.startsWith(prefix) && candidate !== source) continue;
|
|
2941
|
+
const moved = candidate === source ? destination : join(destination, candidate.slice(prefix.length));
|
|
2942
|
+
this.directories.delete(candidate);
|
|
2943
|
+
this.directories.add(moved);
|
|
2944
|
+
const bits = this.directoryModes.get(candidate);
|
|
2945
|
+
this.directoryModes.delete(candidate);
|
|
2946
|
+
if (bits !== void 0) this.directoryModes.set(moved, bits);
|
|
2947
|
+
movedDirectories.push({
|
|
2948
|
+
path: moved,
|
|
2949
|
+
mode: bits ?? DEFAULT_DIRECTORY_MODE
|
|
2950
|
+
});
|
|
2951
|
+
const mtime = this.directoryMtimes.get(candidate);
|
|
2952
|
+
this.directoryMtimes.delete(candidate);
|
|
2953
|
+
if (mtime !== void 0) this.directoryMtimes.set(moved, mtime);
|
|
2954
|
+
}
|
|
2955
|
+
this.forgetIdentity(source);
|
|
2956
|
+
this.forgetIdentity(destination);
|
|
2957
|
+
this.touchDirectory(dirname(source));
|
|
2958
|
+
this.touchDirectory(dirname(destination));
|
|
2959
|
+
this.publish({
|
|
2960
|
+
kind: "remove",
|
|
2961
|
+
path: source
|
|
2962
|
+
});
|
|
2963
|
+
for (const directory of movedDirectories) this.publish({
|
|
2964
|
+
kind: "mkdir",
|
|
2965
|
+
path: directory.path,
|
|
2966
|
+
mode: directory.mode
|
|
2967
|
+
});
|
|
2968
|
+
for (const entry of movedFiles) this.publish({
|
|
2969
|
+
kind: "write",
|
|
2970
|
+
path: entry.path,
|
|
2971
|
+
bytes: entry.bytes,
|
|
2972
|
+
mode: entry.mode,
|
|
2973
|
+
entryChanged: true
|
|
2974
|
+
});
|
|
2975
|
+
}
|
|
2976
|
+
/**
|
|
2977
|
+
* Give existing bytes a second name.
|
|
2978
|
+
*
|
|
2979
|
+
* Both names retain one file identity, so writes and metadata changes through
|
|
2980
|
+
* either name remain visible through the other until that name is removed.
|
|
2981
|
+
* @param existing - Source file path.
|
|
2982
|
+
* @param next - Additional path; its parent must exist and it must be free.
|
|
2983
|
+
*/
|
|
2984
|
+
linkSync(existing, next) {
|
|
2985
|
+
const source = this.key(existing);
|
|
2986
|
+
const target = this.key(next);
|
|
2987
|
+
const node = this.files.get(source);
|
|
2988
|
+
if (node === void 0) fail("ENOENT", "link", source);
|
|
2989
|
+
if (this.files.has(target) || this.directories.has(target)) fail("EEXIST", "link", target);
|
|
2990
|
+
if (!this.directories.has(dirname(target))) fail("ENOENT", "link", target);
|
|
2991
|
+
this.setFile(target, node);
|
|
2992
|
+
this.touchDirectory(dirname(target));
|
|
2993
|
+
this.publish({
|
|
2994
|
+
kind: "write",
|
|
2995
|
+
path: target,
|
|
2996
|
+
bytes: node.bytes,
|
|
2997
|
+
mode: node.mode,
|
|
2998
|
+
entryChanged: true
|
|
2999
|
+
});
|
|
3000
|
+
}
|
|
3001
|
+
/**
|
|
3002
|
+
* Shorten a file.
|
|
3003
|
+
* @param path - File path.
|
|
3004
|
+
* @param length - Byte length to keep; defaults to zero.
|
|
3005
|
+
*/
|
|
3006
|
+
truncateSync(path, length = 0) {
|
|
3007
|
+
const target = this.key(path);
|
|
3008
|
+
const node = this.files.get(target);
|
|
3009
|
+
if (node === void 0) fail("ENOENT", "truncate", target);
|
|
3010
|
+
this.truncateFile(node, length);
|
|
3011
|
+
}
|
|
3012
|
+
/**
|
|
3013
|
+
* Change an entry's permission bits; stat reads back exactly what was set.
|
|
3014
|
+
* @param path - File or directory path.
|
|
3015
|
+
* @param mode - New permission bits (`0o777` mask).
|
|
3016
|
+
*/
|
|
3017
|
+
chmodSync(path, mode) {
|
|
3018
|
+
const target = this.key(path);
|
|
3019
|
+
const node = this.files.get(target);
|
|
3020
|
+
if (node !== void 0) {
|
|
3021
|
+
this.chmodFile(node, mode);
|
|
3022
|
+
return;
|
|
3023
|
+
}
|
|
3024
|
+
if (this.directories.has(target)) {
|
|
3025
|
+
const bits = mode & 511;
|
|
3026
|
+
this.directoryModes.set(target, bits);
|
|
3027
|
+
this.publish({
|
|
3028
|
+
kind: "chmod",
|
|
3029
|
+
path: target,
|
|
3030
|
+
mode: bits
|
|
3031
|
+
});
|
|
3032
|
+
return;
|
|
3033
|
+
}
|
|
3034
|
+
fail("ENOENT", "chmod", target);
|
|
3035
|
+
}
|
|
3036
|
+
/**
|
|
3037
|
+
* Remove a file.
|
|
3038
|
+
* @param path - File path.
|
|
3039
|
+
*/
|
|
3040
|
+
unlinkSync(path) {
|
|
3041
|
+
const target = this.key(path);
|
|
3042
|
+
if (this.deleteFile(target) === void 0) fail("ENOENT", "unlink", target);
|
|
3043
|
+
this.forgetIdentity(target);
|
|
3044
|
+
this.touchDirectory(dirname(target));
|
|
3045
|
+
this.publish({
|
|
3046
|
+
kind: "remove",
|
|
3047
|
+
path: target
|
|
3048
|
+
});
|
|
3049
|
+
}
|
|
3050
|
+
/**
|
|
3051
|
+
* Remove a file or directory.
|
|
3052
|
+
* @param path - Path to remove.
|
|
3053
|
+
* @param options - `recursive` removes subtrees, `force` ignores absence.
|
|
3054
|
+
*/
|
|
3055
|
+
rmSync(path, options) {
|
|
3056
|
+
const target = this.key(path);
|
|
3057
|
+
if (this.deleteFile(target) !== void 0) {
|
|
3058
|
+
this.forgetIdentity(target);
|
|
3059
|
+
this.touchDirectory(dirname(target));
|
|
3060
|
+
this.publish({
|
|
3061
|
+
kind: "remove",
|
|
3062
|
+
path: target
|
|
3063
|
+
});
|
|
3064
|
+
return;
|
|
3065
|
+
}
|
|
3066
|
+
if (this.directories.has(target)) {
|
|
3067
|
+
if (options?.recursive !== true) fail("ERR_FS_EISDIR", "rm", target);
|
|
3068
|
+
const prefix = `${target}/`;
|
|
3069
|
+
for (const candidate of [...this.files.keys()]) if (candidate.startsWith(prefix)) this.deleteFile(candidate);
|
|
3070
|
+
for (const candidate of [...this.directories]) {
|
|
3071
|
+
if (!candidate.startsWith(prefix)) continue;
|
|
3072
|
+
this.directories.delete(candidate);
|
|
3073
|
+
this.directoryModes.delete(candidate);
|
|
3074
|
+
this.directoryMtimes.delete(candidate);
|
|
3075
|
+
}
|
|
3076
|
+
this.directories.delete(target);
|
|
3077
|
+
this.directoryModes.delete(target);
|
|
3078
|
+
this.directoryMtimes.delete(target);
|
|
3079
|
+
this.forgetIdentity(target);
|
|
3080
|
+
this.touchDirectory(dirname(target));
|
|
3081
|
+
this.publish({
|
|
3082
|
+
kind: "remove",
|
|
3083
|
+
path: target
|
|
3084
|
+
});
|
|
3085
|
+
return;
|
|
3086
|
+
}
|
|
3087
|
+
if (options?.force !== true) fail("ENOENT", "rm", target);
|
|
3088
|
+
}
|
|
3089
|
+
/**
|
|
3090
|
+
* Create a uniquely named directory beside `prefix`, as `fs.mkdtempSync` does.
|
|
3091
|
+
* @param prefix - Path prefix; the suffix is appended without a separator.
|
|
3092
|
+
* @returns The created directory path.
|
|
3093
|
+
*/
|
|
3094
|
+
mkdtempSync(prefix) {
|
|
3095
|
+
this.temporaries += 1;
|
|
3096
|
+
const target = `${prefix}${Date.now().toString(36)}${this.temporaries.toString(36)}`;
|
|
3097
|
+
this.mkdirSync(target, { recursive: true });
|
|
3098
|
+
return this.key(target);
|
|
3099
|
+
}
|
|
3100
|
+
/**
|
|
3101
|
+
* Seed a file and its parent directories, for image loading and tests.
|
|
3102
|
+
* @param path - File path.
|
|
3103
|
+
* @param data - Text or bytes.
|
|
3104
|
+
* @param options - Permission bits and modification time supplied by the image or durable store.
|
|
3105
|
+
*/
|
|
3106
|
+
seed(path, data, options = {}) {
|
|
3107
|
+
const target = this.key(path);
|
|
3108
|
+
this.seedDirectory(dirname(target));
|
|
3109
|
+
this.setFile(target, {
|
|
3110
|
+
bytes: typeof data === "string" ? encoder.encode(data) : data,
|
|
3111
|
+
mtimeMs: options.mtimeMs ?? this.touch(target),
|
|
3112
|
+
mode: (options.mode ?? DEFAULT_FILE_MODE) & 511,
|
|
3113
|
+
paths: void 0
|
|
3114
|
+
});
|
|
3115
|
+
this.touchDirectory(dirname(target));
|
|
3116
|
+
}
|
|
3117
|
+
/**
|
|
3118
|
+
* Create a directory and its parents.
|
|
3119
|
+
* @param path - Directory path.
|
|
3120
|
+
* @param options - Permission bits and modification time supplied by the image or durable store.
|
|
3121
|
+
*/
|
|
3122
|
+
seedDirectory(path, options = {}) {
|
|
3123
|
+
const target = this.key(path);
|
|
3124
|
+
if (!this.directories.has(target)) {
|
|
3125
|
+
const parent = dirname(target);
|
|
3126
|
+
if (parent !== target) this.seedDirectory(parent);
|
|
3127
|
+
if (this.files.has(target)) fail("EEXIST", "mkdir", target);
|
|
3128
|
+
this.directories.add(target);
|
|
3129
|
+
this.directoryMtimes.set(target, options.mtimeMs ?? Date.now());
|
|
3130
|
+
this.touchDirectory(parent);
|
|
3131
|
+
}
|
|
3132
|
+
if (options.mode !== void 0) this.directoryModes.set(target, options.mode & 511);
|
|
3133
|
+
if (options.mtimeMs !== void 0) this.directoryMtimes.set(target, options.mtimeMs);
|
|
3134
|
+
}
|
|
3135
|
+
/**
|
|
3136
|
+
* Report what this filesystem holds, for the host's boot diagnostics.
|
|
3137
|
+
* @returns File count, directory count, and total byte size.
|
|
3138
|
+
*/
|
|
3139
|
+
usage() {
|
|
3140
|
+
let bytes = 0;
|
|
3141
|
+
for (const node of this.files.values()) bytes += node.bytes.length;
|
|
3142
|
+
return {
|
|
3143
|
+
files: this.files.size,
|
|
3144
|
+
directories: this.directories.size,
|
|
3145
|
+
bytes
|
|
3146
|
+
};
|
|
3147
|
+
}
|
|
3148
|
+
};
|
|
3149
|
+
/**
|
|
3150
|
+
* Mount a tar image produced by the build-time collector.
|
|
3151
|
+
*
|
|
3152
|
+
* Entry names are relative to `root` (`node_modules/...`, `config/cordis.yml`);
|
|
3153
|
+
* an absolute entry name is a collector defect and fails loud. File contents
|
|
3154
|
+
* stay views into `image` — nothing is copied at mount time.
|
|
3155
|
+
* @param image - The ustar archive, as `inflateImage` produces it from the fetched image.
|
|
3156
|
+
* @param root - Virtual root the entries mount under.
|
|
3157
|
+
* @param vfs - Filesystem to fill; a fresh one by default.
|
|
3158
|
+
* @returns The filled filesystem.
|
|
3159
|
+
*/
|
|
3160
|
+
function loadVfsImage(image, root = "/dsh", vfs = new MemoryVfs()) {
|
|
3161
|
+
vfs.seedDirectory(root);
|
|
3162
|
+
for (const entry of parseTar(image)) {
|
|
3163
|
+
const relativeName = entry.name.startsWith("./") ? entry.name.slice(2) : entry.name;
|
|
3164
|
+
if (relativeName.startsWith("/")) throw new Error(`webworker vfs: image entry must be relative to ${root}, received "${entry.name}"`);
|
|
3165
|
+
const target = join(root, relativeName);
|
|
3166
|
+
if (entry.directory) {
|
|
3167
|
+
vfs.seedDirectory(target, { mode: entry.mode });
|
|
3168
|
+
continue;
|
|
3169
|
+
}
|
|
3170
|
+
vfs.seed(target, entry.bytes, { mode: entry.mode });
|
|
3171
|
+
}
|
|
3172
|
+
return vfs;
|
|
3173
|
+
}
|
|
3174
|
+
/**
|
|
3175
|
+
* Apply one ordered data overlay to an already mounted base image.
|
|
3176
|
+
*
|
|
3177
|
+
* Overlay entries may replace files only under the layout's data directories;
|
|
3178
|
+
* module code, configuration, and the lowering manifest cannot be shadowed.
|
|
3179
|
+
* Paths containing traversal segments are refused before normalization. Later
|
|
3180
|
+
* overlays win for files, while file/directory type conflicts fail loud.
|
|
3181
|
+
* @param image - Uncompressed ustar overlay archive.
|
|
3182
|
+
* @param root - Virtual root shared with the base image.
|
|
3183
|
+
* @param vfs - Mounted filesystem to update.
|
|
3184
|
+
* @returns The same filesystem after applying the overlay.
|
|
3185
|
+
*/
|
|
3186
|
+
function loadVfsOverlay(image, root, vfs) {
|
|
3187
|
+
for (const entry of parseTar(image)) {
|
|
3188
|
+
const relativeName = entry.name.startsWith("./") ? entry.name.slice(2) : entry.name;
|
|
3189
|
+
const path = relativeName.endsWith("/") ? relativeName.slice(0, -1) : relativeName;
|
|
3190
|
+
const segments = path.split("/");
|
|
3191
|
+
if (path === "" || relativeName.startsWith("/") || segments.some((segment) => segment === "" || segment === "." || segment === "..") || !IMAGE_OVERLAY_DIRECTORIES.includes(segments[0] ?? "")) throw new Error(`webworker vfs: overlay entry must stay under ${IMAGE_OVERLAY_DIRECTORIES.join("/ or ")}, received "${entry.name}"`);
|
|
3192
|
+
const target = join(root, path);
|
|
3193
|
+
if (entry.directory) {
|
|
3194
|
+
vfs.seedDirectory(target, { mode: entry.mode });
|
|
3195
|
+
continue;
|
|
3196
|
+
}
|
|
3197
|
+
if (vfs.existsSync(target) && vfs.statSync(target).isDirectory()) throw new Error(`webworker vfs: overlay file cannot replace directory "${target}"`);
|
|
3198
|
+
vfs.seed(target, entry.bytes, { mode: entry.mode });
|
|
3199
|
+
}
|
|
3200
|
+
return vfs;
|
|
3201
|
+
}
|
|
3202
|
+
//#endregion
|
|
3203
|
+
//#region src/storage/active.ts
|
|
3204
|
+
let active;
|
|
3205
|
+
/**
|
|
3206
|
+
* Publish the filesystem the `node:fs` proxy reads.
|
|
3207
|
+
* @param vfs - Filesystem mounted by the worker entry.
|
|
3208
|
+
*/
|
|
3209
|
+
function setActiveVfs(vfs) {
|
|
3210
|
+
active = vfs;
|
|
3211
|
+
}
|
|
3212
|
+
/**
|
|
3213
|
+
* Read the mounted filesystem.
|
|
3214
|
+
* @returns The active filesystem.
|
|
3215
|
+
*/
|
|
3216
|
+
function requireActiveVfs() {
|
|
3217
|
+
if (active === void 0) throw new Error("webworker vfs: no filesystem is mounted; the worker entry must call setActiveVfs before any node:fs access");
|
|
3218
|
+
return active;
|
|
3219
|
+
}
|
|
3220
|
+
function requireGlobalPort(channel) {
|
|
3221
|
+
if (channel !== void 0) return channel;
|
|
3222
|
+
const post = globalThis.postMessage;
|
|
3223
|
+
if (typeof post !== "function") throw new Error("webworker host: no channel; pass options.channel outside a dedicated worker");
|
|
3224
|
+
return { postMessage: (message, transfer) => {
|
|
3225
|
+
post(message, transfer);
|
|
3226
|
+
} };
|
|
3227
|
+
}
|
|
3228
|
+
async function readImage(image) {
|
|
3229
|
+
if (typeof image !== "string") return await inflateImage(image, "the image bytes given to createWorkerHost");
|
|
3230
|
+
const response = await fetch(image);
|
|
3231
|
+
if (!response.ok) throw new Error(`webworker host: image fetch failed with ${String(response.status)} for ${image}`);
|
|
3232
|
+
if (response.body === null) throw new Error(`webworker host: image response for ${image} carried no body`);
|
|
3233
|
+
return await inflateImageStream(response.body, image);
|
|
3234
|
+
}
|
|
3235
|
+
/**
|
|
3236
|
+
* Build the worker host without touching the network or the image.
|
|
3237
|
+
* @param options - Assembly inputs.
|
|
3238
|
+
* @returns Handle whose `handleMessage` is ready immediately.
|
|
3239
|
+
*/
|
|
3240
|
+
function createWorkerHost(options) {
|
|
3241
|
+
const root = options.root ?? "/dsh";
|
|
3242
|
+
const configPath = options.configPath ?? join(root, "config/cordis.yml");
|
|
3243
|
+
const port = options.port ?? 3080;
|
|
3244
|
+
const tunnel = new TunnelServer({
|
|
3245
|
+
port: requireGlobalPort(options.channel),
|
|
3246
|
+
requestListener: options.requestListener,
|
|
3247
|
+
...options.privilegedMethods === void 0 ? {} : { privilegedMethods: options.privilegedMethods },
|
|
3248
|
+
...options.unaryApiLane === void 0 ? {} : { unaryApiLane: options.unaryApiLane }
|
|
3249
|
+
});
|
|
3250
|
+
let vfs;
|
|
3251
|
+
let modules;
|
|
3252
|
+
let context;
|
|
3253
|
+
const start = async () => {
|
|
3254
|
+
try {
|
|
3255
|
+
const home = join(root, IMAGE_HOME_DIRECTORY);
|
|
3256
|
+
installProcessGlobal({
|
|
3257
|
+
cwd: root,
|
|
3258
|
+
env: {
|
|
3259
|
+
DSH_HOME: home,
|
|
3260
|
+
HOME: home,
|
|
3261
|
+
...options.env
|
|
3262
|
+
}
|
|
3263
|
+
});
|
|
3264
|
+
const [bytes, overlays] = await Promise.all([readImage(options.image), Promise.all((options.overlays ?? []).map(readImage))]);
|
|
3265
|
+
const mounted = loadVfsImage(bytes, root);
|
|
3266
|
+
for (const overlay of overlays) loadVfsOverlay(overlay, root, mounted);
|
|
3267
|
+
for (const directory of IMAGE_EMPTY_DIRECTORIES) mounted.seedDirectory(join(root, directory.replace(/\/$/, "")));
|
|
3268
|
+
setActiveVfs(mounted);
|
|
3269
|
+
vfs = mounted;
|
|
3270
|
+
requireLoweredImage(mounted, options.manifestPath ?? join(root, "config/vfs-manifest.json"));
|
|
3271
|
+
const staticModules = { ...options.staticModules };
|
|
3272
|
+
for (const key of ["node:process", "process"]) staticModules[key] ??= () => globalThis.process;
|
|
3273
|
+
const loader = new WorkerModuleLoader({
|
|
3274
|
+
vfs: mounted,
|
|
3275
|
+
root,
|
|
3276
|
+
staticModules,
|
|
3277
|
+
...options.staticModulePrefixes === void 0 ? {} : { staticModulePrefixes: options.staticModulePrefixes },
|
|
3278
|
+
...options.alsCausality === void 0 ? {} : { alsCausality: options.alsCausality }
|
|
3279
|
+
});
|
|
3280
|
+
setActiveModuleLoader(loader);
|
|
3281
|
+
modules = loader;
|
|
3282
|
+
const require = loader.requireFrom(dirname(configPath));
|
|
3283
|
+
const appBoot = require("@deepseek-ai/dsh-app-boot");
|
|
3284
|
+
const cmdline = require("@deepseek-ai/dsh-cmdline");
|
|
3285
|
+
const { patches, presetOverlay } = bootPatches(loader, mounted, configPath, root);
|
|
3286
|
+
const ctx = await appBoot.boot("dsh-webworker", configPath, patches, (hostCtx) => {
|
|
3287
|
+
hostCtx.loader.internal = loader.internal;
|
|
3288
|
+
installLogSink(hostCtx, require);
|
|
3289
|
+
cmdline.provideCmdline(hostCtx, {
|
|
3290
|
+
args: [...options.cmdlineArgs ?? [
|
|
3291
|
+
"--host",
|
|
3292
|
+
"127.0.0.1",
|
|
3293
|
+
"--port",
|
|
3294
|
+
String(port),
|
|
3295
|
+
"--no-open"
|
|
3296
|
+
]],
|
|
3297
|
+
exit: (code) => {
|
|
3298
|
+
console.warn(`webworker host: tree requested exit(${String(code)})`);
|
|
3299
|
+
}
|
|
3300
|
+
});
|
|
3301
|
+
});
|
|
3302
|
+
context = ctx;
|
|
3303
|
+
const connection = ctx.get("connection");
|
|
3304
|
+
if (connection === void 0) throw new Error("webworker host: the tree activated without a Connection service");
|
|
3305
|
+
const typertGateway = ctx.get("typertGateway");
|
|
3306
|
+
if (typertGateway === void 0) throw new Error("webworker host: the tree activated without a typertGateway service");
|
|
3307
|
+
const handler = connection.createSharedFetchHandler("/api");
|
|
3308
|
+
const usage = loader.usage();
|
|
3309
|
+
console.info(`webworker host: tree active (modules=${String(usage.modules)}, data overlays=${String(overlays.length)}, preset root overlay=${presetOverlay ? "applied" : "already in roster"}, direct lane=connection.createSharedFetchHandler, als causality=${options.alsCausality === void 0 ? "inert" : "snapshot/restore"}, image lowering=${LOWERING_VERSION})`);
|
|
3310
|
+
tunnel.serve({
|
|
3311
|
+
directFetch: (request) => handler.fetch(request),
|
|
3312
|
+
bootPayload: () => readBootPayload(ctx),
|
|
3313
|
+
openStream: typertGateway.wireStream.open,
|
|
3314
|
+
streamFailure: typertGateway.wireStream.failure
|
|
3315
|
+
});
|
|
3316
|
+
} catch (reason) {
|
|
3317
|
+
tunnel.fail(reason);
|
|
3318
|
+
throw reason;
|
|
3319
|
+
}
|
|
3320
|
+
};
|
|
3321
|
+
return {
|
|
3322
|
+
handleMessage: (data) => {
|
|
3323
|
+
tunnel.handleMessage(data);
|
|
3324
|
+
},
|
|
3325
|
+
start,
|
|
3326
|
+
stop: async () => {
|
|
3327
|
+
tunnel.fail(/* @__PURE__ */ new Error("webworker host: the tree was disposed"));
|
|
3328
|
+
await context?.fiber.dispose();
|
|
3329
|
+
},
|
|
3330
|
+
get vfs() {
|
|
3331
|
+
return vfs;
|
|
3332
|
+
},
|
|
3333
|
+
get modules() {
|
|
3334
|
+
return modules;
|
|
3335
|
+
}
|
|
3336
|
+
};
|
|
3337
|
+
}
|
|
3338
|
+
/**
|
|
3339
|
+
* Send the tree's own warnings and errors to the worker console.
|
|
3340
|
+
*
|
|
3341
|
+
* Cordis's `LoggerService` always exists and always accepts messages, but with
|
|
3342
|
+
* no exporter mounted it only fills a ring buffer — and no profile in this
|
|
3343
|
+
* repository mounts one, so `ctx.logger.warn(...)` reaches nothing. A provider
|
|
3344
|
+
* that fails and is skipped (the skill registry logs exactly that) then looks
|
|
3345
|
+
* identical to one that found nothing, which is how an empty skill catalog hid a
|
|
3346
|
+
* filesystem fault twice.
|
|
3347
|
+
*
|
|
3348
|
+
* Warnings and errors only: `info`/`debug` from 131 plugin rows would bury the
|
|
3349
|
+
* page console, and this exists to make failures visible rather than to trace.
|
|
3350
|
+
* @param ctx - Host context, before any entry mounts.
|
|
3351
|
+
* @param require - Image resolver, for cordis's own message renderer.
|
|
3352
|
+
*/
|
|
3353
|
+
function installLogSink(ctx, require) {
|
|
3354
|
+
const { Logger } = require("@deepseek-ai/cordis");
|
|
3355
|
+
const exporter = {
|
|
3356
|
+
colors: false,
|
|
3357
|
+
levels: { default: 2 },
|
|
3358
|
+
export: (message) => {
|
|
3359
|
+
if (message.type !== "warn" && message.type !== "error") return;
|
|
3360
|
+
const line = `${message.name}: ${Logger.format(exporter, message)}`;
|
|
3361
|
+
if (message.type === "error") console.error(line);
|
|
3362
|
+
else console.warn(line);
|
|
3363
|
+
}
|
|
3364
|
+
};
|
|
3365
|
+
ctx.logger.exporter(exporter);
|
|
3366
|
+
}
|
|
3367
|
+
/**
|
|
3368
|
+
* Require the mounted image to carry bodies this build can wrap.
|
|
3369
|
+
*
|
|
3370
|
+
* The manifest the packer writes is the single source of truth: the worker holds
|
|
3371
|
+
* no transform, so an image that was never lowered — or was lowered against
|
|
3372
|
+
* different wrapper semantics — cannot be recovered at load and must be rebuilt.
|
|
3373
|
+
* @param vfs - Mounted filesystem.
|
|
3374
|
+
* @param path - Manifest path inside the image.
|
|
3375
|
+
* @throws When the manifest is missing, unreadable, or names another contract.
|
|
3376
|
+
*/
|
|
3377
|
+
function requireLoweredImage(vfs, path) {
|
|
3378
|
+
if (!vfs.existsSync(path)) throw new Error(`webworker host: ${path} is missing, so the image records no lowering; rebuild the image`);
|
|
3379
|
+
const parsed = JSON.parse(vfs.readFileSync(path, "utf8"));
|
|
3380
|
+
if (typeof parsed !== "object" || parsed === null) throw new Error(`webworker host: ${path} does not hold an object`);
|
|
3381
|
+
const lowered = parsed.lowered;
|
|
3382
|
+
if (lowered !== "dsh-worker-transform/1") throw new Error(`webworker host: image was lowered by ${String(lowered)}, this build runs ${LOWERING_VERSION}; rebuild the image`);
|
|
3383
|
+
}
|
|
3384
|
+
/**
|
|
3385
|
+
* The shipped preset root, as the application layer that owns the composition
|
|
3386
|
+
* supplies it.
|
|
3387
|
+
*
|
|
3388
|
+
* A launcher appends this root itself rather than writing it into the roster —
|
|
3389
|
+
* `apps/cli` does it in `composeProfile` (`profile-boot.ts:159-166`) because only
|
|
3390
|
+
* the application knows where its own presets sit. The worker's presets travel
|
|
3391
|
+
* in the image, so the same overlay names their virtual path. Patching replaces
|
|
3392
|
+
* a row's whole `config`, so the current one is read and spread, and a roster
|
|
3393
|
+
* that already names roots keeps them.
|
|
3394
|
+
* @param loader - Module loader, for the image's YAML reader.
|
|
3395
|
+
* @param vfs - Filesystem holding the composed configuration.
|
|
3396
|
+
* @param configPath - Composed configuration path.
|
|
3397
|
+
* @param root - Virtual root.
|
|
3398
|
+
* @returns Boot patches (preset root overlay, frontend serving off) and
|
|
3399
|
+
* whether the preset overlay was applied.
|
|
3400
|
+
*/
|
|
3401
|
+
function bootPatches(loader, vfs, configPath, root) {
|
|
3402
|
+
const text = vfs.readFileSync(configPath, "utf8");
|
|
3403
|
+
let rows;
|
|
3404
|
+
if (configPath.endsWith(".json")) rows = JSON.parse(text);
|
|
3405
|
+
else {
|
|
3406
|
+
const include = loader.load(loader.resolve("@deepseek-ai/cordis-plugin-include", root));
|
|
3407
|
+
rows = loader.load(loader.resolve("js-yaml", root)).load(text, { schema: include.entryListSchema });
|
|
3408
|
+
}
|
|
3409
|
+
const find = (entries, id) => {
|
|
3410
|
+
if (!Array.isArray(entries)) return void 0;
|
|
3411
|
+
for (const entry of entries) {
|
|
3412
|
+
if (entry.id === id) return entry;
|
|
3413
|
+
const nested = find(entry.config, id);
|
|
3414
|
+
if (nested !== void 0) return nested;
|
|
3415
|
+
}
|
|
3416
|
+
};
|
|
3417
|
+
const configOf = (row) => typeof row.config === "object" && row.config !== null && !Array.isArray(row.config) ? row.config : {};
|
|
3418
|
+
const patches = [];
|
|
3419
|
+
let presetOverlay = false;
|
|
3420
|
+
const presets = find(rows, "agent-presets");
|
|
3421
|
+
if (presets !== void 0 && configOf(presets).roots === void 0) {
|
|
3422
|
+
presetOverlay = true;
|
|
3423
|
+
patches.push({
|
|
3424
|
+
id: "agent-presets",
|
|
3425
|
+
config: {
|
|
3426
|
+
...configOf(presets),
|
|
3427
|
+
roots: [{
|
|
3428
|
+
path: join(root, "config/agent-presets"),
|
|
3429
|
+
trust: "system"
|
|
3430
|
+
}]
|
|
3431
|
+
}
|
|
3432
|
+
});
|
|
3433
|
+
}
|
|
3434
|
+
const jsonl = find(rows, "session-persistence-jsonl");
|
|
3435
|
+
if (jsonl !== void 0) patches.push({
|
|
3436
|
+
id: "session-persistence-jsonl",
|
|
3437
|
+
config: {
|
|
3438
|
+
...configOf(jsonl),
|
|
3439
|
+
compression: "none"
|
|
3440
|
+
}
|
|
3441
|
+
});
|
|
3442
|
+
return {
|
|
3443
|
+
patches,
|
|
3444
|
+
presetOverlay
|
|
3445
|
+
};
|
|
3446
|
+
}
|
|
3447
|
+
/**
|
|
3448
|
+
* Assemble the payload the page's pre-Cordis bootstrap needs: the structured
|
|
3449
|
+
* index injection table the served form renders into index.html. Collected
|
|
3450
|
+
* from the in-process webserver service, never from the API surface, because
|
|
3451
|
+
* the page has no Cordis tree yet.
|
|
3452
|
+
* @param ctx - Booted host context.
|
|
3453
|
+
* @returns Boot payload for `GET /__boot__`.
|
|
3454
|
+
*/
|
|
3455
|
+
function readBootPayload(ctx) {
|
|
3456
|
+
const webServer = ctx.get("webServer");
|
|
3457
|
+
if (webServer === void 0) throw new Error("webworker host: no webServer service, so the page cannot receive its boot injections");
|
|
3458
|
+
return { injections: webServer.collectIndexInjections() };
|
|
3459
|
+
}
|
|
3460
|
+
//#endregion
|
|
3461
|
+
//#region src/fixture-manifest.ts
|
|
3462
|
+
/** Browser-readable catalog of built-in Preview filesystem overlays. */
|
|
3463
|
+
/** Manifest format version emitted beside the base VFS image. */
|
|
3464
|
+
const PREVIEW_FIXTURE_MANIFEST_VERSION = 1;
|
|
3465
|
+
/** Leaf name resolved beside the base image. */
|
|
3466
|
+
const PREVIEW_FIXTURE_MANIFEST_FILE = "fixtures.json";
|
|
3467
|
+
function recordOf(value) {
|
|
3468
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) ? value : void 0;
|
|
3469
|
+
}
|
|
3470
|
+
/**
|
|
3471
|
+
* Validate the static fixture catalog before it controls Worker fetches.
|
|
3472
|
+
* @param value - Parsed JSON response.
|
|
3473
|
+
* @returns A detached manifest with unique ids and non-empty overlay lists.
|
|
3474
|
+
*/
|
|
3475
|
+
function parsePreviewFixtureManifest(value) {
|
|
3476
|
+
const record = recordOf(value);
|
|
3477
|
+
if (record?.version !== 1 || !Array.isArray(record.fixtures)) throw new Error(`preview fixture manifest must use version ${String(1)}`);
|
|
3478
|
+
const fixtures = [];
|
|
3479
|
+
const ids = /* @__PURE__ */ new Set();
|
|
3480
|
+
for (const value of record.fixtures) {
|
|
3481
|
+
const fixture = recordOf(value);
|
|
3482
|
+
const id = fixture?.id;
|
|
3483
|
+
const label = fixture?.label;
|
|
3484
|
+
const description = fixture?.description;
|
|
3485
|
+
const overlays = fixture?.overlays;
|
|
3486
|
+
const overlayUrls = Array.isArray(overlays) ? overlays.filter((overlay) => typeof overlay === "string" && overlay.length > 0) : [];
|
|
3487
|
+
if (typeof id !== "string" || !/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(id) || id === "none" || id === "webfs" || typeof label !== "string" || label.length === 0 || typeof description !== "string" || description.length === 0 || !Array.isArray(overlays) || overlays.length === 0 || overlayUrls.length !== overlays.length) throw new Error("preview fixture manifest contains an invalid fixture entry");
|
|
3488
|
+
if (ids.has(id)) throw new Error(`preview fixture manifest repeats id "${id}"`);
|
|
3489
|
+
ids.add(id);
|
|
3490
|
+
fixtures.push({
|
|
3491
|
+
id,
|
|
3492
|
+
label,
|
|
3493
|
+
description,
|
|
3494
|
+
overlays: overlayUrls
|
|
3495
|
+
});
|
|
3496
|
+
}
|
|
3497
|
+
const defaultFixture = record.defaultFixture;
|
|
3498
|
+
if (defaultFixture !== null && (typeof defaultFixture !== "string" || !ids.has(defaultFixture))) throw new Error("preview fixture manifest defaultFixture does not name a fixture");
|
|
3499
|
+
return {
|
|
3500
|
+
version: 1,
|
|
3501
|
+
defaultFixture,
|
|
3502
|
+
fixtures
|
|
3503
|
+
};
|
|
3504
|
+
}
|
|
3505
|
+
//#endregion
|
|
3506
|
+
export { API_PREFIX, DEFAULT_CONDITIONS, DEFAULT_ROOT, IMAGE_CONFIG_PATH, IMAGE_EMPTY_DIRECTORIES, IMAGE_FILE_NAME, IMAGE_HOME_DIRECTORY, IMAGE_MANIFEST_PATH, IMAGE_OVERLAY_DIRECTORIES, LOWERING_VERSION, MODULE_PROXIES, MODULE_PROXY_PREFIXES, MemoryVfs, PREVIEW_FIXTURE_MANIFEST_FILE, PREVIEW_FIXTURE_MANIFEST_VERSION, REPLACED_EXTERNAL_PACKAGES, SYNTHETIC_HOST, TunnelServer, WRAPPER_PARAMS, WorkerModuleLoader, createAlsRuntime, createSyntheticExchange, createWorkerHost, inflateImage, inflateImageStream, installProcessGlobal, loadVfsImage, loadVfsOverlay, lowerModuleSource, packTar, parseInboundFrame, parsePreviewFixtureManifest, parseTar, posix_path_exports as posixPath, requireActiveModuleLoader, requireActiveVfs, setActiveModuleLoader, setActiveVfs };
|