@pieceful/ravel-js-live 0.2.0
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.md +91 -0
- package/package.json +56 -0
- package/src/analyzer.js +219 -0
- package/src/engine.js +282 -0
- package/src/index.d.ts +51 -0
- package/src/index.js +336 -0
- package/src/node.d.ts +20 -0
- package/src/node.js +100 -0
- package/src/worker-browser.d.ts +1 -0
- package/src/worker-browser.js +6 -0
- package/src/worker-node.js +9 -0
- package/src/worker-runtime.js +51 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 James Taylor
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# @pieceful/ravel-js-live
|
|
2
|
+
|
|
3
|
+
The QuickJS/WebAssembly execution provider for [Ravel](https://github.com/jostylr/ravel) 0.2.
|
|
4
|
+
It analyzes ordinary `js` and `javascript` chunks marked `.run`, resolves their
|
|
5
|
+
declared `ch("...")` and `load("...")` inputs through Ravel core, and evaluates
|
|
6
|
+
them without Node, filesystem, network, console, or output capabilities.
|
|
7
|
+
|
|
8
|
+
```sh
|
|
9
|
+
npm install @pieceful/ravel-core @pieceful/ravel-js-live
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Register the provider with core after ordinary Ravel composition:
|
|
13
|
+
|
|
14
|
+
```js
|
|
15
|
+
import { executeLiveProgram } from "@pieceful/ravel-core";
|
|
16
|
+
import { javascriptLiveProvider } from "@pieceful/ravel-js-live";
|
|
17
|
+
|
|
18
|
+
const result = await executeLiveProgram(program, {
|
|
19
|
+
providers: [javascriptLiveProvider],
|
|
20
|
+
resources: { "cool.csv": "name,value\nalpha,1\n" },
|
|
21
|
+
});
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Live modules must end with exactly one `export default`. The value may be any
|
|
25
|
+
JSON-compatible value, including an empty string, empty collection, `false`,
|
|
26
|
+
`0`, or `null`. Inputs are copied and deeply frozen before evaluation.
|
|
27
|
+
|
|
28
|
+
QuickJS runs in a persistent Node or browser worker so its compiled Wasm module
|
|
29
|
+
stays warm. Every evaluation still creates a fresh QuickJS runtime. Timeout,
|
|
30
|
+
cancellation, worker failure, or an unresponsive interrupt terminates the
|
|
31
|
+
outer worker; the provider creates a replacement for a later run. Call
|
|
32
|
+
`provider.dispose()` when a provider is no longer needed.
|
|
33
|
+
|
|
34
|
+
## Approved modules
|
|
35
|
+
|
|
36
|
+
Live code cannot resolve npm packages, paths, or URLs. A host may instead
|
|
37
|
+
register immutable, QuickJS-compatible ES module source:
|
|
38
|
+
|
|
39
|
+
```js
|
|
40
|
+
import { createJavaScriptLiveProvider } from "@pieceful/ravel-js-live";
|
|
41
|
+
|
|
42
|
+
const provider = createJavaScriptLiveProvider({
|
|
43
|
+
modules: {
|
|
44
|
+
"@ravel/csv": `
|
|
45
|
+
export const parseCsv = (text) =>
|
|
46
|
+
text.trim().split(/\\r?\\n/).map((line) => line.split(","));
|
|
47
|
+
`,
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
The live block can then use a normal static import:
|
|
53
|
+
|
|
54
|
+
```js
|
|
55
|
+
import { parseCsv } from "@ravel/csv";
|
|
56
|
+
export default parseCsv(load("cool.csv"));
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
For an npm CSV parser, the host must first bundle its pure-JavaScript dependency
|
|
60
|
+
graph into one ESM source string and register that string under an approved
|
|
61
|
+
specifier. Native addons, Node built-ins, dynamic imports, package resolution,
|
|
62
|
+
and filesystem loading remain unavailable.
|
|
63
|
+
|
|
64
|
+
Node hosts can bundle explicitly allowlisted, already-installed package exports
|
|
65
|
+
without executing them:
|
|
66
|
+
|
|
67
|
+
```js
|
|
68
|
+
import { prepareJavaScriptModules } from "@pieceful/ravel-js-live/node";
|
|
69
|
+
|
|
70
|
+
const modules = await prepareJavaScriptModules(
|
|
71
|
+
[{ specifier: "@ravel/csv", from: "csv-parse/browser/esm/sync" }],
|
|
72
|
+
{ rootDirectory: projectDirectory },
|
|
73
|
+
);
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
The `node` subpath is deliberately separate from the browser-safe provider
|
|
77
|
+
entry. Ravel does not install packages or run package lifecycle scripts.
|
|
78
|
+
|
|
79
|
+
Browser builds should emit `@pieceful/ravel-js-live/worker-browser` as a
|
|
80
|
+
separate module-worker entry and place the release-sync
|
|
81
|
+
`emscripten-module.wasm` asset beside that worker bundle. Supply the emitted
|
|
82
|
+
worker through `workerFactory` when a bundler cannot preserve the package's
|
|
83
|
+
default `new URL(..., import.meta.url)` worker location.
|
|
84
|
+
|
|
85
|
+
The worker boundary, memory/stack/time/output limits, immutable data boundary,
|
|
86
|
+
and closed module registry provide layered isolation. Pending-job quotas,
|
|
87
|
+
broader adversarial review, transform modules, and a virtual filesystem are
|
|
88
|
+
deferred to 0.3, so this package does not claim a complete hostile-code
|
|
89
|
+
sandbox.
|
|
90
|
+
|
|
91
|
+
MIT © James Taylor
|
package/package.json
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@pieceful/ravel-js-live",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "QuickJS/Wasm live JavaScript provider for Ravel.",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "James Taylor",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/jostylr/ravel.git",
|
|
11
|
+
"directory": "packages/js-live"
|
|
12
|
+
},
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/jostylr/ravel/issues"
|
|
15
|
+
},
|
|
16
|
+
"homepage": "https://ravel.jostylr.com/",
|
|
17
|
+
"engines": {
|
|
18
|
+
"node": ">=22"
|
|
19
|
+
},
|
|
20
|
+
"keywords": [
|
|
21
|
+
"ravel",
|
|
22
|
+
"quickjs",
|
|
23
|
+
"webassembly",
|
|
24
|
+
"sandbox",
|
|
25
|
+
"live-code"
|
|
26
|
+
],
|
|
27
|
+
"publishConfig": {
|
|
28
|
+
"access": "public"
|
|
29
|
+
},
|
|
30
|
+
"exports": {
|
|
31
|
+
".": "./src/index.js",
|
|
32
|
+
"./node": {
|
|
33
|
+
"types": "./src/node.d.ts",
|
|
34
|
+
"default": "./src/node.js"
|
|
35
|
+
},
|
|
36
|
+
"./worker-browser": {
|
|
37
|
+
"types": "./src/worker-browser.d.ts",
|
|
38
|
+
"default": "./src/worker-browser.js"
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
"types": "./src/index.d.ts",
|
|
42
|
+
"files": [
|
|
43
|
+
"src",
|
|
44
|
+
"README.md",
|
|
45
|
+
"LICENSE"
|
|
46
|
+
],
|
|
47
|
+
"dependencies": {
|
|
48
|
+
"@jitl/quickjs-wasmfile-release-sync": "0.32.0",
|
|
49
|
+
"acorn": "8.17.0",
|
|
50
|
+
"esbuild": "0.28.1",
|
|
51
|
+
"quickjs-emscripten": "0.32.0"
|
|
52
|
+
},
|
|
53
|
+
"peerDependencies": {
|
|
54
|
+
"@pieceful/ravel-core": "0.2.0"
|
|
55
|
+
}
|
|
56
|
+
}
|
package/src/analyzer.js
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { parse } from "acorn";
|
|
2
|
+
|
|
3
|
+
const reservedBindings = new Set(["ch", "load"]);
|
|
4
|
+
const dynamicCodeBindings = new Set(["eval", "Function"]);
|
|
5
|
+
|
|
6
|
+
export const diagnostic = (code, message, source) => ({
|
|
7
|
+
code,
|
|
8
|
+
severity: "error",
|
|
9
|
+
message,
|
|
10
|
+
source
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
const advance = (start, text) => {
|
|
14
|
+
let line = start.line;
|
|
15
|
+
let column = start.column;
|
|
16
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
17
|
+
const character = text[index];
|
|
18
|
+
if (character === "\n") {
|
|
19
|
+
line += 1;
|
|
20
|
+
column = 0;
|
|
21
|
+
} else {
|
|
22
|
+
column += 1;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return { line, column, offset: start.offset + text.length };
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const sourceAt = (sourceLocation, source, start, end = start) => ({
|
|
29
|
+
uri: sourceLocation.uri,
|
|
30
|
+
range: {
|
|
31
|
+
start: advance(sourceLocation.range.start, source.slice(0, start)),
|
|
32
|
+
end: advance(sourceLocation.range.start, source.slice(0, end))
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const bindingNames = (pattern, names = []) => {
|
|
37
|
+
if (!pattern || typeof pattern !== "object") return names;
|
|
38
|
+
if (pattern.type === "Identifier") names.push(pattern.name);
|
|
39
|
+
else if (pattern.type === "RestElement") bindingNames(pattern.argument, names);
|
|
40
|
+
else if (pattern.type === "AssignmentPattern") bindingNames(pattern.left, names);
|
|
41
|
+
else if (pattern.type === "ArrayPattern") {
|
|
42
|
+
for (const element of pattern.elements) bindingNames(element, names);
|
|
43
|
+
} else if (pattern.type === "ObjectPattern") {
|
|
44
|
+
for (const property of pattern.properties) {
|
|
45
|
+
bindingNames(property.type === "RestElement" ? property.argument : property.value, names);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return names;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const walk = (node, visit) => {
|
|
52
|
+
if (!node || typeof node !== "object" || typeof node.type !== "string") return;
|
|
53
|
+
visit(node);
|
|
54
|
+
for (const [key, value] of Object.entries(node)) {
|
|
55
|
+
if (key === "start" || key === "end" || key === "loc") continue;
|
|
56
|
+
if (Array.isArray(value)) {
|
|
57
|
+
for (const child of value) walk(child, visit);
|
|
58
|
+
} else {
|
|
59
|
+
walk(value, visit);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export const analyzeJavaScript = ({ source, sourceLocation, availableModules }) => {
|
|
65
|
+
const diagnostics = [];
|
|
66
|
+
let program;
|
|
67
|
+
try {
|
|
68
|
+
program = parse(source, {
|
|
69
|
+
ecmaVersion: "latest",
|
|
70
|
+
sourceType: "module",
|
|
71
|
+
locations: true
|
|
72
|
+
});
|
|
73
|
+
} catch (error) {
|
|
74
|
+
const start = Number.isInteger(error?.pos) ? error.pos : 0;
|
|
75
|
+
diagnostics.push(diagnostic(
|
|
76
|
+
"RJL100",
|
|
77
|
+
"JavaScript syntax error: " + (error?.message ?? String(error)),
|
|
78
|
+
sourceAt(sourceLocation, source, start, start + 1)
|
|
79
|
+
));
|
|
80
|
+
return { version: 1, dependencies: [], resources: [], modules: [], diagnostics };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const defaults = program.body.filter((node) => node.type === "ExportDefaultDeclaration");
|
|
84
|
+
if (defaults.length !== 1) {
|
|
85
|
+
diagnostics.push(diagnostic(
|
|
86
|
+
"RJL101",
|
|
87
|
+
"A live JavaScript block must contain exactly one export default.",
|
|
88
|
+
sourceLocation
|
|
89
|
+
));
|
|
90
|
+
} else if (program.body.at(-1) !== defaults[0]) {
|
|
91
|
+
diagnostics.push(diagnostic(
|
|
92
|
+
"RJL102",
|
|
93
|
+
"export default must be the final top-level statement.",
|
|
94
|
+
sourceAt(sourceLocation, source, defaults[0].start, defaults[0].end)
|
|
95
|
+
));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const dependencies = new Map();
|
|
99
|
+
const resources = new Map();
|
|
100
|
+
const modules = new Map();
|
|
101
|
+
const reportedBindings = new Set();
|
|
102
|
+
const reportedDynamicCode = new Set();
|
|
103
|
+
const reportBinding = (name, node) => {
|
|
104
|
+
if (reservedBindings.has(name) || name.startsWith("__ravel")) {
|
|
105
|
+
const key = name + ":" + node.start + ":" + node.end;
|
|
106
|
+
if (reportedBindings.has(key)) return;
|
|
107
|
+
reportedBindings.add(key);
|
|
108
|
+
diagnostics.push(diagnostic(
|
|
109
|
+
"RJL103",
|
|
110
|
+
"Live JavaScript cannot declare, assign, or access the reserved binding " + name + ".",
|
|
111
|
+
sourceAt(sourceLocation, source, node.start, node.end)
|
|
112
|
+
));
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
const reportDynamicCode = (node) => {
|
|
116
|
+
const key = node.start + ":" + node.end;
|
|
117
|
+
if (reportedDynamicCode.has(key)) return;
|
|
118
|
+
reportedDynamicCode.add(key);
|
|
119
|
+
diagnostics.push(diagnostic(
|
|
120
|
+
"RJL106",
|
|
121
|
+
"Dynamic code generation is unavailable in live JavaScript.",
|
|
122
|
+
sourceAt(sourceLocation, source, node.start, node.end)
|
|
123
|
+
));
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
walk(program, (node) => {
|
|
127
|
+
if (node.type === "Identifier" && node.name.startsWith("__ravel")) {
|
|
128
|
+
reportBinding(node.name, node);
|
|
129
|
+
}
|
|
130
|
+
if (node.type === "Identifier" && dynamicCodeBindings.has(node.name)) {
|
|
131
|
+
reportDynamicCode(node);
|
|
132
|
+
}
|
|
133
|
+
if (node.type === "MemberExpression" && node.computed &&
|
|
134
|
+
node.property.type === "Literal" && dynamicCodeBindings.has(node.property.value)) {
|
|
135
|
+
reportDynamicCode(node);
|
|
136
|
+
}
|
|
137
|
+
if (node.type === "ImportDeclaration") {
|
|
138
|
+
const specifier = node.source.value;
|
|
139
|
+
const entry = {
|
|
140
|
+
specifier,
|
|
141
|
+
source: sourceAt(sourceLocation, source, node.source.start, node.source.end)
|
|
142
|
+
};
|
|
143
|
+
modules.set(specifier, entry);
|
|
144
|
+
if (availableModules && !availableModules.has(specifier)) {
|
|
145
|
+
diagnostics.push(diagnostic(
|
|
146
|
+
"RJL108",
|
|
147
|
+
"Live JavaScript module is not approved: " + specifier,
|
|
148
|
+
entry.source
|
|
149
|
+
));
|
|
150
|
+
}
|
|
151
|
+
for (const imported of node.specifiers) reportBinding(imported.local.name, imported.local);
|
|
152
|
+
}
|
|
153
|
+
if (node.type === "ImportExpression" || node.type === "ExportNamedDeclaration" ||
|
|
154
|
+
node.type === "ExportAllDeclaration") {
|
|
155
|
+
diagnostics.push(diagnostic(
|
|
156
|
+
"RJL104",
|
|
157
|
+
"Dynamic imports, named exports, and export forwarding are unavailable in live JavaScript.",
|
|
158
|
+
sourceAt(sourceLocation, source, node.start, node.end)
|
|
159
|
+
));
|
|
160
|
+
}
|
|
161
|
+
if (node.type === "AwaitExpression") {
|
|
162
|
+
diagnostics.push(diagnostic(
|
|
163
|
+
"RJL105",
|
|
164
|
+
"await is unavailable in the initial synchronous live JavaScript profile.",
|
|
165
|
+
sourceAt(sourceLocation, source, node.start, node.end)
|
|
166
|
+
));
|
|
167
|
+
}
|
|
168
|
+
if (node.type === "VariableDeclarator") {
|
|
169
|
+
for (const name of bindingNames(node.id)) reportBinding(name, node.id);
|
|
170
|
+
}
|
|
171
|
+
if (node.type === "FunctionDeclaration" || node.type === "FunctionExpression" ||
|
|
172
|
+
node.type === "ArrowFunctionExpression") {
|
|
173
|
+
if (node.id) reportBinding(node.id.name, node.id);
|
|
174
|
+
for (const parameter of node.params) {
|
|
175
|
+
for (const name of bindingNames(parameter)) reportBinding(name, parameter);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
if (node.type === "ClassDeclaration" || node.type === "ClassExpression") {
|
|
179
|
+
if (node.id) reportBinding(node.id.name, node.id);
|
|
180
|
+
}
|
|
181
|
+
if (node.type === "CatchClause") {
|
|
182
|
+
for (const name of bindingNames(node.param)) reportBinding(name, node.param);
|
|
183
|
+
}
|
|
184
|
+
if (node.type === "AssignmentExpression") {
|
|
185
|
+
for (const name of bindingNames(node.left)) reportBinding(name, node.left);
|
|
186
|
+
}
|
|
187
|
+
if (node.type === "UpdateExpression") {
|
|
188
|
+
for (const name of bindingNames(node.argument)) reportBinding(name, node.argument);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const callName = node.type === "CallExpression" && node.callee.type === "Identifier"
|
|
192
|
+
? node.callee.name
|
|
193
|
+
: null;
|
|
194
|
+
if (callName !== "ch" && callName !== "load") return;
|
|
195
|
+
const argument = node.arguments[0];
|
|
196
|
+
if (node.arguments.length !== 1 || argument?.type !== "Literal" ||
|
|
197
|
+
typeof argument.value !== "string") {
|
|
198
|
+
diagnostics.push(diagnostic(
|
|
199
|
+
"RJL107",
|
|
200
|
+
callName + " requires one static string literal.",
|
|
201
|
+
sourceAt(sourceLocation, source, node.start, node.end)
|
|
202
|
+
));
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
const entry = {
|
|
206
|
+
...(callName === "ch" ? { reference: argument.value } : { name: argument.value }),
|
|
207
|
+
source: sourceAt(sourceLocation, source, node.start, node.end)
|
|
208
|
+
};
|
|
209
|
+
(callName === "ch" ? dependencies : resources).set(argument.value, entry);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
return {
|
|
213
|
+
version: 1,
|
|
214
|
+
dependencies: [...dependencies.values()],
|
|
215
|
+
resources: [...resources.values()],
|
|
216
|
+
modules: [...modules.values()],
|
|
217
|
+
diagnostics
|
|
218
|
+
};
|
|
219
|
+
};
|
package/src/engine.js
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
import { parse } from "acorn";
|
|
2
|
+
import { getQuickJS } from "quickjs-emscripten";
|
|
3
|
+
import { analyzeJavaScript, diagnostic } from "./analyzer.js";
|
|
4
|
+
|
|
5
|
+
let quickJSPromise;
|
|
6
|
+
|
|
7
|
+
export const prepareQuickJS = () => {
|
|
8
|
+
quickJSPromise ??= getQuickJS();
|
|
9
|
+
return quickJSPromise;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const bootstrapSource = (inputs, resources) => {
|
|
13
|
+
const inputJson = JSON.stringify(inputs);
|
|
14
|
+
const resourceJson = JSON.stringify(resources);
|
|
15
|
+
return `(() => {
|
|
16
|
+
"use strict";
|
|
17
|
+
const O = Object;
|
|
18
|
+
const J = JSON;
|
|
19
|
+
const keys = O.keys;
|
|
20
|
+
const descriptor = O.getOwnPropertyDescriptor;
|
|
21
|
+
const prototype = O.getPrototypeOf;
|
|
22
|
+
const objectPrototype = O.prototype;
|
|
23
|
+
const A = Array;
|
|
24
|
+
const arrayPrototype = A.prototype;
|
|
25
|
+
const isArray = A.isArray;
|
|
26
|
+
const isFiniteNumber = Number.isFinite;
|
|
27
|
+
const isSafeInteger = Number.isSafeInteger;
|
|
28
|
+
const S = Set;
|
|
29
|
+
const ownKeys = Reflect.ownKeys;
|
|
30
|
+
const hasOwn = O.hasOwn;
|
|
31
|
+
const stringify = J.stringify;
|
|
32
|
+
const inputs = J.parse(${JSON.stringify(inputJson)});
|
|
33
|
+
const resources = J.parse(${JSON.stringify(resourceJson)});
|
|
34
|
+
const freeze = (value, seen = new S()) => {
|
|
35
|
+
if (value === null || typeof value !== "object" || seen.has(value)) return value;
|
|
36
|
+
seen.add(value);
|
|
37
|
+
for (const key of keys(value)) freeze(value[key], seen);
|
|
38
|
+
return O.freeze(value);
|
|
39
|
+
};
|
|
40
|
+
freeze(inputs);
|
|
41
|
+
freeze(resources);
|
|
42
|
+
const ch = (name) => {
|
|
43
|
+
if (typeof name !== "string" || !hasOwn(inputs, name)) {
|
|
44
|
+
throw new Error("Unknown live dependency: " + String(name));
|
|
45
|
+
}
|
|
46
|
+
return inputs[name];
|
|
47
|
+
};
|
|
48
|
+
const load = (name) => {
|
|
49
|
+
if (typeof name !== "string" || !hasOwn(resources, name)) {
|
|
50
|
+
throw new Error("Unknown live resource: " + String(name));
|
|
51
|
+
}
|
|
52
|
+
return resources[name];
|
|
53
|
+
};
|
|
54
|
+
const validate = (value, path = "$", seen = new S()) => {
|
|
55
|
+
if (value === null || typeof value === "string" || typeof value === "boolean") return;
|
|
56
|
+
if (typeof value === "number") {
|
|
57
|
+
if (!isFiniteNumber(value)) throw new TypeError(path + " must be a finite number.");
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
if (typeof value !== "object") {
|
|
61
|
+
throw new TypeError(path + " has unsupported type " + typeof value + ".");
|
|
62
|
+
}
|
|
63
|
+
if (seen.has(value)) throw new TypeError(path + " contains a cycle.");
|
|
64
|
+
seen.add(value);
|
|
65
|
+
if (!isArray(value) && prototype(value) !== objectPrototype && prototype(value) !== null) {
|
|
66
|
+
throw new TypeError(path + " must be a plain record.");
|
|
67
|
+
}
|
|
68
|
+
if (isArray(value)) {
|
|
69
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
70
|
+
if (!hasOwn(value, index)) throw new TypeError(path + " must not contain array holes.");
|
|
71
|
+
const itemDescriptor = descriptor(value, index);
|
|
72
|
+
if (!itemDescriptor || !itemDescriptor.enumerable ||
|
|
73
|
+
itemDescriptor.get || itemDescriptor.set) {
|
|
74
|
+
throw new TypeError(path + "[" + index + "] must be an enumerable data property.");
|
|
75
|
+
}
|
|
76
|
+
validate(value[index], path + "[" + index + "]", seen);
|
|
77
|
+
}
|
|
78
|
+
for (const key of ownKeys(value)) {
|
|
79
|
+
if (key === "length") continue;
|
|
80
|
+
const index = typeof key === "string" && /^(?:0|[1-9][0-9]*)$/.test(key)
|
|
81
|
+
? Number(key)
|
|
82
|
+
: -1;
|
|
83
|
+
if (!isSafeInteger(index) || index < 0 || index >= value.length) {
|
|
84
|
+
throw new TypeError(path + " has a non-index array property.");
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
seen.delete(value);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
for (const key of ownKeys(value)) {
|
|
91
|
+
if (typeof key !== "string") throw new TypeError(path + " must not contain symbol keys.");
|
|
92
|
+
const valueDescriptor = descriptor(value, key);
|
|
93
|
+
if (!valueDescriptor || !valueDescriptor.enumerable ||
|
|
94
|
+
valueDescriptor.get || valueDescriptor.set) {
|
|
95
|
+
throw new TypeError(path + "." + key + " must be an enumerable data property.");
|
|
96
|
+
}
|
|
97
|
+
validate(value[key], path + "." + key, seen);
|
|
98
|
+
}
|
|
99
|
+
seen.delete(value);
|
|
100
|
+
};
|
|
101
|
+
const bridge = O.freeze({ ch, load, validate, stringify });
|
|
102
|
+
for (const name of ["eval", "Function"]) {
|
|
103
|
+
try {
|
|
104
|
+
O.defineProperty(globalThis, name, {
|
|
105
|
+
value: undefined,
|
|
106
|
+
configurable: false,
|
|
107
|
+
enumerable: false,
|
|
108
|
+
writable: false
|
|
109
|
+
});
|
|
110
|
+
} catch {}
|
|
111
|
+
}
|
|
112
|
+
O.defineProperty(globalThis, "__ravelBridge", {
|
|
113
|
+
value: bridge,
|
|
114
|
+
configurable: false,
|
|
115
|
+
enumerable: false,
|
|
116
|
+
writable: false
|
|
117
|
+
});
|
|
118
|
+
O.freeze(objectPrototype);
|
|
119
|
+
O.freeze(arrayPrototype);
|
|
120
|
+
O.freeze(J);
|
|
121
|
+
O.freeze(Number);
|
|
122
|
+
O.freeze(Reflect);
|
|
123
|
+
O.freeze(S.prototype);
|
|
124
|
+
})()`;
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const moduleSource = (source) => {
|
|
128
|
+
const program = parse(source, { ecmaVersion: "latest", sourceType: "module" });
|
|
129
|
+
const exportNode = program.body.at(-1);
|
|
130
|
+
if (!exportNode || exportNode.type !== "ExportDefaultDeclaration") return null;
|
|
131
|
+
const declaration = source.slice(exportNode.declaration.start, exportNode.declaration.end);
|
|
132
|
+
const beforeExport = source.slice(0, exportNode.start);
|
|
133
|
+
return `const {
|
|
134
|
+
ch,
|
|
135
|
+
load,
|
|
136
|
+
validate: __ravelValidate,
|
|
137
|
+
stringify: __ravelStringify
|
|
138
|
+
} = globalThis.__ravelBridge;
|
|
139
|
+
${beforeExport}
|
|
140
|
+
const __ravelValue = (${declaration});
|
|
141
|
+
__ravelValidate(__ravelValue);
|
|
142
|
+
export default __ravelStringify(__ravelValue);`;
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const quickJSError = (error) => {
|
|
146
|
+
if (error && typeof error === "object") {
|
|
147
|
+
const name = error.name ? String(error.name) + ": " : "";
|
|
148
|
+
return name + (error.message ?? JSON.stringify(error));
|
|
149
|
+
}
|
|
150
|
+
return String(error);
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const unwrapResult = (context, runtime, result) => {
|
|
154
|
+
if (result.error) {
|
|
155
|
+
runtime.setMemoryLimit(-1);
|
|
156
|
+
const error = context.dump(result.error);
|
|
157
|
+
result.error.dispose();
|
|
158
|
+
throw error;
|
|
159
|
+
}
|
|
160
|
+
return result.value;
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
const utf8Bytes = (value) => new TextEncoder().encode(value).byteLength;
|
|
164
|
+
|
|
165
|
+
export const executeQuickJS = async ({ request, options = {}, modules = {} }) => {
|
|
166
|
+
const started = performance.now();
|
|
167
|
+
const availableModules = new Set(Object.keys(modules));
|
|
168
|
+
const analysis = analyzeJavaScript({
|
|
169
|
+
source: request.source,
|
|
170
|
+
sourceLocation: request.sourceLocation,
|
|
171
|
+
availableModules
|
|
172
|
+
});
|
|
173
|
+
if (analysis.diagnostics.length) {
|
|
174
|
+
return {
|
|
175
|
+
ok: false,
|
|
176
|
+
version: 1,
|
|
177
|
+
status: "failed",
|
|
178
|
+
hasExport: false,
|
|
179
|
+
diagnostics: analysis.diagnostics,
|
|
180
|
+
durationMs: performance.now() - started
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
const entrySource = moduleSource(request.source);
|
|
184
|
+
if (!entrySource) {
|
|
185
|
+
return {
|
|
186
|
+
ok: false,
|
|
187
|
+
version: 1,
|
|
188
|
+
status: "failed",
|
|
189
|
+
hasExport: false,
|
|
190
|
+
diagnostics: [diagnostic(
|
|
191
|
+
"RJL101",
|
|
192
|
+
"A live JavaScript block must contain exactly one final export default.",
|
|
193
|
+
request.sourceLocation
|
|
194
|
+
)],
|
|
195
|
+
durationMs: performance.now() - started
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const timeoutMs = request.limits?.timeoutMs ?? options.timeoutMs ?? 1000;
|
|
200
|
+
const deadline = Date.now() + timeoutMs;
|
|
201
|
+
const QuickJS = await prepareQuickJS();
|
|
202
|
+
const runtime = QuickJS.newRuntime();
|
|
203
|
+
runtime.setMemoryLimit(request.limits?.memoryBytes ?? options.memoryBytes ?? 32 * 1024 * 1024);
|
|
204
|
+
runtime.setMaxStackSize(request.limits?.stackBytes ?? options.stackBytes ?? 512 * 1024);
|
|
205
|
+
runtime.setInterruptHandler(() => Date.now() >= deadline);
|
|
206
|
+
runtime.setModuleLoader(
|
|
207
|
+
(moduleName) => {
|
|
208
|
+
if (!Object.hasOwn(modules, moduleName)) {
|
|
209
|
+
throw new Error("Live JavaScript module is not approved: " + moduleName);
|
|
210
|
+
}
|
|
211
|
+
return modules[moduleName];
|
|
212
|
+
},
|
|
213
|
+
(_baseModuleName, requestedName) => requestedName
|
|
214
|
+
);
|
|
215
|
+
const context = runtime.newContext();
|
|
216
|
+
|
|
217
|
+
try {
|
|
218
|
+
const bootstrap = context.evalCode(bootstrapSource(
|
|
219
|
+
request.inputs ?? {},
|
|
220
|
+
request.resources ?? {}
|
|
221
|
+
), "ravel:bootstrap", { type: "global", strict: true });
|
|
222
|
+
unwrapResult(context, runtime, bootstrap).dispose();
|
|
223
|
+
|
|
224
|
+
const evaluated = context.evalCode(
|
|
225
|
+
entrySource,
|
|
226
|
+
request.sourceLocation?.uri ?? request.id ?? "ravel:entry",
|
|
227
|
+
{ type: "module" }
|
|
228
|
+
);
|
|
229
|
+
const exportsHandle = unwrapResult(context, runtime, evaluated);
|
|
230
|
+
const exportsValue = context.dump(exportsHandle);
|
|
231
|
+
exportsHandle.dispose();
|
|
232
|
+
const serialized = exportsValue?.default;
|
|
233
|
+
if (typeof serialized !== "string") {
|
|
234
|
+
throw new TypeError("Live JavaScript module did not return a serialized default export.");
|
|
235
|
+
}
|
|
236
|
+
const maxOutputBytes = request.limits?.outputBytes ??
|
|
237
|
+
options.outputBytes ??
|
|
238
|
+
8 * 1024 * 1024;
|
|
239
|
+
if (utf8Bytes(serialized) > maxOutputBytes) {
|
|
240
|
+
return {
|
|
241
|
+
ok: false,
|
|
242
|
+
version: 1,
|
|
243
|
+
status: "failed",
|
|
244
|
+
hasExport: true,
|
|
245
|
+
diagnostics: [diagnostic(
|
|
246
|
+
"RJL122",
|
|
247
|
+
"Live JavaScript output exceeded " + maxOutputBytes + " bytes.",
|
|
248
|
+
request.sourceLocation
|
|
249
|
+
)],
|
|
250
|
+
durationMs: performance.now() - started
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
return {
|
|
254
|
+
ok: true,
|
|
255
|
+
version: 1,
|
|
256
|
+
status: "succeeded",
|
|
257
|
+
hasExport: true,
|
|
258
|
+
serialized,
|
|
259
|
+
diagnostics: [],
|
|
260
|
+
durationMs: performance.now() - started
|
|
261
|
+
};
|
|
262
|
+
} catch (error) {
|
|
263
|
+
const timedOut = Date.now() >= deadline;
|
|
264
|
+
return {
|
|
265
|
+
ok: false,
|
|
266
|
+
version: 1,
|
|
267
|
+
status: "failed",
|
|
268
|
+
hasExport: true,
|
|
269
|
+
diagnostics: [diagnostic(
|
|
270
|
+
timedOut ? "RJL120" : "RJL110",
|
|
271
|
+
timedOut
|
|
272
|
+
? "Live JavaScript execution exceeded " + timeoutMs + " ms."
|
|
273
|
+
: "Live JavaScript failed: " + quickJSError(error),
|
|
274
|
+
request.sourceLocation
|
|
275
|
+
)],
|
|
276
|
+
durationMs: performance.now() - started
|
|
277
|
+
};
|
|
278
|
+
} finally {
|
|
279
|
+
context.dispose();
|
|
280
|
+
runtime.dispose();
|
|
281
|
+
}
|
|
282
|
+
};
|
package/src/index.d.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ExecutionProvider,
|
|
3
|
+
LiveAnalysis,
|
|
4
|
+
LiveExecutionOutcome,
|
|
5
|
+
LiveExecutionRequest,
|
|
6
|
+
SourceLocation
|
|
7
|
+
} from "@pieceful/ravel-core";
|
|
8
|
+
|
|
9
|
+
export interface JavaScriptLiveProviderOptions {
|
|
10
|
+
id?: string;
|
|
11
|
+
languages?: string[];
|
|
12
|
+
timeoutMs?: number;
|
|
13
|
+
memoryBytes?: number;
|
|
14
|
+
stackBytes?: number;
|
|
15
|
+
outputBytes?: number;
|
|
16
|
+
workerStartupTimeoutMs?: number;
|
|
17
|
+
workerTerminationGraceMs?: number;
|
|
18
|
+
moduleEntries?: number;
|
|
19
|
+
moduleBytes?: number;
|
|
20
|
+
modules?: Map<string, string | { source: string }> |
|
|
21
|
+
Record<string, string | { source: string }>;
|
|
22
|
+
workerFactory?: () => JavaScriptLiveWorker | Promise<JavaScriptLiveWorker>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface JavaScriptLiveWorker {
|
|
26
|
+
postMessage(message: unknown): void;
|
|
27
|
+
terminate(): unknown;
|
|
28
|
+
addEventListener?(type: "message" | "error", listener: (event: any) => void): void;
|
|
29
|
+
removeEventListener?(type: "message" | "error", listener: (event: any) => void): void;
|
|
30
|
+
on?(type: "message" | "error", listener: (value: any) => void): void;
|
|
31
|
+
off?(type: "message" | "error", listener: (value: any) => void): void;
|
|
32
|
+
ref?(): void;
|
|
33
|
+
unref?(): void;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface JavaScriptLiveProvider extends ExecutionProvider {
|
|
37
|
+
id: string;
|
|
38
|
+
version: string;
|
|
39
|
+
languages: string[];
|
|
40
|
+
analyze(request: {
|
|
41
|
+
id?: string;
|
|
42
|
+
language?: string;
|
|
43
|
+
source: string;
|
|
44
|
+
sourceLocation: SourceLocation;
|
|
45
|
+
}): LiveAnalysis;
|
|
46
|
+
execute(request: LiveExecutionRequest): Promise<LiveExecutionOutcome>;
|
|
47
|
+
dispose(): Promise<void>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function createJavaScriptLiveProvider(options?: JavaScriptLiveProviderOptions): JavaScriptLiveProvider;
|
|
51
|
+
export const javascriptLiveProvider: JavaScriptLiveProvider;
|
package/src/index.js
ADDED
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
import { analyzeJavaScript, diagnostic } from "./analyzer.js";
|
|
2
|
+
|
|
3
|
+
const providerId = "quickjs-wasm-worker";
|
|
4
|
+
const providerVersion = "0.2.0";
|
|
5
|
+
|
|
6
|
+
const moduleEntries = (modules) => {
|
|
7
|
+
if (modules instanceof Map) return [...modules.entries()];
|
|
8
|
+
if (modules && typeof modules === "object") return Object.entries(modules);
|
|
9
|
+
return [];
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const normalizeModules = (modules, options) => {
|
|
13
|
+
const entries = moduleEntries(modules).map(([specifier, value]) => {
|
|
14
|
+
const source = typeof value === "string" ? value : value?.source;
|
|
15
|
+
if (typeof specifier !== "string" || !specifier ||
|
|
16
|
+
typeof source !== "string") {
|
|
17
|
+
throw new TypeError("Live JavaScript modules require a non-empty specifier and source string.");
|
|
18
|
+
}
|
|
19
|
+
return [specifier, source];
|
|
20
|
+
});
|
|
21
|
+
const maximumEntries = options.moduleEntries ?? 100;
|
|
22
|
+
if (entries.length > maximumEntries) {
|
|
23
|
+
throw new RangeError("Live JavaScript module registry exceeds " + maximumEntries + " entries.");
|
|
24
|
+
}
|
|
25
|
+
const maximumBytes = options.moduleBytes ?? 8 * 1024 * 1024;
|
|
26
|
+
const bytes = entries.reduce(
|
|
27
|
+
(total, [specifier, source]) =>
|
|
28
|
+
total + new TextEncoder().encode(specifier).byteLength +
|
|
29
|
+
new TextEncoder().encode(source).byteLength,
|
|
30
|
+
0
|
|
31
|
+
);
|
|
32
|
+
if (bytes > maximumBytes) {
|
|
33
|
+
throw new RangeError("Live JavaScript module registry exceeds " + maximumBytes + " bytes.");
|
|
34
|
+
}
|
|
35
|
+
return Object.fromEntries(entries);
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const engineOptions = (options) => ({
|
|
39
|
+
timeoutMs: options.timeoutMs,
|
|
40
|
+
memoryBytes: options.memoryBytes,
|
|
41
|
+
stackBytes: options.stackBytes,
|
|
42
|
+
outputBytes: options.outputBytes
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const isNodeLike = () =>
|
|
46
|
+
globalThis.process?.release?.name === "node" ||
|
|
47
|
+
typeof globalThis.Bun?.version === "string";
|
|
48
|
+
|
|
49
|
+
const createDefaultWorker = async () => {
|
|
50
|
+
if (isNodeLike()) {
|
|
51
|
+
const workerThreadsSpecifier = "node:" + "worker_threads";
|
|
52
|
+
const { Worker } = await import(workerThreadsSpecifier);
|
|
53
|
+
return new Worker(new URL("./worker-node.js", import.meta.url), {
|
|
54
|
+
type: "module",
|
|
55
|
+
name: "ravel-quickjs",
|
|
56
|
+
// Test runners, profilers, and `node --input-type` can inject flags that
|
|
57
|
+
// are invalid for file-backed workers. The sandbox worker needs none of
|
|
58
|
+
// the parent's Node execution flags.
|
|
59
|
+
execArgv: []
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
if (typeof globalThis.Worker === "function") {
|
|
63
|
+
return new globalThis.Worker(new URL("./worker-browser.js", import.meta.url), {
|
|
64
|
+
type: "module",
|
|
65
|
+
name: "ravel-quickjs"
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
throw new Error("This host does not provide a supported Worker implementation.");
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const workerMessage = (worker, listener) => {
|
|
72
|
+
if (typeof worker.addEventListener === "function") {
|
|
73
|
+
const wrapped = (event) => listener(event.data);
|
|
74
|
+
worker.addEventListener("message", wrapped);
|
|
75
|
+
return () => worker.removeEventListener("message", wrapped);
|
|
76
|
+
}
|
|
77
|
+
worker.on("message", listener);
|
|
78
|
+
return () => worker.off("message", listener);
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const workerError = (worker, listener) => {
|
|
82
|
+
if (typeof worker.addEventListener === "function") {
|
|
83
|
+
const wrapped = (event) => listener(event.error ?? new Error(event.message));
|
|
84
|
+
worker.addEventListener("error", wrapped);
|
|
85
|
+
return () => worker.removeEventListener("error", wrapped);
|
|
86
|
+
}
|
|
87
|
+
worker.on("error", listener);
|
|
88
|
+
return () => worker.off("error", listener);
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const workerExit = (worker, listener) => {
|
|
92
|
+
if (typeof worker.on !== "function") return () => {};
|
|
93
|
+
worker.on("exit", listener);
|
|
94
|
+
return () => worker.off("exit", listener);
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const postToWorker = (worker, message) => worker.postMessage(message);
|
|
98
|
+
|
|
99
|
+
const stopWorker = async (worker) => {
|
|
100
|
+
try {
|
|
101
|
+
await worker.terminate();
|
|
102
|
+
} catch {
|
|
103
|
+
// A worker that already exited is already isolated from the host.
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const waitForWorkerConfiguration = (worker, modules, options) => new Promise((resolve, reject) => {
|
|
108
|
+
let phase = "ready";
|
|
109
|
+
const timeout = setTimeout(() => {
|
|
110
|
+
cleanup();
|
|
111
|
+
reject(new Error("QuickJS worker startup timed out."));
|
|
112
|
+
}, options.workerStartupTimeoutMs ?? 15_000);
|
|
113
|
+
const removeMessage = workerMessage(worker, (message) => {
|
|
114
|
+
if (phase === "ready" && message?.type === "ready") {
|
|
115
|
+
phase = "configured";
|
|
116
|
+
postToWorker(worker, {
|
|
117
|
+
type: "configure",
|
|
118
|
+
modules,
|
|
119
|
+
options: engineOptions(options)
|
|
120
|
+
});
|
|
121
|
+
} else if (phase === "configured" && message?.type === "configured") {
|
|
122
|
+
cleanup();
|
|
123
|
+
resolve();
|
|
124
|
+
} else if (message?.type === "startup-error") {
|
|
125
|
+
cleanup();
|
|
126
|
+
reject(new Error(message.message));
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
const removeError = workerError(worker, (error) => {
|
|
130
|
+
cleanup();
|
|
131
|
+
reject(error);
|
|
132
|
+
});
|
|
133
|
+
const removeExit = workerExit(worker, (code) => {
|
|
134
|
+
cleanup();
|
|
135
|
+
reject(new Error("QuickJS worker exited during startup with code " + code + "."));
|
|
136
|
+
});
|
|
137
|
+
const cleanup = () => {
|
|
138
|
+
clearTimeout(timeout);
|
|
139
|
+
removeMessage();
|
|
140
|
+
removeError();
|
|
141
|
+
removeExit();
|
|
142
|
+
};
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
const failureOutcome = (code, message, source) => ({
|
|
146
|
+
ok: false,
|
|
147
|
+
version: 1,
|
|
148
|
+
status: "failed",
|
|
149
|
+
hasExport: false,
|
|
150
|
+
diagnostics: [diagnostic(code, message, source)]
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
export const createJavaScriptLiveProvider = (options = {}) => {
|
|
154
|
+
const modules = normalizeModules(options.modules, options);
|
|
155
|
+
const availableModules = new Set(Object.keys(modules));
|
|
156
|
+
let workerPromise;
|
|
157
|
+
let executionSequence = 0;
|
|
158
|
+
let tail = Promise.resolve();
|
|
159
|
+
let disposed = false;
|
|
160
|
+
let cancelCurrent;
|
|
161
|
+
|
|
162
|
+
const discardWorker = async () => {
|
|
163
|
+
const pending = workerPromise;
|
|
164
|
+
workerPromise = undefined;
|
|
165
|
+
if (!pending) return;
|
|
166
|
+
try {
|
|
167
|
+
const worker = await pending;
|
|
168
|
+
await stopWorker(worker);
|
|
169
|
+
} catch {
|
|
170
|
+
// Startup failures have no usable worker to retain.
|
|
171
|
+
}
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
const workerForExecution = () => {
|
|
175
|
+
if (!workerPromise) {
|
|
176
|
+
const created = (async () => {
|
|
177
|
+
const worker = await (options.workerFactory?.() ?? createDefaultWorker());
|
|
178
|
+
try {
|
|
179
|
+
await waitForWorkerConfiguration(worker, modules, options);
|
|
180
|
+
worker.unref?.();
|
|
181
|
+
return worker;
|
|
182
|
+
} catch (error) {
|
|
183
|
+
await stopWorker(worker);
|
|
184
|
+
throw error;
|
|
185
|
+
}
|
|
186
|
+
})();
|
|
187
|
+
workerPromise = created;
|
|
188
|
+
created.catch(() => {
|
|
189
|
+
if (workerPromise === created) workerPromise = undefined;
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
return workerPromise;
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
const executeInWorker = async (request) => {
|
|
196
|
+
if (disposed) {
|
|
197
|
+
return failureOutcome(
|
|
198
|
+
"RJL131",
|
|
199
|
+
"The QuickJS worker provider has been disposed.",
|
|
200
|
+
request.sourceLocation
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
if (request.signal?.aborted) {
|
|
204
|
+
return failureOutcome("RJL121", "Live JavaScript execution was cancelled.", request.sourceLocation);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
let worker;
|
|
208
|
+
try {
|
|
209
|
+
worker = await workerForExecution();
|
|
210
|
+
} catch (error) {
|
|
211
|
+
return failureOutcome(
|
|
212
|
+
"RJL130",
|
|
213
|
+
"QuickJS worker could not start: " + (error?.message ?? String(error)),
|
|
214
|
+
request.sourceLocation
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
if (request.signal?.aborted) {
|
|
218
|
+
await discardWorker();
|
|
219
|
+
return failureOutcome(
|
|
220
|
+
"RJL121",
|
|
221
|
+
"Live JavaScript execution was cancelled and its worker was terminated.",
|
|
222
|
+
request.sourceLocation
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
worker.ref?.();
|
|
227
|
+
const executionId = ++executionSequence;
|
|
228
|
+
const timeoutMs = request.limits?.timeoutMs ?? options.timeoutMs ?? 1000;
|
|
229
|
+
const terminationGraceMs = options.workerTerminationGraceMs ?? 100;
|
|
230
|
+
const clonedRequest = { ...request };
|
|
231
|
+
delete clonedRequest.signal;
|
|
232
|
+
|
|
233
|
+
return new Promise((resolve) => {
|
|
234
|
+
let settled = false;
|
|
235
|
+
const settle = (outcome, replaceWorker = false) => {
|
|
236
|
+
if (settled) return;
|
|
237
|
+
settled = true;
|
|
238
|
+
cleanup();
|
|
239
|
+
if (cancelCurrent === cancelExecution) cancelCurrent = undefined;
|
|
240
|
+
worker.unref?.();
|
|
241
|
+
if (replaceWorker) void discardWorker();
|
|
242
|
+
resolve(outcome);
|
|
243
|
+
};
|
|
244
|
+
const removeMessage = workerMessage(worker, (message) => {
|
|
245
|
+
if (message?.type !== "result" || message.executionId !== executionId) return;
|
|
246
|
+
settle(message.outcome);
|
|
247
|
+
});
|
|
248
|
+
const removeError = workerError(worker, (error) => {
|
|
249
|
+
settle(failureOutcome(
|
|
250
|
+
"RJL130",
|
|
251
|
+
"QuickJS worker failed: " + (error?.message ?? String(error)),
|
|
252
|
+
request.sourceLocation
|
|
253
|
+
), true);
|
|
254
|
+
});
|
|
255
|
+
const removeExit = workerExit(worker, (code) => {
|
|
256
|
+
settle(failureOutcome(
|
|
257
|
+
"RJL130",
|
|
258
|
+
"QuickJS worker exited unexpectedly with code " + code + ".",
|
|
259
|
+
request.sourceLocation
|
|
260
|
+
), true);
|
|
261
|
+
});
|
|
262
|
+
const timeout = setTimeout(() => {
|
|
263
|
+
settle(failureOutcome(
|
|
264
|
+
"RJL120",
|
|
265
|
+
"QuickJS worker was terminated after exceeding " + timeoutMs + " ms.",
|
|
266
|
+
request.sourceLocation
|
|
267
|
+
), true);
|
|
268
|
+
}, timeoutMs + terminationGraceMs);
|
|
269
|
+
const onAbort = () => {
|
|
270
|
+
settle(failureOutcome(
|
|
271
|
+
"RJL121",
|
|
272
|
+
"Live JavaScript execution was cancelled and its worker was terminated.",
|
|
273
|
+
request.sourceLocation
|
|
274
|
+
), true);
|
|
275
|
+
};
|
|
276
|
+
const cancelExecution = () => {
|
|
277
|
+
settle(failureOutcome(
|
|
278
|
+
"RJL131",
|
|
279
|
+
"The QuickJS worker provider was disposed during execution.",
|
|
280
|
+
request.sourceLocation
|
|
281
|
+
), true);
|
|
282
|
+
};
|
|
283
|
+
cancelCurrent = cancelExecution;
|
|
284
|
+
const cleanup = () => {
|
|
285
|
+
clearTimeout(timeout);
|
|
286
|
+
removeMessage();
|
|
287
|
+
removeError();
|
|
288
|
+
removeExit();
|
|
289
|
+
request.signal?.removeEventListener("abort", onAbort);
|
|
290
|
+
};
|
|
291
|
+
request.signal?.addEventListener("abort", onAbort, { once: true });
|
|
292
|
+
if (request.signal?.aborted) {
|
|
293
|
+
onAbort();
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
try {
|
|
298
|
+
postToWorker(worker, {
|
|
299
|
+
type: "execute",
|
|
300
|
+
executionId,
|
|
301
|
+
request: clonedRequest
|
|
302
|
+
});
|
|
303
|
+
} catch (error) {
|
|
304
|
+
settle(failureOutcome(
|
|
305
|
+
"RJL130",
|
|
306
|
+
"QuickJS worker request failed: " + (error?.message ?? String(error)),
|
|
307
|
+
request.sourceLocation
|
|
308
|
+
), true);
|
|
309
|
+
}
|
|
310
|
+
});
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
const provider = {
|
|
314
|
+
id: options.id ?? providerId,
|
|
315
|
+
version: providerVersion,
|
|
316
|
+
languages: options.languages ?? ["js", "javascript"],
|
|
317
|
+
analyze: (request) => analyzeJavaScript({ ...request, availableModules }),
|
|
318
|
+
execute: (request) => {
|
|
319
|
+
const scheduled = tail.then(
|
|
320
|
+
() => executeInWorker(request),
|
|
321
|
+
() => executeInWorker(request)
|
|
322
|
+
);
|
|
323
|
+
tail = scheduled.then(() => undefined, () => undefined);
|
|
324
|
+
return scheduled;
|
|
325
|
+
},
|
|
326
|
+
dispose: async () => {
|
|
327
|
+
disposed = true;
|
|
328
|
+
cancelCurrent?.();
|
|
329
|
+
await tail;
|
|
330
|
+
await discardWorker();
|
|
331
|
+
}
|
|
332
|
+
};
|
|
333
|
+
return provider;
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
export const javascriptLiveProvider = createJavaScriptLiveProvider();
|
package/src/node.d.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { Diagnostic, SourceLocation } from "@pieceful/ravel-core";
|
|
2
|
+
|
|
3
|
+
export interface JavaScriptModuleDeclaration {
|
|
4
|
+
specifier: string;
|
|
5
|
+
from: string;
|
|
6
|
+
source?: SourceLocation;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export class JavaScriptModulePreparationError extends Error {
|
|
10
|
+
diagnostics: Diagnostic[];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function prepareJavaScriptModules(
|
|
14
|
+
declarations: JavaScriptModuleDeclaration[],
|
|
15
|
+
options: {
|
|
16
|
+
rootDirectory: string;
|
|
17
|
+
moduleEntries?: number;
|
|
18
|
+
moduleBytes?: number;
|
|
19
|
+
}
|
|
20
|
+
): Promise<Record<string, string>>;
|
package/src/node.js
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { Buffer } from "node:buffer";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { build as bundleWithEsbuild } from "esbuild";
|
|
4
|
+
|
|
5
|
+
const packageExportPattern = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*(?:\/[a-z0-9][a-z0-9._-]*)*$/i;
|
|
6
|
+
|
|
7
|
+
const fallbackSource = (uri = "<ravel-config>") => ({
|
|
8
|
+
uri,
|
|
9
|
+
range: {
|
|
10
|
+
start: { line: 0, column: 0, offset: 0 },
|
|
11
|
+
end: { line: 0, column: 0, offset: 0 }
|
|
12
|
+
}
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
/** Expected failure while preparing a configured package for QuickJS. */
|
|
16
|
+
export class JavaScriptModulePreparationError extends Error {
|
|
17
|
+
constructor(diagnostics) {
|
|
18
|
+
super(diagnostics.map((entry) => entry.message).join(" "));
|
|
19
|
+
this.name = "JavaScriptModulePreparationError";
|
|
20
|
+
this.diagnostics = diagnostics;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const preparationError = (message, source) => new JavaScriptModulePreparationError([{
|
|
25
|
+
code: "RJL140",
|
|
26
|
+
severity: "error",
|
|
27
|
+
message,
|
|
28
|
+
source: source?.uri ? source : fallbackSource()
|
|
29
|
+
}]);
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Bundle explicitly allowlisted, already-installed package exports for a
|
|
33
|
+
* closed live-JavaScript module registry. Package code is read but not run.
|
|
34
|
+
*/
|
|
35
|
+
export const prepareJavaScriptModules = async (declarations, {
|
|
36
|
+
rootDirectory,
|
|
37
|
+
moduleEntries = 100,
|
|
38
|
+
moduleBytes = 8 * 1024 * 1024
|
|
39
|
+
} = {}) => {
|
|
40
|
+
if (!Array.isArray(declarations)) throw new TypeError("Live module declarations must be an array.");
|
|
41
|
+
if (declarations.length > moduleEntries) {
|
|
42
|
+
throw preparationError(
|
|
43
|
+
"Live module count exceeds the configured limit of " + moduleEntries + ".",
|
|
44
|
+
declarations[0]?.source
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
if (typeof rootDirectory !== "string" || !rootDirectory) {
|
|
48
|
+
throw new TypeError("rootDirectory must be a non-empty string.");
|
|
49
|
+
}
|
|
50
|
+
const root = resolve(rootDirectory);
|
|
51
|
+
const modules = {};
|
|
52
|
+
let bytes = 0;
|
|
53
|
+
|
|
54
|
+
for (const declaration of declarations) {
|
|
55
|
+
const source = declaration?.source;
|
|
56
|
+
const specifier = declaration?.specifier;
|
|
57
|
+
const packageExport = declaration?.from;
|
|
58
|
+
if (typeof specifier !== "string" || !specifier ||
|
|
59
|
+
typeof packageExport !== "string" || !packageExportPattern.test(packageExport)) {
|
|
60
|
+
throw preparationError("Invalid live JavaScript module declaration.", source);
|
|
61
|
+
}
|
|
62
|
+
if (Object.hasOwn(modules, specifier)) {
|
|
63
|
+
throw preparationError("Duplicate live JavaScript module specifier: " + specifier, source);
|
|
64
|
+
}
|
|
65
|
+
let result;
|
|
66
|
+
try {
|
|
67
|
+
result = await bundleWithEsbuild({
|
|
68
|
+
absWorkingDir: root,
|
|
69
|
+
entryPoints: [packageExport],
|
|
70
|
+
bundle: true,
|
|
71
|
+
write: false,
|
|
72
|
+
format: "esm",
|
|
73
|
+
platform: "neutral",
|
|
74
|
+
target: "es2023",
|
|
75
|
+
packages: "bundle",
|
|
76
|
+
legalComments: "none",
|
|
77
|
+
logLevel: "silent"
|
|
78
|
+
});
|
|
79
|
+
} catch (error) {
|
|
80
|
+
const detail = error?.errors?.[0]?.text ?? error?.message ?? String(error);
|
|
81
|
+
throw preparationError(
|
|
82
|
+
"Unable to prepare live module " + specifier + " from " + packageExport + ": " + detail,
|
|
83
|
+
source
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
if (result.outputFiles?.length !== 1) {
|
|
87
|
+
throw preparationError("Live module " + specifier + " did not produce one ESM bundle.", source);
|
|
88
|
+
}
|
|
89
|
+
const bundled = result.outputFiles[0].text;
|
|
90
|
+
bytes += Buffer.byteLength(bundled, "utf8");
|
|
91
|
+
if (bytes > moduleBytes) {
|
|
92
|
+
throw preparationError(
|
|
93
|
+
"Live module source exceeds the configured limit of " + moduleBytes + " bytes.",
|
|
94
|
+
source
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
modules[specifier] = bundled;
|
|
98
|
+
}
|
|
99
|
+
return modules;
|
|
100
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { parentPort } from "node:worker_threads";
|
|
2
|
+
import { startWorkerRuntime } from "./worker-runtime.js";
|
|
3
|
+
|
|
4
|
+
if (!parentPort) throw new Error("The QuickJS Node worker requires a parent port.");
|
|
5
|
+
|
|
6
|
+
await startWorkerRuntime({
|
|
7
|
+
post: (message) => parentPort.postMessage(message),
|
|
8
|
+
listen: (listener) => parentPort.on("message", listener)
|
|
9
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { diagnostic } from "./analyzer.js";
|
|
2
|
+
import { executeQuickJS, prepareQuickJS } from "./engine.js";
|
|
3
|
+
|
|
4
|
+
export const startWorkerRuntime = async ({ post, listen }) => {
|
|
5
|
+
try {
|
|
6
|
+
await prepareQuickJS();
|
|
7
|
+
post({ type: "ready" });
|
|
8
|
+
} catch (error) {
|
|
9
|
+
post({
|
|
10
|
+
type: "startup-error",
|
|
11
|
+
message: error?.message ?? String(error)
|
|
12
|
+
});
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
let configuration = { options: {}, modules: {} };
|
|
17
|
+
listen(async (message) => {
|
|
18
|
+
if (message?.type === "configure") {
|
|
19
|
+
configuration = {
|
|
20
|
+
options: message.options ?? {},
|
|
21
|
+
modules: message.modules ?? {}
|
|
22
|
+
};
|
|
23
|
+
post({ type: "configured" });
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
if (message?.type !== "execute") return;
|
|
27
|
+
const { executionId, request } = message;
|
|
28
|
+
post({ type: "started", executionId });
|
|
29
|
+
let outcome;
|
|
30
|
+
try {
|
|
31
|
+
outcome = await executeQuickJS({
|
|
32
|
+
request,
|
|
33
|
+
options: configuration.options,
|
|
34
|
+
modules: configuration.modules
|
|
35
|
+
});
|
|
36
|
+
} catch (error) {
|
|
37
|
+
outcome = {
|
|
38
|
+
ok: false,
|
|
39
|
+
version: 1,
|
|
40
|
+
status: "failed",
|
|
41
|
+
hasExport: false,
|
|
42
|
+
diagnostics: [diagnostic(
|
|
43
|
+
"RJL130",
|
|
44
|
+
"QuickJS worker failed: " + (error?.message ?? String(error)),
|
|
45
|
+
request.sourceLocation
|
|
46
|
+
)]
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
post({ type: "result", executionId, outcome });
|
|
50
|
+
});
|
|
51
|
+
};
|