@genroc/eval-node 0.0.0-edge.0477fb0 → 0.0.0-edge.04ed705c
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 +202 -0
- package/README.md +71 -36
- package/dist/eval.js +77 -0
- package/dist/import.js +359 -0
- package/dist/realm.js +105 -0
- package/dist/worker.js +273 -0
- package/eval.ts +37 -21
- package/import.ts +220 -146
- package/package.json +6 -4
- package/realm.ts +58 -82
- package/worker.ts +64 -65
- package/bin/import.mjs +0 -4
- package/bin/worker.mjs +0 -2
package/dist/import.js
ADDED
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// The code-phase resolver: manifest on stdin, `{"code": [...]}` on stdout, diagnostic on stderr
|
|
3
|
+
// with a non-zero exit (specs/source-resolution.md). Two modes share one `tsc` pass: "types"
|
|
4
|
+
// writes declarations only, "build" typechecks and bundles.
|
|
5
|
+
import { existsSync } from "node:fs";
|
|
6
|
+
import { access, writeFile } from "node:fs/promises";
|
|
7
|
+
import { builtinModules } from "node:module";
|
|
8
|
+
import { dirname, join, resolve } from "node:path";
|
|
9
|
+
import commonjs from "@rollup/plugin-commonjs";
|
|
10
|
+
import json from "@rollup/plugin-json";
|
|
11
|
+
import { nodeResolve } from "@rollup/plugin-node-resolve";
|
|
12
|
+
import { rollup } from "rollup";
|
|
13
|
+
import ts from "typescript";
|
|
14
|
+
function die(message) {
|
|
15
|
+
console.error(message);
|
|
16
|
+
process.exit(1);
|
|
17
|
+
}
|
|
18
|
+
async function exists(path) {
|
|
19
|
+
try {
|
|
20
|
+
await access(path);
|
|
21
|
+
return true;
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
// ── JSON Schema → TypeScript ───────────────────────────────────────────────────
|
|
28
|
+
/** Only genroc's keyword set is handled; anything else its strict decoder would have
|
|
29
|
+
* refused before this ran (internal/schema, allowedKeywords). */
|
|
30
|
+
function tsType(s, used) {
|
|
31
|
+
if (s === undefined || s === null)
|
|
32
|
+
return "unknown";
|
|
33
|
+
if (typeof s.$ref === "string") {
|
|
34
|
+
const name = s.$ref.replace(/^#\/\$defs\//, "");
|
|
35
|
+
used.add(name);
|
|
36
|
+
return identifier(name);
|
|
37
|
+
}
|
|
38
|
+
if (Array.isArray(s.enum)) {
|
|
39
|
+
return s.enum.map((v) => JSON.stringify(v)).join(" | ") || "never";
|
|
40
|
+
}
|
|
41
|
+
if (Array.isArray(s.anyOf))
|
|
42
|
+
return union(s.anyOf.map((a) => tsType(a, used)));
|
|
43
|
+
if (Array.isArray(s.oneOf))
|
|
44
|
+
return union(s.oneOf.map((a) => tsType(a, used)));
|
|
45
|
+
if (Array.isArray(s.allOf)) {
|
|
46
|
+
return s.allOf.map((a) => tsType(a, used)).join(" & ") || "unknown";
|
|
47
|
+
}
|
|
48
|
+
const types = s.type === undefined ? [] : Array.isArray(s.type) ? s.type : [s.type];
|
|
49
|
+
if (types.length === 0) {
|
|
50
|
+
// The top type: `{}` means unknown, not "an empty object". specs/unknown-type.md.
|
|
51
|
+
return s.properties ? objectType(s, used) : "unknown";
|
|
52
|
+
}
|
|
53
|
+
return union(types.map((t) => scalarType(t, s, used)));
|
|
54
|
+
}
|
|
55
|
+
function scalarType(t, s, used) {
|
|
56
|
+
switch (t) {
|
|
57
|
+
case "object":
|
|
58
|
+
return objectType(s, used);
|
|
59
|
+
case "array":
|
|
60
|
+
return s.items ? `Array<${tsType(s.items, used)}>` : "unknown[]";
|
|
61
|
+
case "string":
|
|
62
|
+
return "string";
|
|
63
|
+
case "number":
|
|
64
|
+
case "integer":
|
|
65
|
+
return "number";
|
|
66
|
+
case "boolean":
|
|
67
|
+
return "boolean";
|
|
68
|
+
case "null":
|
|
69
|
+
return "null";
|
|
70
|
+
default:
|
|
71
|
+
return "unknown";
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
function objectType(s, used) {
|
|
75
|
+
const props = s.properties ?? {};
|
|
76
|
+
const required = new Set(s.required ?? []);
|
|
77
|
+
const lines = [];
|
|
78
|
+
for (const [key, sub] of Object.entries(props)) {
|
|
79
|
+
const doc = typeof sub.description === "string"
|
|
80
|
+
? ` /** ${sub.description} */\n`
|
|
81
|
+
: "";
|
|
82
|
+
lines.push(`${doc} ${propKey(key)}${required.has(key) ? "" : "?"}: ${nest(tsType(sub, used))};`);
|
|
83
|
+
}
|
|
84
|
+
if (s.additionalProperties && typeof s.additionalProperties === "object") {
|
|
85
|
+
lines.push(` [key: string]: ${nest(tsType(s.additionalProperties, used))};`);
|
|
86
|
+
}
|
|
87
|
+
if (lines.length === 0)
|
|
88
|
+
return "Record<string, unknown>";
|
|
89
|
+
return `{\n${lines.join("\n")}\n}`;
|
|
90
|
+
}
|
|
91
|
+
/** Every line but the first, which is already placed by the property that opens it. */
|
|
92
|
+
const nest = (t) => t.replace(/\n/g, "\n ");
|
|
93
|
+
function union(parts) {
|
|
94
|
+
const seen = [...new Set(parts)];
|
|
95
|
+
return seen.length === 0 ? "unknown" : seen.join(" | ");
|
|
96
|
+
}
|
|
97
|
+
const IDENT = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
|
|
98
|
+
const propKey = (k) => (IDENT.test(k) ? k : JSON.stringify(k));
|
|
99
|
+
const identifier = (n) => IDENT.test(n) ? n : `Def_${n.replace(/[^A-Za-z0-9_$]/g, "_")}`;
|
|
100
|
+
/** Emits one named type per reachable $def rather than inlining: a task output may
|
|
101
|
+
* reference itself (specs/recursive-type-inference.md) and inlining would not terminate. */
|
|
102
|
+
function declarations(at) {
|
|
103
|
+
const { site, where } = at;
|
|
104
|
+
const defs = where.$defs ?? {};
|
|
105
|
+
const used = new Set();
|
|
106
|
+
const input = tsType(site.types?.Input, used);
|
|
107
|
+
const output = tsType(site.types?.Output, used);
|
|
108
|
+
const emitted = [];
|
|
109
|
+
const done = new Set();
|
|
110
|
+
while (true) {
|
|
111
|
+
const next = [...used].find((n) => !done.has(n));
|
|
112
|
+
if (next === undefined)
|
|
113
|
+
break;
|
|
114
|
+
done.add(next);
|
|
115
|
+
const body = tsType(defs[next], used);
|
|
116
|
+
emitted.push(`export type ${identifier(next)} = ${body};`);
|
|
117
|
+
}
|
|
118
|
+
return [
|
|
119
|
+
"// Generated by genroc. Do not edit - regenerate with `genctl types`.",
|
|
120
|
+
`// ${where.name} (${address(site.pointer)})`,
|
|
121
|
+
"",
|
|
122
|
+
...emitted,
|
|
123
|
+
emitted.length ? "" : "",
|
|
124
|
+
`export type Input = ${input};`,
|
|
125
|
+
"",
|
|
126
|
+
`export type Output = ${output};`,
|
|
127
|
+
"",
|
|
128
|
+
].join("\n");
|
|
129
|
+
}
|
|
130
|
+
/** Keyed by the script's PATH, not the task id: keyed by task, renaming a task would break
|
|
131
|
+
* the author's `import type` line with the error landing nowhere near the rename. */
|
|
132
|
+
function typesPathFor(scriptPath) {
|
|
133
|
+
return scriptPath.replace(/\.[^.\/]+$/, "") + ".genroc.d.ts";
|
|
134
|
+
}
|
|
135
|
+
// ── typecheck ──────────────────────────────────────────────────────────────────
|
|
136
|
+
/** The tsconfig the author's editor reads, so editor and apply agree. Stops at the project
|
|
137
|
+
* root. */
|
|
138
|
+
async function nearestTsconfig(from, root) {
|
|
139
|
+
for (let dir = from;; dir = dirname(dir)) {
|
|
140
|
+
const candidate = join(dir, "tsconfig.json");
|
|
141
|
+
if (await exists(candidate))
|
|
142
|
+
return candidate;
|
|
143
|
+
if (dir === root || dirname(dir) === dir)
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
async function typecheck(sites) {
|
|
148
|
+
// One program per distinct base config: `extends` takes a single base, so merging two would
|
|
149
|
+
// check each script under the other author's options.
|
|
150
|
+
const groups = new Map();
|
|
151
|
+
for (const at of sites) {
|
|
152
|
+
const base = (await nearestTsconfig(dirname(at.file), root)) ?? "";
|
|
153
|
+
const group = groups.get(base);
|
|
154
|
+
if (group)
|
|
155
|
+
group.push(at);
|
|
156
|
+
else
|
|
157
|
+
groups.set(base, [at]);
|
|
158
|
+
}
|
|
159
|
+
const diagnostics = [];
|
|
160
|
+
for (const [base, group] of groups) {
|
|
161
|
+
const config = {
|
|
162
|
+
...(base ? { extends: base } : {}),
|
|
163
|
+
compilerOptions: {
|
|
164
|
+
noEmit: true,
|
|
165
|
+
strict: true,
|
|
166
|
+
skipLibCheck: true,
|
|
167
|
+
moduleDetection: "force",
|
|
168
|
+
module: "preserve",
|
|
169
|
+
target: "esnext",
|
|
170
|
+
// `lib` DESCRIBES the realm and is written after `extends` so a base cannot widen it:
|
|
171
|
+
// a worker thread has no document, whatever an author's config claims.
|
|
172
|
+
lib: ["esnext", "webworker"],
|
|
173
|
+
// `types` is the author's opt-in to node globals, which the realm has. With no base
|
|
174
|
+
// config there is nothing to opt in with, so it stays none.
|
|
175
|
+
...(base ? {} : { types: [] }),
|
|
176
|
+
},
|
|
177
|
+
files: group.flatMap((s) => [s.file, typesPathFor(s.file)]),
|
|
178
|
+
// `files` overrides the base's, but a base `include` survives beside it and would
|
|
179
|
+
// drag the author's whole tree in, to be checked under the worker lib.
|
|
180
|
+
include: [],
|
|
181
|
+
};
|
|
182
|
+
// Never written; anchors `@types` lookup at the root. Not `tsconfig.json`: that is often the
|
|
183
|
+
// base it extends, and tsc refuses a circular extends.
|
|
184
|
+
const parsed = ts.parseJsonConfigFileContent(config, ts.sys, root, undefined, join(root, "tsconfig.genroc.json"));
|
|
185
|
+
const program = ts.createProgram({
|
|
186
|
+
rootNames: parsed.fileNames,
|
|
187
|
+
options: parsed.options,
|
|
188
|
+
projectReferences: parsed.projectReferences,
|
|
189
|
+
configFileParsingDiagnostics: parsed.errors,
|
|
190
|
+
});
|
|
191
|
+
diagnostics.push(...parsed.errors, ...ts.getPreEmitDiagnostics(program));
|
|
192
|
+
}
|
|
193
|
+
if (diagnostics.length > 0) {
|
|
194
|
+
// The diagnostics are the whole type check: this is what genctl surfaces, and the reason a
|
|
195
|
+
// failed import never produces a string.
|
|
196
|
+
die(ts
|
|
197
|
+
.formatDiagnostics(diagnostics, {
|
|
198
|
+
getCanonicalFileName: (f) => f,
|
|
199
|
+
getCurrentDirectory: () => root,
|
|
200
|
+
getNewLine: () => "\n",
|
|
201
|
+
})
|
|
202
|
+
.trimEnd());
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
// ── bundle ─────────────────────────────────────────────────────────────────────
|
|
206
|
+
/** Transpiles only. The typecheck above already ran over the author's OWN tsconfig, and a
|
|
207
|
+
* second opinion from a config they do not control could fail a build they cannot fix. */
|
|
208
|
+
const transpile = {
|
|
209
|
+
name: "genroc-transpile",
|
|
210
|
+
transform(code, id) {
|
|
211
|
+
if (!id.endsWith(".ts") && !id.endsWith(".tsx"))
|
|
212
|
+
return null;
|
|
213
|
+
const out = ts.transpileModule(code, {
|
|
214
|
+
fileName: id,
|
|
215
|
+
compilerOptions: {
|
|
216
|
+
target: ts.ScriptTarget.ESNext,
|
|
217
|
+
module: ts.ModuleKind.ESNext,
|
|
218
|
+
verbatimModuleSyntax: false,
|
|
219
|
+
jsx: id.endsWith(".tsx") ? ts.JsxEmit.ReactJSX : undefined,
|
|
220
|
+
},
|
|
221
|
+
});
|
|
222
|
+
return { code: out.outputText, map: out.sourceMapText ?? null };
|
|
223
|
+
},
|
|
224
|
+
};
|
|
225
|
+
const BUILTIN = new Set([
|
|
226
|
+
...builtinModules,
|
|
227
|
+
...builtinModules.map((m) => `node:${m}`),
|
|
228
|
+
]);
|
|
229
|
+
/** Resolves through TYPESCRIPT under the typecheck's config, so a `paths` alias that compiles
|
|
230
|
+
* also bundles. A `.d.ts` resolution is declined (a type, not code), leaving node_modules to
|
|
231
|
+
* nodeResolve. */
|
|
232
|
+
function tsResolve(configPath) {
|
|
233
|
+
let options = {};
|
|
234
|
+
if (configPath) {
|
|
235
|
+
const read = ts.readConfigFile(configPath, ts.sys.readFile);
|
|
236
|
+
options = ts.parseJsonConfigFileContent(read.config ?? {}, ts.sys, dirname(configPath)).options;
|
|
237
|
+
}
|
|
238
|
+
return {
|
|
239
|
+
name: "genroc-ts-resolve",
|
|
240
|
+
resolveId(source, importer) {
|
|
241
|
+
if (!importer || BUILTIN.has(source))
|
|
242
|
+
return null;
|
|
243
|
+
const { resolvedModule } = ts.resolveModuleName(source, importer, options, ts.sys);
|
|
244
|
+
if (!resolvedModule || resolvedModule.isExternalLibraryImport)
|
|
245
|
+
return null;
|
|
246
|
+
return resolvedModule.resolvedFileName.endsWith(".d.ts")
|
|
247
|
+
? null
|
|
248
|
+
: resolvedModule.resolvedFileName;
|
|
249
|
+
},
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
/** A self-contained ES module whose default export is the author's own, unwrapped. Bundling here
|
|
253
|
+
* means a definition version pins its code forever. */
|
|
254
|
+
async function bundle(at) {
|
|
255
|
+
const site = at.site;
|
|
256
|
+
// Builtins are externalised; anything else unresolved is a REFUSAL, since rollup's default
|
|
257
|
+
// leaves an import that bundles clean and fails at runtime.
|
|
258
|
+
const built = await rollup({
|
|
259
|
+
input: at.file,
|
|
260
|
+
external: (id) => BUILTIN.has(id),
|
|
261
|
+
plugins: [
|
|
262
|
+
tsResolve(await nearestTsconfig(dirname(at.file), root)),
|
|
263
|
+
nodeResolve({ extensions: [".ts", ".tsx", ".mjs", ".js", ".json"] }),
|
|
264
|
+
commonjs(),
|
|
265
|
+
// Inlines `.json` imports; without it rollup hands JSON to the JS parser.
|
|
266
|
+
json(),
|
|
267
|
+
transpile,
|
|
268
|
+
],
|
|
269
|
+
onwarn(warning) {
|
|
270
|
+
if (warning.code === "UNRESOLVED_IMPORT") {
|
|
271
|
+
die(`${at.file}: cannot resolve ${warning.exporter ?? "an import"} — is it installed?`);
|
|
272
|
+
}
|
|
273
|
+
},
|
|
274
|
+
}).catch((e) => die(`${at.file}: ${e instanceof Error ? e.message : String(e)}`));
|
|
275
|
+
const { output } = await built.generate({
|
|
276
|
+
format: "es",
|
|
277
|
+
inlineDynamicImports: true,
|
|
278
|
+
});
|
|
279
|
+
await built.close();
|
|
280
|
+
// Refused here rather than in the realm: the evaluator can only report it against a running
|
|
281
|
+
// instance, and the file it names is on this machine.
|
|
282
|
+
if (!output[0].exports.includes("default")) {
|
|
283
|
+
die(`${at.file}: a script must \`export default\` the function to run`);
|
|
284
|
+
}
|
|
285
|
+
return output[0].code;
|
|
286
|
+
}
|
|
287
|
+
// ── main ───────────────────────────────────────────────────────────────────────
|
|
288
|
+
const stdin = await new Promise((resolve, reject) => {
|
|
289
|
+
let raw = "";
|
|
290
|
+
process.stdin.setEncoding("utf8");
|
|
291
|
+
process.stdin.on("data", (c) => (raw += c));
|
|
292
|
+
process.stdin.on("end", () => resolve(raw));
|
|
293
|
+
process.stdin.on("error", reject);
|
|
294
|
+
});
|
|
295
|
+
const manifest = JSON.parse(stdin);
|
|
296
|
+
// genctl runs a resolver with the project root as its cwd, so the manifest need not say so.
|
|
297
|
+
const root = process.cwd();
|
|
298
|
+
if (!manifest || !Array.isArray(manifest.processes))
|
|
299
|
+
die("stdin is not a genroc resolver manifest");
|
|
300
|
+
/** A pointer as the address it is, so a generated comment or an error can be pasted into
|
|
301
|
+
* `genctl schema`. A key no identifier can spell is bracketed, as the grammar spells it. */
|
|
302
|
+
function address(pointer) {
|
|
303
|
+
return pointer
|
|
304
|
+
.map((seg) => typeof seg === "string" && /^[A-Za-z_][A-Za-z0-9_]*$/.test(seg)
|
|
305
|
+
? `.${seg}`
|
|
306
|
+
: `[${JSON.stringify(seg)}]`)
|
|
307
|
+
.join("")
|
|
308
|
+
.replace(/^\./, "");
|
|
309
|
+
}
|
|
310
|
+
const located = manifest.processes.flatMap((where) => where.sites.map((site) => ({
|
|
311
|
+
site,
|
|
312
|
+
where,
|
|
313
|
+
file: resolve(where.dir, site.argument),
|
|
314
|
+
})));
|
|
315
|
+
// The `code`-field contract is this resolver's, not genctl's, so it checks the directive landed
|
|
316
|
+
// there: in a child call (the scaffold's shape) or an external task's input.
|
|
317
|
+
for (const at of located) {
|
|
318
|
+
// The action's kind is a field; where in it the directive sits is the pointer.
|
|
319
|
+
const kind = at.site.level === "action" ? (at.site.action ?? "") : "";
|
|
320
|
+
const slot = at.site.pointer.slice(-2).join(".");
|
|
321
|
+
if ((kind !== "child" && kind !== "external") || slot !== "input.code") {
|
|
322
|
+
die(`${join(at.where.dir, at.where.file)}: ${address(at.site.pointer)}: an evaluated script ` +
|
|
323
|
+
"belongs in the `code` field of a child or external task's input, and this is " +
|
|
324
|
+
`${kind ? `a ${kind} task's ` : ""}\`${slot}\`.`);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
// genctl does not know the argument is a file, so this resolver reports a missing one.
|
|
328
|
+
for (const at of located) {
|
|
329
|
+
if (!existsSync(at.file)) {
|
|
330
|
+
die(`${join(at.where.dir, at.where.file)}: ${address(at.site.pointer)}: ` +
|
|
331
|
+
`"$import: ${at.site.argument}" names no file (looked at ${at.file})`);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
// One script at two sites with different input types is a refusal, not a union: the union
|
|
335
|
+
// is sound and would typecheck a body that is wrong at one of the sites.
|
|
336
|
+
const byPath = new Map();
|
|
337
|
+
for (const at of located) {
|
|
338
|
+
const seen = byPath.get(at.file);
|
|
339
|
+
if (seen &&
|
|
340
|
+
JSON.stringify(seen.site.types) !== JSON.stringify(at.site.types)) {
|
|
341
|
+
die(`${at.file} is imported at ${address(seen.site.pointer)} and ` +
|
|
342
|
+
`${address(at.site.pointer)} with different input types.\n` +
|
|
343
|
+
"Split it into two scripts, or make the two call sites pass the same shape.");
|
|
344
|
+
}
|
|
345
|
+
byPath.set(at.file, at);
|
|
346
|
+
}
|
|
347
|
+
for (const at of byPath.values()) {
|
|
348
|
+
await writeFile(typesPathFor(at.file), declarations(at));
|
|
349
|
+
}
|
|
350
|
+
if (manifest.mode === "types") {
|
|
351
|
+
console.log(JSON.stringify(manifest, null, 2));
|
|
352
|
+
process.exit(0);
|
|
353
|
+
}
|
|
354
|
+
await typecheck([...byPath.values()]);
|
|
355
|
+
const code = [];
|
|
356
|
+
for (const at of located) {
|
|
357
|
+
code.push(await bundle(at));
|
|
358
|
+
}
|
|
359
|
+
process.stdout.write(JSON.stringify({ code }));
|
package/dist/realm.js
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// The evaluation realm: one Worker per execution, killable mid-loop by eval.ts, which owns the
|
|
2
|
+
// budget. Everything touching the script's VALUE lives here, the only realm it exists in.
|
|
3
|
+
var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
|
|
4
|
+
if (typeof path === "string" && /^\.\.?\//.test(path)) {
|
|
5
|
+
return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
|
|
6
|
+
return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
|
|
7
|
+
});
|
|
8
|
+
}
|
|
9
|
+
return path;
|
|
10
|
+
};
|
|
11
|
+
import { registerHooks } from "node:module";
|
|
12
|
+
import { parentPort } from "node:worker_threads";
|
|
13
|
+
// IMPORTED under our own URL rather than compiled from a string, so frames carry the author's
|
|
14
|
+
// line numbers and builtin imports resolve normally.
|
|
15
|
+
const SCRIPT_URL = "script:main";
|
|
16
|
+
const STACK_BYTES = 2_048;
|
|
17
|
+
// The only channel a load hook has to the source. Written per request and read once — a realm
|
|
18
|
+
// evaluates one script and is then discarded, so no second execution can observe it.
|
|
19
|
+
let source = "";
|
|
20
|
+
registerHooks({
|
|
21
|
+
resolve: (specifier, context, next) => specifier === SCRIPT_URL ? { url: SCRIPT_URL, shortCircuit: true } : next(specifier, context),
|
|
22
|
+
load: (url, context, next) => url === SCRIPT_URL ? { format: "module", source, shortCircuit: true } : next(url, context),
|
|
23
|
+
});
|
|
24
|
+
/** Keeps the frames that are the script's own. Everything below the last of them is runner
|
|
25
|
+
* plumbing the author cannot act on, and V8 puts this file's path in it. */
|
|
26
|
+
function scriptStack(err) {
|
|
27
|
+
if (!(err instanceof Error) || typeof err.stack !== "string")
|
|
28
|
+
return undefined;
|
|
29
|
+
const lines = err.stack.split("\n");
|
|
30
|
+
let last = 0;
|
|
31
|
+
for (let i = 0; i < lines.length; i++)
|
|
32
|
+
if (lines[i].includes(SCRIPT_URL))
|
|
33
|
+
last = i;
|
|
34
|
+
const stack = lines.slice(0, last + 1).join("\n");
|
|
35
|
+
return stack.length > STACK_BYTES ? stack.slice(0, STACK_BYTES) : stack;
|
|
36
|
+
}
|
|
37
|
+
function describe(err, kind) {
|
|
38
|
+
if (err instanceof Error) {
|
|
39
|
+
return { kind, name: err.name, message: err.message, stack: scriptStack(err) };
|
|
40
|
+
}
|
|
41
|
+
// A script may throw a non-Error (`throw {code: "x"}`), so name/message must not assume one.
|
|
42
|
+
return { kind, name: "Thrown", message: safeText(err) };
|
|
43
|
+
}
|
|
44
|
+
function safeText(v) {
|
|
45
|
+
try {
|
|
46
|
+
return typeof v === "string" ? v : JSON.stringify(v) ?? String(v);
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return String(v);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/** Unparseable code or an unresolvable import is broken code; anything else the import throws is
|
|
53
|
+
* the module's top level running, i.e. the script throwing. */
|
|
54
|
+
function unloadable(err) {
|
|
55
|
+
const code = err?.code;
|
|
56
|
+
return err instanceof SyntaxError || (typeof code === "string" && code.startsWith("ERR_MODULE"));
|
|
57
|
+
}
|
|
58
|
+
async function run(req) {
|
|
59
|
+
source = req.code;
|
|
60
|
+
let main;
|
|
61
|
+
try {
|
|
62
|
+
main = (await import(__rewriteRelativeImportExtension(SCRIPT_URL))).default;
|
|
63
|
+
}
|
|
64
|
+
catch (err) {
|
|
65
|
+
return { ok: false, failure: describe(err, unloadable(err) ? "compile_error" : "threw") };
|
|
66
|
+
}
|
|
67
|
+
if (typeof main !== "function") {
|
|
68
|
+
const got = main === undefined ? "no default export" : `a ${typeof main}`;
|
|
69
|
+
return {
|
|
70
|
+
ok: false,
|
|
71
|
+
failure: {
|
|
72
|
+
kind: "compile_error",
|
|
73
|
+
name: "NoDefaultExport",
|
|
74
|
+
message: `a script must export default a function; this one has ${got}`,
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
let value;
|
|
79
|
+
try {
|
|
80
|
+
value = await main(req.input);
|
|
81
|
+
}
|
|
82
|
+
catch (err) {
|
|
83
|
+
return { ok: false, failure: describe(err, "threw") };
|
|
84
|
+
}
|
|
85
|
+
try {
|
|
86
|
+
// undefined stringifies to undefined, not "undefined"; an empty body is how genroc
|
|
87
|
+
// spells null, which is the right reading of a script that returned nothing.
|
|
88
|
+
return { ok: true, body: value === undefined ? "" : JSON.stringify(value) ?? "" };
|
|
89
|
+
}
|
|
90
|
+
catch (err) {
|
|
91
|
+
return { ok: false, failure: describe(err, "nonserializable") };
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
// eval.ts terminates this thread the moment the reply lands, while stdio may still be in the
|
|
95
|
+
// pipe. An empty write's callback fires once the queue drains, making the reply a barrier.
|
|
96
|
+
function flush(stream) {
|
|
97
|
+
return new Promise((resolve) => stream.write("", () => resolve()));
|
|
98
|
+
}
|
|
99
|
+
// Non-null because this module only ever runs as a worker entry point; a null port here
|
|
100
|
+
// would mean eval.ts loaded it as a plain module, which nothing does.
|
|
101
|
+
parentPort.on("message", async (req) => {
|
|
102
|
+
const reply = await run(req);
|
|
103
|
+
await Promise.all([flush(process.stdout), flush(process.stderr)]);
|
|
104
|
+
parentPort.postMessage(reply);
|
|
105
|
+
});
|