@eryx-org/eryx 0.0.0 → 0.15.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/README.md +281 -4
- package/eryx-sandbox.conditional-host-import-0.wasm +0 -0
- package/eryx-sandbox.conditional-host-import-1.wasm +0 -0
- package/eryx-sandbox.conditional-suspending-1-i32-to-i32.wasm +0 -0
- package/eryx-sandbox.core.wasm +0 -0
- package/eryx-sandbox.core10.wasm +0 -0
- package/eryx-sandbox.core11.wasm +0 -0
- package/eryx-sandbox.core12.wasm +0 -0
- package/eryx-sandbox.core13.wasm +0 -0
- package/eryx-sandbox.core2.wasm +0 -0
- package/eryx-sandbox.core3.wasm +0 -0
- package/eryx-sandbox.core4.wasm +0 -0
- package/eryx-sandbox.core5.wasm +0 -0
- package/eryx-sandbox.core6.wasm +0 -0
- package/eryx-sandbox.core7.wasm +0 -0
- package/eryx-sandbox.core8.wasm +0 -0
- package/eryx-sandbox.core9.wasm +0 -0
- package/eryx-sandbox.d.ts +163 -0
- package/eryx-sandbox.js +43541 -0
- package/index.d.ts +331 -0
- package/index.js +698 -0
- package/instance.js +174 -0
- package/interfaces/eryx-net-tcp.d.ts +30 -0
- package/interfaces/eryx-net-tls.d.ts +24 -0
- package/interfaces/wasi-cli-environment.d.ts +3 -0
- package/interfaces/wasi-cli-exit.d.ts +3 -0
- package/interfaces/wasi-cli-stderr.d.ts +3 -0
- package/interfaces/wasi-cli-stdin.d.ts +3 -0
- package/interfaces/wasi-cli-stdout.d.ts +3 -0
- package/interfaces/wasi-cli-terminal-input.d.ts +8 -0
- package/interfaces/wasi-cli-terminal-output.d.ts +8 -0
- package/interfaces/wasi-cli-terminal-stderr.d.ts +3 -0
- package/interfaces/wasi-cli-terminal-stdin.d.ts +3 -0
- package/interfaces/wasi-cli-terminal-stdout.d.ts +3 -0
- package/interfaces/wasi-clocks-monotonic-clock.d.ts +8 -0
- package/interfaces/wasi-clocks-wall-clock.d.ts +7 -0
- package/interfaces/wasi-filesystem-preopens.d.ts +3 -0
- package/interfaces/wasi-filesystem-types.d.ts +207 -0
- package/interfaces/wasi-io-error.d.ts +8 -0
- package/interfaces/wasi-io-poll.d.ts +10 -0
- package/interfaces/wasi-io-streams.d.ts +33 -0
- package/interfaces/wasi-random-random.d.ts +2 -0
- package/interfaces/wasi-sockets-instance-network.d.ts +3 -0
- package/interfaces/wasi-sockets-ip-name-lookup.d.ts +15 -0
- package/interfaces/wasi-sockets-network.d.ts +92 -0
- package/interfaces/wasi-sockets-tcp-create-socket.d.ts +5 -0
- package/interfaces/wasi-sockets-tcp.d.ts +52 -0
- package/interfaces/wasi-sockets-udp-create-socket.d.ts +5 -0
- package/interfaces/wasi-sockets-udp.d.ts +51 -0
- package/package.json +69 -3
- package/python-stdlib.tar.gz +0 -0
- package/shims/callbacks.d.ts +45 -0
- package/shims/callbacks.js +469 -0
- package/shims/ip.js +46 -0
- package/shims/net-fetch.js +264 -0
- package/shims/net-node.js +236 -0
- package/shims/net.d.ts +84 -0
- package/shims/net.js +255 -0
- package/shims/preview2-node.js +35 -0
- package/shims/raw-json.js +111 -0
- package/shims/sockets.js +53 -0
- package/stdlib-loader.js +174 -0
- package/worker.js +100 -0
package/shims/net.js
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Network shims for the eryx sandbox (eryx:net/tcp and eryx:net/tls).
|
|
3
|
+
*
|
|
4
|
+
* Networking is disabled until the host sets a policy, per sandbox or as the
|
|
5
|
+
* default with setNetConfig(). Host policy mirrors the Rust crate's NetConfig:
|
|
6
|
+
* blocked patterns are always enforced, then the host must match allowedHosts
|
|
7
|
+
* unless allowAllHosts is set. Hosts are
|
|
8
|
+
* matched in their WHATWG URL form too, so `2130706433` and `0x7f.1` read as
|
|
9
|
+
* `127.0.0.1`. IP addresses are checked against blockedNetworks (minus
|
|
10
|
+
* allowedNetworks); in Node, so are the addresses a name resolves to, unless
|
|
11
|
+
* the host matches internalHosts. Browsers don't expose DNS answers, so there
|
|
12
|
+
* only an allowlist keeps names that resolve to private addresses out.
|
|
13
|
+
*
|
|
14
|
+
* The transport depends on the environment:
|
|
15
|
+
* - Node.js: real TCP/TLS sockets (net-node.js)
|
|
16
|
+
* - Browsers: HTTP(S) only, replayed through fetch() (net-fetch.js)
|
|
17
|
+
*
|
|
18
|
+
* The guest calls these imports synchronously; they return Promises and are
|
|
19
|
+
* transpiled as JSPI async imports, which suspend the wasm stack until they
|
|
20
|
+
* settle. jco lowers `result<T, E>` imports as: return T, or throw
|
|
21
|
+
* `{ payload: E }`.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const isNode =
|
|
25
|
+
typeof globalThis.process !== "undefined" &&
|
|
26
|
+
typeof globalThis.process.versions?.node !== "undefined";
|
|
27
|
+
|
|
28
|
+
const transport = isNode ? await import("./net-node.js") : await import("./net-fetch.js");
|
|
29
|
+
|
|
30
|
+
/** The Rust crate's `NetConfig::default().blocked_hosts`, plus `0.0.0.0` and `[::]`. */
|
|
31
|
+
export const DEFAULT_BLOCKED_HOSTS = Object.freeze([
|
|
32
|
+
"localhost",
|
|
33
|
+
"*.localhost",
|
|
34
|
+
"127.*",
|
|
35
|
+
"10.*",
|
|
36
|
+
...Array.from({ length: 16 }, (_, i) => `172.${16 + i}.*`),
|
|
37
|
+
"192.168.*",
|
|
38
|
+
"169.254.*",
|
|
39
|
+
"[::1]",
|
|
40
|
+
"0.0.0.0",
|
|
41
|
+
"[::]",
|
|
42
|
+
]);
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The Rust crate's `DEFAULT_BLOCKED_NETWORKS`. IPv4-mapped, IPv4-compatible,
|
|
46
|
+
* NAT64 and Teredo addresses are also checked by the IPv4 address they embed.
|
|
47
|
+
*/
|
|
48
|
+
export const DEFAULT_BLOCKED_NETWORKS = Object.freeze([
|
|
49
|
+
"0.0.0.0/8",
|
|
50
|
+
"10.0.0.0/8",
|
|
51
|
+
"100.64.0.0/10",
|
|
52
|
+
"127.0.0.0/8",
|
|
53
|
+
"169.254.0.0/16",
|
|
54
|
+
"172.16.0.0/12",
|
|
55
|
+
"192.0.0.0/24",
|
|
56
|
+
"192.168.0.0/16",
|
|
57
|
+
"198.18.0.0/15",
|
|
58
|
+
"224.0.0.0/4",
|
|
59
|
+
"240.0.0.0/4",
|
|
60
|
+
"::/96",
|
|
61
|
+
"2001::/32",
|
|
62
|
+
"2001:db8::/32",
|
|
63
|
+
"2002::/16",
|
|
64
|
+
"64:ff9b:1::/48",
|
|
65
|
+
"fc00::/7",
|
|
66
|
+
"fe80::/10",
|
|
67
|
+
"fec0::/10",
|
|
68
|
+
"ff00::/8",
|
|
69
|
+
]);
|
|
70
|
+
|
|
71
|
+
/** Same defaults as the Rust crate's `NetConfig::default()`. */
|
|
72
|
+
const DEFAULT_MAX_CONNECTIONS = 10;
|
|
73
|
+
const DEFAULT_CONNECT_TIMEOUT_MS = 30_000;
|
|
74
|
+
const DEFAULT_IO_TIMEOUT_MS = 60_000;
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* A checked policy; `isAllowed` checks resolved addresses (null where the
|
|
78
|
+
* transport can't see them).
|
|
79
|
+
*
|
|
80
|
+
* @typedef {Required<import("./net.js").NetConfig> & { isAllowed: ((address: string, family: number) => boolean) | null }} Policy
|
|
81
|
+
*/
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The policy new instances start with, set by setNetConfig().
|
|
85
|
+
* @type {Policy | null}
|
|
86
|
+
*/
|
|
87
|
+
let defaultPolicy = null;
|
|
88
|
+
|
|
89
|
+
/** Strict CIDR syntax, so a typo can't parse as a wider network (`10.0.0.0/` as `/0`). */
|
|
90
|
+
function checkCidrs(cidrs) {
|
|
91
|
+
for (const cidr of cidrs) {
|
|
92
|
+
const m = /^([0-9a-fA-F:.]+)\/(\d{1,3})$/.exec(cidr);
|
|
93
|
+
if (!m || Number(m[2]) > (m[1].includes(":") ? 128 : 32)) {
|
|
94
|
+
throw new TypeError(`invalid CIDR '${cidr}'`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return cidrs;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** A non-negative integer, or a TypeError. */
|
|
101
|
+
function checkCount(name, value) {
|
|
102
|
+
if (!Number.isInteger(value) || value < 0) {
|
|
103
|
+
throw new TypeError(`${name} must be a non-negative integer, got ${value}`);
|
|
104
|
+
}
|
|
105
|
+
return value;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** A timeout above zero, or a TypeError: zero would time out every connection, as in Rust. */
|
|
109
|
+
function checkTimeout(name, value) {
|
|
110
|
+
if (typeof value !== "number" || !(value > 0)) {
|
|
111
|
+
throw new TypeError(`${name} must be greater than zero, got ${value}`);
|
|
112
|
+
}
|
|
113
|
+
return value;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Check `netConfig` and fill in the defaults; null disables networking.
|
|
118
|
+
*
|
|
119
|
+
* Throws a TypeError if a network is not a valid CIDR, maxConnections is
|
|
120
|
+
* not a non-negative integer, or a timeout is not greater than zero.
|
|
121
|
+
*
|
|
122
|
+
* @param {import("./net.js").NetConfig | null} netConfig
|
|
123
|
+
* @returns {Policy | null}
|
|
124
|
+
*/
|
|
125
|
+
function toPolicy(netConfig) {
|
|
126
|
+
if (!netConfig) return null;
|
|
127
|
+
// Copies, so changing the caller's arrays later can't change the policy.
|
|
128
|
+
const list = (xs, fallback) => Object.freeze([...(xs ?? fallback)]);
|
|
129
|
+
const next = {
|
|
130
|
+
allowAllHosts: netConfig.allowAllHosts ?? false,
|
|
131
|
+
allowedHosts: list(netConfig.allowedHosts, []),
|
|
132
|
+
blockedHosts: list(netConfig.blockedHosts, DEFAULT_BLOCKED_HOSTS),
|
|
133
|
+
blockedNetworks: checkCidrs(list(netConfig.blockedNetworks, DEFAULT_BLOCKED_NETWORKS)),
|
|
134
|
+
allowedNetworks: checkCidrs(list(netConfig.allowedNetworks, [])),
|
|
135
|
+
internalHosts: list(netConfig.internalHosts, []),
|
|
136
|
+
maxConnections: checkCount("maxConnections", netConfig.maxConnections ?? DEFAULT_MAX_CONNECTIONS),
|
|
137
|
+
connectTimeoutMs: checkTimeout("connectTimeoutMs", netConfig.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS),
|
|
138
|
+
ioTimeoutMs: checkTimeout("ioTimeoutMs", netConfig.ioTimeoutMs ?? DEFAULT_IO_TIMEOUT_MS),
|
|
139
|
+
};
|
|
140
|
+
return Object.freeze({ ...next, isAllowed: transport.networkFilter(next) });
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Set the default policy (`null` disables networking), which each sandbox
|
|
145
|
+
* copies when it is created; sandboxes that already exist keep theirs.
|
|
146
|
+
*
|
|
147
|
+
* @param {import("./net.js").NetConfig | null} netConfig
|
|
148
|
+
*/
|
|
149
|
+
export function setNetConfig(netConfig) {
|
|
150
|
+
defaultPolicy = toPolicy(netConfig);
|
|
151
|
+
defaultNet.setNetConfig(netConfig);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* The guest's timeout (`0` for none) clamped to the host's, so sandboxed code
|
|
156
|
+
* can shorten a timeout but never extend it.
|
|
157
|
+
*/
|
|
158
|
+
// setTimeout fires at once for delays past 2^31 - 1 ms.
|
|
159
|
+
const clamp = (hostMs, guestMs) => Math.min(guestMs ? Math.min(guestMs, hostMs) : hostMs, 2 ** 31 - 1);
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* `host` in WHATWG URL form (lower-cased, IPv4 in dotted decimal, IPv6
|
|
163
|
+
* bracketed and compressed) without a trailing dot, or null if it isn't a
|
|
164
|
+
* bare host name or address.
|
|
165
|
+
*/
|
|
166
|
+
function canonicalHost(host) {
|
|
167
|
+
// URL parsing would read these as userinfo, path or port delimiters.
|
|
168
|
+
if (!host || /[\s/?#@\\]/.test(host)) return null;
|
|
169
|
+
try {
|
|
170
|
+
return new URL(`http://${host.includes(":") ? `[${host}]` : host}`).hostname.replace(/\.$/, "");
|
|
171
|
+
} catch {
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Glob match with `*` wildcards, case-insensitive (like the Rust matcher). */
|
|
177
|
+
function hostMatches(host, pattern) {
|
|
178
|
+
const re = pattern
|
|
179
|
+
.split("*")
|
|
180
|
+
.map((part) => part.replace(/[.+?^${}()|[\]\\]/g, "\\$&"))
|
|
181
|
+
.join(".*");
|
|
182
|
+
return new RegExp(`^${re}$`, "i").test(host);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const notPermitted = (val) => ({ payload: { tag: "not-permitted", val } });
|
|
186
|
+
|
|
187
|
+
/** Check `host` against `config` and return its canonical form. */
|
|
188
|
+
function checkHost(config, host) {
|
|
189
|
+
if (!config) throw notPermitted("networking is disabled; enable it with setNetConfig()");
|
|
190
|
+
const canonical = canonicalHost(host);
|
|
191
|
+
if (canonical === null) throw notPermitted(`host '${host}' is not a valid host name`);
|
|
192
|
+
// Refusals match either spelling; allowances only the canonical one.
|
|
193
|
+
const matches = (patterns) => patterns.some((p) => hostMatches(canonical, p));
|
|
194
|
+
if (config.blockedHosts.some((p) => hostMatches(host, p)) || matches(config.blockedHosts)) {
|
|
195
|
+
throw notPermitted(`host '${host}' is blocked`);
|
|
196
|
+
}
|
|
197
|
+
if (!config.allowAllHosts && !matches(config.allowedHosts)) {
|
|
198
|
+
throw notPermitted(`host '${host}' not in allowed list`);
|
|
199
|
+
}
|
|
200
|
+
return { canonical, internal: matches(config.internalHosts) };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* The eryx:net imports for one sandbox instance, with its own connection
|
|
205
|
+
* table and policy. The policy starts as the default set with setNetConfig();
|
|
206
|
+
* `setNetConfig()` on the result replaces it for this instance only, for
|
|
207
|
+
* connections opened after the call.
|
|
208
|
+
*
|
|
209
|
+
* `closeConnections()` closes every connection the instance's code left open;
|
|
210
|
+
* the sandbox calls it after each call into the instance.
|
|
211
|
+
*/
|
|
212
|
+
export function createNet() {
|
|
213
|
+
const t = transport.createTransport();
|
|
214
|
+
let config = defaultPolicy;
|
|
215
|
+
const connectTimeout = (ms) => clamp(config?.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS, ms);
|
|
216
|
+
const ioTimeout = (ms) => clamp(config?.ioTimeoutMs ?? DEFAULT_IO_TIMEOUT_MS, ms);
|
|
217
|
+
return {
|
|
218
|
+
tcp: {
|
|
219
|
+
async connect(host, port, timeoutMs) {
|
|
220
|
+
const { canonical, internal } = checkHost(config, host);
|
|
221
|
+
// 0 means no limit, as in the Rust crate.
|
|
222
|
+
if (config.maxConnections > 0 && t.openConnections() >= config.maxConnections) {
|
|
223
|
+
throw notPermitted(`connection limit reached (${config.maxConnections})`);
|
|
224
|
+
}
|
|
225
|
+
// The transport checks the address (Node: the resolved addresses)
|
|
226
|
+
// with this; internal hosts skip it.
|
|
227
|
+
const isAllowed = internal ? null : config.isAllowed;
|
|
228
|
+
// Connect to the form that was checked, unbracketed for the transport.
|
|
229
|
+
const target = canonical.replace(/^\[(.*)\]$/, "$1");
|
|
230
|
+
return t.tcp.connect(target, port, connectTimeout(timeoutMs), isAllowed);
|
|
231
|
+
},
|
|
232
|
+
read: (h, len, timeoutMs) => t.tcp.read(h, len, ioTimeout(timeoutMs)),
|
|
233
|
+
write: (h, timeoutMs, data) => t.tcp.write(h, ioTimeout(timeoutMs), data),
|
|
234
|
+
close: t.tcp.close,
|
|
235
|
+
},
|
|
236
|
+
tls: {
|
|
237
|
+
upgrade: (h, hostname, timeoutMs) => t.tls.upgrade(h, hostname, connectTimeout(timeoutMs)),
|
|
238
|
+
read: (h, len, timeoutMs) => t.tls.read(h, len, ioTimeout(timeoutMs)),
|
|
239
|
+
write: (h, timeoutMs, data) => t.tls.write(h, ioTimeout(timeoutMs), data),
|
|
240
|
+
close: t.tls.close,
|
|
241
|
+
},
|
|
242
|
+
closeConnections: t.closeAll,
|
|
243
|
+
/** @param {import("./net.js").NetConfig | null} netConfig */
|
|
244
|
+
setNetConfig(netConfig) {
|
|
245
|
+
config = toPolicy(netConfig);
|
|
246
|
+
},
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* The default instance, for code that imports the shims directly. It follows
|
|
252
|
+
* setNetConfig().
|
|
253
|
+
*/
|
|
254
|
+
const defaultNet = createNet();
|
|
255
|
+
export const { tcp, tls, closeConnections } = defaultNet;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Node.js entry for the `#preview2/*` package imports.
|
|
3
|
+
*
|
|
4
|
+
* Under the "node" export condition, `@bytecodealliance/preview2-shim` resolves to
|
|
5
|
+
* its Node.js shims, which back WASI with the host filesystem and process and lack
|
|
6
|
+
* `_setFileData`. The sandbox needs the in-memory browser shims everywhere, so this
|
|
7
|
+
* loads them by file URL (bypassing the package's export conditions). Browsers and
|
|
8
|
+
* bundlers take the "default" branch in package.json and import them directly.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
async function load(name) {
|
|
12
|
+
const url = import.meta.resolve(`@bytecodealliance/preview2-shim/${name}`);
|
|
13
|
+
if (!url.includes("/dist/nodejs/")) {
|
|
14
|
+
throw new Error(`unexpected preview2-shim layout: ${url}`);
|
|
15
|
+
}
|
|
16
|
+
return import(url.replace("/dist/nodejs/", "/dist/browser/"));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const {
|
|
20
|
+
environment,
|
|
21
|
+
exit,
|
|
22
|
+
stderr,
|
|
23
|
+
stdin,
|
|
24
|
+
stdout,
|
|
25
|
+
terminalInput,
|
|
26
|
+
terminalOutput,
|
|
27
|
+
terminalStderr,
|
|
28
|
+
terminalStdin,
|
|
29
|
+
terminalStdout,
|
|
30
|
+
} = await load("cli");
|
|
31
|
+
export const { monotonicClock, wallClock } = await load("clocks");
|
|
32
|
+
export const { preopens, types, _setFileData, createFilesystem, InMemoryFilesystemAdapter } =
|
|
33
|
+
await load("filesystem");
|
|
34
|
+
export const { error, poll, streams } = await load("io");
|
|
35
|
+
export const { random } = await load("random");
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A JSON parser that keeps each value's source text.
|
|
3
|
+
*
|
|
4
|
+
* Callback replay needs this to store and replay callback results exactly as
|
|
5
|
+
* they were returned (`1.0` stays a float, large integers stay exact), which
|
|
6
|
+
* `JSON.parse` cannot do. Journal keys need no parsing: they are the guest's
|
|
7
|
+
* canonical argument text, used verbatim.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const SPACE = /\s*/y;
|
|
11
|
+
// Strings are scanned by hand in parseJson (linear by construction, and
|
|
12
|
+
// guest-controlled input never meets a backtracking string pattern).
|
|
13
|
+
const TOKEN =
|
|
14
|
+
/(-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)|([{}[\]:,])|(true|false|null)/y;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* A parsed JSON value that keeps its source text.
|
|
18
|
+
* @typedef {Object} JsonNode
|
|
19
|
+
* @property {"string"|"number"|"literal"|"array"|"object"} kind
|
|
20
|
+
* @property {*} value - Decoded string, number lexeme, literal text, array of
|
|
21
|
+
* nodes, or Map of key to node (duplicate keys: last wins)
|
|
22
|
+
* @property {string} raw - The node's exact source text
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Parse JSON without losing number lexemes or source text.
|
|
27
|
+
*
|
|
28
|
+
* (Not `JSON.parse` with a reviver's `context.source`: besides being missing on
|
|
29
|
+
* older engines, it intermittently returned wrong keys and no source under
|
|
30
|
+
* Node 24 during differential testing.)
|
|
31
|
+
*
|
|
32
|
+
* @param {string} text - A JSON document
|
|
33
|
+
* @returns {JsonNode}
|
|
34
|
+
* @throws {SyntaxError} If `text` is not valid JSON
|
|
35
|
+
*/
|
|
36
|
+
export function parseJson(text) {
|
|
37
|
+
let pos = 0;
|
|
38
|
+
// Returns [text consumed, string, number, punctuation, literal].
|
|
39
|
+
const next = () => {
|
|
40
|
+
const from = pos;
|
|
41
|
+
SPACE.lastIndex = pos;
|
|
42
|
+
SPACE.exec(text);
|
|
43
|
+
const start = SPACE.lastIndex;
|
|
44
|
+
if (text[start] === '"') {
|
|
45
|
+
let end = start + 1;
|
|
46
|
+
while (end < text.length && text[end] !== '"') {
|
|
47
|
+
end += text[end] === "\\" ? 2 : 1;
|
|
48
|
+
}
|
|
49
|
+
if (end >= text.length) {
|
|
50
|
+
throw new SyntaxError(`Unterminated string at position ${start}`);
|
|
51
|
+
}
|
|
52
|
+
pos = end + 1;
|
|
53
|
+
return [text.slice(from, pos), text.slice(start, pos)];
|
|
54
|
+
}
|
|
55
|
+
TOKEN.lastIndex = start;
|
|
56
|
+
const m = TOKEN.exec(text);
|
|
57
|
+
if (!m) throw new SyntaxError(`Invalid JSON at position ${start}`);
|
|
58
|
+
pos = TOKEN.lastIndex;
|
|
59
|
+
return [text.slice(from, pos), undefined, m[1], m[2], m[3]];
|
|
60
|
+
};
|
|
61
|
+
const expect = (m, punct) => {
|
|
62
|
+
if (m[3] !== punct) {
|
|
63
|
+
throw new SyntaxError(`Expected '${punct}' at position ${pos}`);
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
const node = (m) => {
|
|
67
|
+
const start = pos - m[0].trimStart().length;
|
|
68
|
+
const done = (kind, value) => ({
|
|
69
|
+
kind,
|
|
70
|
+
value,
|
|
71
|
+
raw: text.slice(start, pos),
|
|
72
|
+
});
|
|
73
|
+
if (m[1]) return done("string", JSON.parse(m[1]));
|
|
74
|
+
if (m[2]) return done("number", m[2]);
|
|
75
|
+
if (m[4]) return done("literal", m[4]);
|
|
76
|
+
if (m[3] === "[") {
|
|
77
|
+
const items = [];
|
|
78
|
+
for (let t = next(); t[3] !== "]";) {
|
|
79
|
+
if (items.length) {
|
|
80
|
+
expect(t, ",");
|
|
81
|
+
t = next();
|
|
82
|
+
}
|
|
83
|
+
items.push(node(t));
|
|
84
|
+
t = next();
|
|
85
|
+
}
|
|
86
|
+
return done("array", items);
|
|
87
|
+
}
|
|
88
|
+
if (m[3] === "{") {
|
|
89
|
+
const entries = new Map();
|
|
90
|
+
let first = true;
|
|
91
|
+
for (let t = next(); t[3] !== "}"; first = false) {
|
|
92
|
+
if (!first) {
|
|
93
|
+
expect(t, ",");
|
|
94
|
+
t = next();
|
|
95
|
+
}
|
|
96
|
+
if (!t[1]) throw new SyntaxError(`Expected a key at position ${pos}`);
|
|
97
|
+
const key = JSON.parse(t[1]);
|
|
98
|
+
expect(next(), ":");
|
|
99
|
+
entries.set(key, node(next()));
|
|
100
|
+
t = next();
|
|
101
|
+
}
|
|
102
|
+
return done("object", entries);
|
|
103
|
+
}
|
|
104
|
+
throw new SyntaxError(`Unexpected token at position ${pos}`);
|
|
105
|
+
};
|
|
106
|
+
const root = node(next());
|
|
107
|
+
if (text.slice(pos).trim() !== "") {
|
|
108
|
+
throw new SyntaxError(`Unexpected data at position ${pos}`);
|
|
109
|
+
}
|
|
110
|
+
return root;
|
|
111
|
+
}
|
package/shims/sockets.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sockets shims for the eryx sandbox.
|
|
3
|
+
*
|
|
4
|
+
* The Python sandbox imports wasi:sockets/* but doesn't actually use them
|
|
5
|
+
* (networking goes through eryx:net/tcp and eryx:net/tls instead).
|
|
6
|
+
* These stubs satisfy the imports without providing real functionality.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
class ResolveAddressStream {}
|
|
10
|
+
class TcpSocket {}
|
|
11
|
+
class UdpSocket {}
|
|
12
|
+
class IncomingDatagramStream {}
|
|
13
|
+
class OutgoingDatagramStream {}
|
|
14
|
+
class Network {}
|
|
15
|
+
|
|
16
|
+
export const instanceNetwork = {
|
|
17
|
+
instanceNetwork() {
|
|
18
|
+
return new Network();
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export const ipNameLookup = {
|
|
23
|
+
ResolveAddressStream,
|
|
24
|
+
resolveAddresses() {
|
|
25
|
+
throw new Error("networking not available in sandbox");
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export const network = {
|
|
30
|
+
Network,
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export const tcpCreateSocket = {
|
|
34
|
+
createTcpSocket() {
|
|
35
|
+
throw new Error("networking not available in sandbox");
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export const tcp = {
|
|
40
|
+
TcpSocket,
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export const udpCreateSocket = {
|
|
44
|
+
createUdpSocket() {
|
|
45
|
+
throw new Error("networking not available in sandbox");
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export const udp = {
|
|
50
|
+
UdpSocket,
|
|
51
|
+
IncomingDatagramStream,
|
|
52
|
+
OutgoingDatagramStream,
|
|
53
|
+
};
|
package/stdlib-loader.js
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Python stdlib loader for browser/Node.js environments.
|
|
3
|
+
*
|
|
4
|
+
* Fetches python-stdlib.tar.gz, decompresses it, parses the tar archive,
|
|
5
|
+
* and returns a file tree compatible with the @bytecodealliance/preview2-shim
|
|
6
|
+
* filesystem.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Parse a tar archive from a Uint8Array into a preview2-shim file tree.
|
|
11
|
+
*
|
|
12
|
+
* @param {Uint8Array} tarData - Raw tar file bytes
|
|
13
|
+
* @returns {object} File tree with { dir: { ... } } structure
|
|
14
|
+
*/
|
|
15
|
+
function parseTar(tarData) {
|
|
16
|
+
const root = { dir: {} };
|
|
17
|
+
let offset = 0;
|
|
18
|
+
|
|
19
|
+
while (offset + 512 <= tarData.length) {
|
|
20
|
+
// Read 512-byte header
|
|
21
|
+
const header = tarData.subarray(offset, offset + 512);
|
|
22
|
+
|
|
23
|
+
// Check for end-of-archive (two consecutive zero blocks)
|
|
24
|
+
if (header.every((b) => b === 0)) break;
|
|
25
|
+
|
|
26
|
+
// Extract filename: prefix (bytes 345-499) + name (bytes 0-99)
|
|
27
|
+
const prefix = readString(header, 345, 155);
|
|
28
|
+
const name = readString(header, 0, 100);
|
|
29
|
+
const fullName = prefix ? `${prefix}/${name}` : name;
|
|
30
|
+
|
|
31
|
+
// File size in octal (bytes 124-135)
|
|
32
|
+
const sizeStr = readString(header, 124, 12);
|
|
33
|
+
const size = parseInt(sizeStr, 8) || 0;
|
|
34
|
+
|
|
35
|
+
// Type flag (byte 156): '0' or '\0' = file, '5' = directory
|
|
36
|
+
const typeFlag = header[156];
|
|
37
|
+
|
|
38
|
+
offset += 512; // Move past header
|
|
39
|
+
|
|
40
|
+
if (fullName && fullName !== "pax_global_header") {
|
|
41
|
+
// Normalize path: strip leading ./ and trailing /
|
|
42
|
+
const cleanPath = fullName.replace(/^\.\//, "").replace(/\/$/, "");
|
|
43
|
+
|
|
44
|
+
if (cleanPath) {
|
|
45
|
+
if (typeFlag === 53 /* '5' = directory */) {
|
|
46
|
+
ensureDir(root, cleanPath);
|
|
47
|
+
} else if (
|
|
48
|
+
typeFlag === 48 /* '0' = regular file */ ||
|
|
49
|
+
typeFlag === 0 /* '\0' = regular file (old format) */
|
|
50
|
+
) {
|
|
51
|
+
const content = tarData.slice(offset, offset + size);
|
|
52
|
+
setFile(root, cleanPath, content);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Advance past file data (padded to 512-byte boundary)
|
|
58
|
+
offset += Math.ceil(size / 512) * 512;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return root;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Read a null-terminated string from a buffer at the given offset. */
|
|
65
|
+
function readString(buf, start, maxLen) {
|
|
66
|
+
let end = start;
|
|
67
|
+
while (end < start + maxLen && buf[end] !== 0) end++;
|
|
68
|
+
return new TextDecoder().decode(buf.subarray(start, end));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Ensure a directory path exists in the file tree. */
|
|
72
|
+
function ensureDir(root, path) {
|
|
73
|
+
const parts = path.split("/");
|
|
74
|
+
let current = root;
|
|
75
|
+
for (const part of parts) {
|
|
76
|
+
if (!part) continue;
|
|
77
|
+
if (!current.dir) current.dir = {};
|
|
78
|
+
if (!current.dir[part]) current.dir[part] = { dir: {} };
|
|
79
|
+
current = current.dir[part];
|
|
80
|
+
}
|
|
81
|
+
return current;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Set a file at the given path in the file tree. */
|
|
85
|
+
function setFile(root, path, content) {
|
|
86
|
+
const parts = path.split("/");
|
|
87
|
+
const fileName = parts.pop();
|
|
88
|
+
const dir = parts.length > 0 ? ensureDir(root, parts.join("/")) : root;
|
|
89
|
+
if (!dir.dir) dir.dir = {};
|
|
90
|
+
dir.dir[fileName] = { source: content };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Read and decompress the stdlib tar.gz in Node.js using fs and zlib.
|
|
95
|
+
* @param {string} filePath - Absolute path to the tar.gz file
|
|
96
|
+
* @returns {Promise<Uint8Array>} Decompressed tar data
|
|
97
|
+
*/
|
|
98
|
+
async function loadTarGzNode(filePath) {
|
|
99
|
+
const { readFileSync } = await import(/* webpackIgnore: true */ "node:fs");
|
|
100
|
+
const { gunzipSync } = await import(/* webpackIgnore: true */ "node:zlib");
|
|
101
|
+
const compressed = readFileSync(filePath);
|
|
102
|
+
return gunzipSync(compressed);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Read and decompress the stdlib tar.gz in the browser using fetch + DecompressionStream.
|
|
107
|
+
* @param {string} url - URL to the tar.gz file
|
|
108
|
+
* @returns {Promise<Uint8Array>} Decompressed tar data
|
|
109
|
+
*/
|
|
110
|
+
async function loadTarGzBrowser(url) {
|
|
111
|
+
const response = await fetch(url);
|
|
112
|
+
if (!response.ok) {
|
|
113
|
+
throw new Error(
|
|
114
|
+
`Failed to fetch Python stdlib: ${response.status} ${response.statusText}`,
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (typeof DecompressionStream !== "undefined") {
|
|
119
|
+
const ds = new DecompressionStream("gzip");
|
|
120
|
+
const decompressed = response.body.pipeThrough(ds);
|
|
121
|
+
const reader = decompressed.getReader();
|
|
122
|
+
const chunks = [];
|
|
123
|
+
let totalLen = 0;
|
|
124
|
+
while (true) {
|
|
125
|
+
const { done, value } = await reader.read();
|
|
126
|
+
if (done) break;
|
|
127
|
+
chunks.push(value);
|
|
128
|
+
totalLen += value.length;
|
|
129
|
+
}
|
|
130
|
+
const tarData = new Uint8Array(totalLen);
|
|
131
|
+
let pos = 0;
|
|
132
|
+
for (const chunk of chunks) {
|
|
133
|
+
tarData.set(chunk, pos);
|
|
134
|
+
pos += chunk.length;
|
|
135
|
+
}
|
|
136
|
+
return tarData;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Fallback: try node:zlib
|
|
140
|
+
const buf = new Uint8Array(await response.arrayBuffer());
|
|
141
|
+
const { gunzipSync } = await import(/* webpackIgnore: true */ "node:zlib");
|
|
142
|
+
return gunzipSync(buf);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Fetch and decompress python-stdlib.tar.gz, returning a file tree.
|
|
147
|
+
*
|
|
148
|
+
* @param {string|URL} [url] - URL to python-stdlib.tar.gz. Defaults to
|
|
149
|
+
* './python-stdlib.tar.gz' relative to this module.
|
|
150
|
+
* @returns {Promise<object>} File tree with { dir: { ... } } structure
|
|
151
|
+
*/
|
|
152
|
+
export async function loadStdlib(url) {
|
|
153
|
+
const stdlibUrl =
|
|
154
|
+
url || new URL("./python-stdlib.tar.gz", import.meta.url).href;
|
|
155
|
+
|
|
156
|
+
let tarData;
|
|
157
|
+
|
|
158
|
+
// In Node.js, use fs + zlib for file:// URLs (fetch doesn't support file://)
|
|
159
|
+
const isNode =
|
|
160
|
+
typeof globalThis.process !== "undefined" &&
|
|
161
|
+
typeof globalThis.process.versions?.node !== "undefined";
|
|
162
|
+
|
|
163
|
+
if (isNode && stdlibUrl.startsWith("file://")) {
|
|
164
|
+
const { fileURLToPath } = await import(
|
|
165
|
+
/* webpackIgnore: true */ "node:url"
|
|
166
|
+
);
|
|
167
|
+
const filePath = fileURLToPath(stdlibUrl);
|
|
168
|
+
tarData = await loadTarGzNode(filePath);
|
|
169
|
+
} else {
|
|
170
|
+
tarData = await loadTarGzBrowser(stdlibUrl);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return parseTar(tarData);
|
|
174
|
+
}
|