@swmansion/popcorn 0.3.2 → 0.4.0-next.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 +1 -1
- package/NOTICE +12 -0
- package/README.md +78 -2
- package/dist/beam.d.ts +10 -0
- package/dist/errors.d.ts +96 -39
- package/dist/etf.d.ts +38 -0
- package/dist/events.d.ts +82 -0
- package/dist/index.d.ts +7 -3
- package/dist/index.mjs +1287 -2
- package/dist/plugins/beam_tools/lib/popcorn/beam_tools/beam_patcher.ex +184 -0
- package/dist/plugins/beam_tools/lib/popcorn/beam_tools/cli.ex +74 -0
- package/dist/plugins/beam_tools/lib/popcorn/beam_tools/packager.ex +540 -0
- package/dist/plugins/beam_tools/mix.exs +16 -0
- package/dist/plugins/beam_tools/patches/kernel/prim_tty.erl +13 -0
- package/dist/plugins/beam_tools/patches/stdlib/beam_lib.erl +27 -0
- package/dist/plugins/esbuild.d.ts +10 -2
- package/dist/plugins/esbuild.mjs +39 -34
- package/dist/plugins/rollup.d.ts +10 -2
- package/dist/plugins/rollup.mjs +46 -25
- package/dist/plugins/shared.d.ts +54 -4
- package/dist/plugins/shared.mjs +207 -0
- package/dist/plugins/vite.d.ts +17 -2
- package/dist/plugins/vite.mjs +201 -75
- package/dist/popcorn.d.ts +237 -110
- package/dist/runtimes/core/beam.emu.mjs +141 -0
- package/dist/runtimes/core/beam.mjs +141 -0
- package/dist/runtimes/core/beam.wasm +0 -0
- package/dist/runtimes/core/manifest.json +1 -0
- package/dist/runtimes/crypto/beam.emu.mjs +520 -0
- package/dist/runtimes/crypto/beam.mjs +520 -0
- package/dist/runtimes/crypto/beam.wasm +0 -0
- package/dist/runtimes/crypto/manifest.json +1 -0
- package/dist/tar.d.ts +4 -0
- package/dist/types.d.ts +108 -63
- package/dist/utils.d.ts +7 -0
- package/dist/worker.d.ts +1 -0
- package/dist/worker.mjs +765 -0
- package/package.json +20 -28
- package/dist/AtomVM.mjs +0 -7992
- package/dist/AtomVM.wasm +0 -0
- package/dist/bridge.d.ts +0 -22
- package/dist/bridge.mjs +0 -66
- package/dist/errors.mjs +0 -55
- package/dist/iframe.d.ts +0 -1
- package/dist/iframe.mjs +0 -215
- package/dist/popcorn.mjs +0 -381
- package/dist/types.mjs +0 -25
package/dist/index.mjs
CHANGED
|
@@ -1,2 +1,1287 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
function err$1(t, data) {
|
|
2
|
+
return new PopcornError({ t, data });
|
|
3
|
+
}
|
|
4
|
+
/** @hidden */
|
|
5
|
+
class PopcornError extends Error {
|
|
6
|
+
cause;
|
|
7
|
+
serialized;
|
|
8
|
+
constructor(cause) {
|
|
9
|
+
super(message(cause), { cause });
|
|
10
|
+
this.name = "PopcornError";
|
|
11
|
+
Object.setPrototypeOf(this, new.target.prototype);
|
|
12
|
+
this.cause = cause;
|
|
13
|
+
this.serialized = cause;
|
|
14
|
+
}
|
|
15
|
+
get t() {
|
|
16
|
+
return this.serialized.t;
|
|
17
|
+
}
|
|
18
|
+
get data() {
|
|
19
|
+
return this.serialized.data;
|
|
20
|
+
}
|
|
21
|
+
/** Returns the tag and a shallow copy of its details. */
|
|
22
|
+
serialize() {
|
|
23
|
+
return {
|
|
24
|
+
t: this.serialized.t,
|
|
25
|
+
data: { ...this.serialized.data },
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
/** Restores a serialized error. Throws if validation fails. */
|
|
29
|
+
static deserialize(value) {
|
|
30
|
+
return new PopcornError(parse(value));
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
function message(error) {
|
|
34
|
+
switch (error.t) {
|
|
35
|
+
case "timeout:init":
|
|
36
|
+
return `Init timed out after ${error.data.timeoutMs}ms`;
|
|
37
|
+
case "timeout:send":
|
|
38
|
+
return `Send timed out after ${error.data.timeoutMs}ms`;
|
|
39
|
+
case "timeout:call":
|
|
40
|
+
return `Call timed out after ${error.data.timeoutMs}ms`;
|
|
41
|
+
case "worker:load":
|
|
42
|
+
return error.data.message;
|
|
43
|
+
case "vm:exited":
|
|
44
|
+
return "VM exited";
|
|
45
|
+
case "bridge:not-started":
|
|
46
|
+
return "Bridge did not start";
|
|
47
|
+
case "bridge:invalid-target":
|
|
48
|
+
return "Target must be a non-empty name or a PID from this VM boot";
|
|
49
|
+
case "bridge:unserializable":
|
|
50
|
+
return "Message can't be serialized to ETF";
|
|
51
|
+
case "bridge:listener-not-found":
|
|
52
|
+
return `Target listener not found: '${error.data.targetName}'`;
|
|
53
|
+
case "genserver:noproc":
|
|
54
|
+
return `No process registered for genserver target: '${error.data.target}'`;
|
|
55
|
+
case "genserver:exit":
|
|
56
|
+
return `Genserver exited: ${error.data.reason}`;
|
|
57
|
+
case "genserver:unserializable":
|
|
58
|
+
return "Genserver reply can't be serialized to JSON";
|
|
59
|
+
case "stdio:overflow":
|
|
60
|
+
return `Stdin chunk exceeds the ${error.data.capacityBytes} byte queue capacity`;
|
|
61
|
+
case "beam:missing-boot-script":
|
|
62
|
+
return `Missing boot script: '${error.data.url}'`;
|
|
63
|
+
case "beam:missing-manifest":
|
|
64
|
+
return `Missing tarball manifest: '${error.data.url}'`;
|
|
65
|
+
case "beam:missing-tarball":
|
|
66
|
+
return `Missing tarball: '${error.data.name}'. Available tarballs: ${error.data.all.join(", ")}`;
|
|
67
|
+
case "internal:check":
|
|
68
|
+
return error.data.detail === undefined
|
|
69
|
+
? "Check failed"
|
|
70
|
+
: `Check failed: ${error.data.detail}`;
|
|
71
|
+
case "internal:unreachable":
|
|
72
|
+
return "Entered unreachable code";
|
|
73
|
+
case "runtime:eval-unavailable":
|
|
74
|
+
return "JS eval is unavailable; run_js requires a Content-Security-Policy that allows 'unsafe-eval'";
|
|
75
|
+
default:
|
|
76
|
+
unreachable$1();
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
function parse(value) {
|
|
80
|
+
check$1(objectWithKeys$1(value, ["t", "data"]));
|
|
81
|
+
switch (value.t) {
|
|
82
|
+
case "timeout:init":
|
|
83
|
+
case "timeout:send":
|
|
84
|
+
check$1(isTimeoutData(value.data));
|
|
85
|
+
return { t: value.t, data: value.data };
|
|
86
|
+
case "worker:load":
|
|
87
|
+
check$1(isWorkerLoadData(value.data));
|
|
88
|
+
return { t: value.t, data: value.data };
|
|
89
|
+
case "vm:exited":
|
|
90
|
+
check$1(isVmExitedData(value.data));
|
|
91
|
+
return { t: value.t, data: value.data };
|
|
92
|
+
case "bridge:not-started":
|
|
93
|
+
check$1(isEmptyData(value.data));
|
|
94
|
+
return { t: value.t, data: value.data };
|
|
95
|
+
case "bridge:invalid-target":
|
|
96
|
+
check$1(isEmptyData(value.data));
|
|
97
|
+
return { t: value.t, data: value.data };
|
|
98
|
+
case "bridge:unserializable":
|
|
99
|
+
check$1(isUnserializableData(value.data));
|
|
100
|
+
return { t: value.t, data: value.data };
|
|
101
|
+
case "bridge:listener-not-found":
|
|
102
|
+
check$1(isListenerNotFoundData(value.data));
|
|
103
|
+
return { t: value.t, data: value.data };
|
|
104
|
+
case "stdio:overflow":
|
|
105
|
+
check$1(isStdioOverflowData(value.data));
|
|
106
|
+
return { t: value.t, data: value.data };
|
|
107
|
+
case "beam:missing-boot-script":
|
|
108
|
+
case "beam:missing-manifest":
|
|
109
|
+
check$1(isUrlData(value.data));
|
|
110
|
+
return { t: value.t, data: value.data };
|
|
111
|
+
case "beam:missing-tarball":
|
|
112
|
+
check$1(isMissingTarballData(value.data));
|
|
113
|
+
return { t: value.t, data: value.data };
|
|
114
|
+
case "internal:check":
|
|
115
|
+
check$1(isInternalCheckData(value.data));
|
|
116
|
+
return { t: value.t, data: value.data };
|
|
117
|
+
case "internal:unreachable":
|
|
118
|
+
case "runtime:eval-unavailable":
|
|
119
|
+
check$1(isEmptyData(value.data));
|
|
120
|
+
return { t: value.t, data: value.data };
|
|
121
|
+
default:
|
|
122
|
+
unreachable$1();
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
function isTimeoutData(value) {
|
|
126
|
+
return objectWithKeys$1(value, ["timeoutMs"]) !== null;
|
|
127
|
+
}
|
|
128
|
+
function isWorkerLoadData(value) {
|
|
129
|
+
return objectWithKeys$1(value, ["message"]) !== null;
|
|
130
|
+
}
|
|
131
|
+
function isVmExitedData(value) {
|
|
132
|
+
return objectWithKeys$1(value, ["reason"]) !== null;
|
|
133
|
+
}
|
|
134
|
+
function isListenerNotFoundData(value) {
|
|
135
|
+
return objectWithKeys$1(value, ["targetName"]) !== null;
|
|
136
|
+
}
|
|
137
|
+
function isStdioOverflowData(value) {
|
|
138
|
+
return objectWithKeys$1(value, ["capacityBytes", "attemptedBytes"]) !== null;
|
|
139
|
+
}
|
|
140
|
+
function isUnserializableData(value) {
|
|
141
|
+
return (objectWithKeys$1(value, ["data", "part", "reason"]) &&
|
|
142
|
+
isUnserializableReason(value.reason));
|
|
143
|
+
}
|
|
144
|
+
function isUnserializableReason(value) {
|
|
145
|
+
return (value === "cyclic-object" ||
|
|
146
|
+
value === "non-plain-object" ||
|
|
147
|
+
value === "lossy-int" ||
|
|
148
|
+
value === "non-finite-float" ||
|
|
149
|
+
value === "unsupported");
|
|
150
|
+
}
|
|
151
|
+
function isUrlData(value) {
|
|
152
|
+
return objectWithKeys$1(value, ["url"]) !== null;
|
|
153
|
+
}
|
|
154
|
+
function isMissingTarballData(value) {
|
|
155
|
+
return objectWithKeys$1(value, ["name", "all"]) !== null;
|
|
156
|
+
}
|
|
157
|
+
function isInternalCheckData(value) {
|
|
158
|
+
return objectWithKeys$1(value, []) !== null;
|
|
159
|
+
}
|
|
160
|
+
function isEmptyData(value) {
|
|
161
|
+
return objectWithKeys$1(value, []) !== null;
|
|
162
|
+
}
|
|
163
|
+
function objectWithKeys$1(value, keys) {
|
|
164
|
+
const isObject = value !== null && typeof value === "object";
|
|
165
|
+
return isObject && keys.every((key) => Object.hasOwn(value, key));
|
|
166
|
+
}
|
|
167
|
+
function unreachable$1() {
|
|
168
|
+
throw err$1("internal:unreachable", {});
|
|
169
|
+
}
|
|
170
|
+
function check$1(ok, msg) {
|
|
171
|
+
if (!ok) {
|
|
172
|
+
throw err$1("internal:check", { detail: msg });
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function base64ToBytes(b64) {
|
|
177
|
+
const binary = atob(b64);
|
|
178
|
+
const bytes = new Uint8Array(binary.length);
|
|
179
|
+
for (let i = 0; i < binary.length; i++) {
|
|
180
|
+
bytes[i] = binary.charCodeAt(i);
|
|
181
|
+
}
|
|
182
|
+
return bytes;
|
|
183
|
+
}
|
|
184
|
+
function check(ok, msg) {
|
|
185
|
+
if (!ok)
|
|
186
|
+
throw err$1("internal:check", { detail: msg });
|
|
187
|
+
}
|
|
188
|
+
function unreachable() {
|
|
189
|
+
throw err$1("internal:unreachable", {});
|
|
190
|
+
}
|
|
191
|
+
function objectWithKeys(value, keys) {
|
|
192
|
+
if (value === null || typeof value !== "object")
|
|
193
|
+
return null;
|
|
194
|
+
if (value.constructor !== Object)
|
|
195
|
+
return null;
|
|
196
|
+
const hasAllKeys = keys.every((k) => Object.hasOwn(value, k));
|
|
197
|
+
if (!hasAllKeys)
|
|
198
|
+
return null;
|
|
199
|
+
return value;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const VERSION = 0x83;
|
|
203
|
+
const NEW_FLOAT_EXT = 0x46;
|
|
204
|
+
const SMALL_INTEGER_EXT = 0x61;
|
|
205
|
+
const INTEGER_EXT = 0x62;
|
|
206
|
+
const SMALL_TUPLE_EXT = 0x68;
|
|
207
|
+
const LARGE_TUPLE_EXT = 0x69;
|
|
208
|
+
const NIL_EXT = 0x6a;
|
|
209
|
+
const LIST_EXT = 0x6c;
|
|
210
|
+
const BINARY_EXT = 0x6d;
|
|
211
|
+
const SMALL_BIG_EXT = 0x6e;
|
|
212
|
+
const MAP_EXT = 0x74;
|
|
213
|
+
const ATOM_UTF8_EXT = 0x76;
|
|
214
|
+
const SMALL_ATOM_UTF8_EXT = 0x77;
|
|
215
|
+
const UTF8$1 = new TextEncoder();
|
|
216
|
+
class AtomTerm {
|
|
217
|
+
name;
|
|
218
|
+
constructor(name) {
|
|
219
|
+
this.name = name;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
class TupleTerm {
|
|
223
|
+
entries;
|
|
224
|
+
constructor(entries) {
|
|
225
|
+
this.entries = entries;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* Creates a BEAM atom value.
|
|
230
|
+
*
|
|
231
|
+
* The atom must already exist in the receiving VM. Plain strings encode as binaries.
|
|
232
|
+
*/
|
|
233
|
+
function atom(name) {
|
|
234
|
+
return new AtomTerm(name);
|
|
235
|
+
}
|
|
236
|
+
/** Alias for {@link atom}. */
|
|
237
|
+
const a = atom;
|
|
238
|
+
/**
|
|
239
|
+
* Creates a BEAM tuple.
|
|
240
|
+
*
|
|
241
|
+
* Plain arrays encode as lists.
|
|
242
|
+
*/
|
|
243
|
+
function tuple(first, second, ...rest) {
|
|
244
|
+
check(arguments.length > 1, "tuple requires at least two entries");
|
|
245
|
+
return new TupleTerm([first, second, ...rest]);
|
|
246
|
+
}
|
|
247
|
+
/** Alias for {@link tuple}. */
|
|
248
|
+
const t = tuple;
|
|
249
|
+
/** Pre-encoded ETF sub-term bytes (no version prefix), spliced verbatim. */
|
|
250
|
+
class RawTerm {
|
|
251
|
+
bytes;
|
|
252
|
+
constructor(bytes) {
|
|
253
|
+
this.bytes = bytes;
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* Wraps a full external term (`term_to_binary` output) as a spliceable
|
|
257
|
+
* sub-term by dropping its leading version byte.
|
|
258
|
+
*/
|
|
259
|
+
static fromExternal(external) {
|
|
260
|
+
if (external[0] !== VERSION) {
|
|
261
|
+
throw new TypeError("expected a version-prefixed ETF external term");
|
|
262
|
+
}
|
|
263
|
+
return new RawTerm(external.subarray(1));
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
function encode(data, mapper) {
|
|
267
|
+
try {
|
|
268
|
+
return { ok: true, data: new Encoder(mapper).encode(data) };
|
|
269
|
+
}
|
|
270
|
+
catch (error) {
|
|
271
|
+
let reason = "unsupported";
|
|
272
|
+
let part = data;
|
|
273
|
+
if (error instanceof TypeError && isUnserializableReason(error.message)) {
|
|
274
|
+
reason = error.message;
|
|
275
|
+
part = error.cause;
|
|
276
|
+
}
|
|
277
|
+
return {
|
|
278
|
+
ok: false,
|
|
279
|
+
error: err$1("bridge:unserializable", { data, part, reason }),
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
class Encoder {
|
|
284
|
+
mapper;
|
|
285
|
+
ancestors = new Set();
|
|
286
|
+
output = [];
|
|
287
|
+
buffer = new ArrayBuffer(8);
|
|
288
|
+
view = new DataView(this.buffer);
|
|
289
|
+
constructor(mapper = (value) => value) {
|
|
290
|
+
this.mapper = mapper;
|
|
291
|
+
}
|
|
292
|
+
encode(value) {
|
|
293
|
+
this.byte(VERSION);
|
|
294
|
+
this.value(value);
|
|
295
|
+
return new Uint8Array(this.output);
|
|
296
|
+
}
|
|
297
|
+
value(value) {
|
|
298
|
+
if (value === null || value === undefined) {
|
|
299
|
+
this.atom("nil");
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
switch (typeof value) {
|
|
303
|
+
case "boolean":
|
|
304
|
+
this.atom(value ? "true" : "false");
|
|
305
|
+
return;
|
|
306
|
+
case "string":
|
|
307
|
+
this.binary(value);
|
|
308
|
+
return;
|
|
309
|
+
case "number":
|
|
310
|
+
this.number(value);
|
|
311
|
+
return;
|
|
312
|
+
case "object":
|
|
313
|
+
this.object(value);
|
|
314
|
+
return;
|
|
315
|
+
default:
|
|
316
|
+
throw err("unsupported", value);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
number(value) {
|
|
320
|
+
if (!Number.isFinite(value)) {
|
|
321
|
+
throw err("non-finite-float", value);
|
|
322
|
+
}
|
|
323
|
+
if (!Number.isInteger(value)) {
|
|
324
|
+
this.byte(NEW_FLOAT_EXT);
|
|
325
|
+
this.float64(value);
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
if (!Number.isSafeInteger(value)) {
|
|
329
|
+
throw err("lossy-int", value);
|
|
330
|
+
}
|
|
331
|
+
if (value >= 0 && value < 2 ** 8) {
|
|
332
|
+
this.byte(SMALL_INTEGER_EXT);
|
|
333
|
+
this.byte(value);
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
if (value >= -2147483648 && value < 2 ** 31) {
|
|
337
|
+
this.byte(INTEGER_EXT);
|
|
338
|
+
this.int32(value);
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
this.smallBigInt(value);
|
|
342
|
+
}
|
|
343
|
+
// https://www.erlang.org/doc/apps/erts/erl_ext_dist.html#small_big_ext
|
|
344
|
+
smallBigInt(value) {
|
|
345
|
+
let magnitude = BigInt(Math.abs(value));
|
|
346
|
+
const digits = [];
|
|
347
|
+
while (magnitude > 0n) {
|
|
348
|
+
digits.push(Number(magnitude & 0xffn));
|
|
349
|
+
magnitude >>= 8n;
|
|
350
|
+
}
|
|
351
|
+
this.byte(SMALL_BIG_EXT);
|
|
352
|
+
this.byte(digits.length);
|
|
353
|
+
this.byte(value < 0 ? 1 : 0);
|
|
354
|
+
this.bytes(digits);
|
|
355
|
+
}
|
|
356
|
+
object(rawValue) {
|
|
357
|
+
const value = this.mapper(rawValue);
|
|
358
|
+
if (value instanceof RawTerm) {
|
|
359
|
+
this.bytes(value.bytes);
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
if (value instanceof AtomTerm) {
|
|
363
|
+
this.atom(value.name);
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
if (this.ancestors.has(value)) {
|
|
367
|
+
throw err("cyclic-object", value);
|
|
368
|
+
}
|
|
369
|
+
this.ancestors.add(value);
|
|
370
|
+
try {
|
|
371
|
+
if (value instanceof TupleTerm) {
|
|
372
|
+
this.tuple(value.entries);
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
if (Array.isArray(value)) {
|
|
376
|
+
this.array(value);
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
const prototype = Object.getPrototypeOf(value);
|
|
380
|
+
const isObject = prototype === Object.prototype || prototype === null;
|
|
381
|
+
if (!isObject) {
|
|
382
|
+
throw err("non-plain-object", value);
|
|
383
|
+
}
|
|
384
|
+
this.map(value);
|
|
385
|
+
}
|
|
386
|
+
finally {
|
|
387
|
+
this.ancestors.delete(value);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
tuple(entries) {
|
|
391
|
+
if (entries.length < 2 ** 8) {
|
|
392
|
+
this.byte(SMALL_TUPLE_EXT);
|
|
393
|
+
this.byte(entries.length);
|
|
394
|
+
}
|
|
395
|
+
else {
|
|
396
|
+
this.byte(LARGE_TUPLE_EXT);
|
|
397
|
+
this.uint32(entries.length);
|
|
398
|
+
}
|
|
399
|
+
for (const entry of entries) {
|
|
400
|
+
this.value(entry);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
array(value) {
|
|
404
|
+
if (value.length === 0) {
|
|
405
|
+
this.byte(NIL_EXT);
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
this.byte(LIST_EXT);
|
|
409
|
+
this.uint32(value.length);
|
|
410
|
+
for (const item of value) {
|
|
411
|
+
this.value(item);
|
|
412
|
+
}
|
|
413
|
+
this.byte(NIL_EXT);
|
|
414
|
+
}
|
|
415
|
+
map(value) {
|
|
416
|
+
const keys = Object.keys(value).sort();
|
|
417
|
+
this.byte(MAP_EXT);
|
|
418
|
+
this.uint32(keys.length);
|
|
419
|
+
for (const key of keys) {
|
|
420
|
+
this.binary(key);
|
|
421
|
+
this.value(value[key]);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
atom(atom) {
|
|
425
|
+
const bytes = UTF8$1.encode(atom);
|
|
426
|
+
if (bytes.length >= 2 ** 16) {
|
|
427
|
+
throw err("unsupported", atom);
|
|
428
|
+
}
|
|
429
|
+
if (bytes.length < 2 ** 8) {
|
|
430
|
+
this.byte(SMALL_ATOM_UTF8_EXT);
|
|
431
|
+
this.byte(bytes.length);
|
|
432
|
+
}
|
|
433
|
+
else {
|
|
434
|
+
this.byte(ATOM_UTF8_EXT);
|
|
435
|
+
this.uint16(bytes.length);
|
|
436
|
+
}
|
|
437
|
+
this.bytes(bytes);
|
|
438
|
+
}
|
|
439
|
+
binary(value) {
|
|
440
|
+
const bytes = UTF8$1.encode(value);
|
|
441
|
+
this.byte(BINARY_EXT);
|
|
442
|
+
this.uint32(bytes.length);
|
|
443
|
+
this.bytes(bytes);
|
|
444
|
+
}
|
|
445
|
+
byte(value) {
|
|
446
|
+
this.output.push(value);
|
|
447
|
+
}
|
|
448
|
+
bytes(values) {
|
|
449
|
+
for (let index = 0; index < values.length; index++) {
|
|
450
|
+
this.output.push(values[index]);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
uint32(value) {
|
|
454
|
+
this.view.setUint32(0, value);
|
|
455
|
+
this.bytes(new Uint8Array(this.buffer, 0, 4));
|
|
456
|
+
}
|
|
457
|
+
uint16(value) {
|
|
458
|
+
this.view.setUint16(0, value);
|
|
459
|
+
this.bytes(new Uint8Array(this.buffer, 0, 2));
|
|
460
|
+
}
|
|
461
|
+
int32(value) {
|
|
462
|
+
this.view.setInt32(0, value);
|
|
463
|
+
this.bytes(new Uint8Array(this.buffer, 0, 4));
|
|
464
|
+
}
|
|
465
|
+
float64(value) {
|
|
466
|
+
this.view.setFloat64(0, value);
|
|
467
|
+
this.bytes(new Uint8Array(this.buffer));
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
function err(reason, part) {
|
|
471
|
+
return new TypeError(reason, { cause: part });
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function readWorkerEvent(value) {
|
|
475
|
+
const data = objectWithKeys(value, ["type", "payload"]);
|
|
476
|
+
check(data !== null && typeof data.type === "string");
|
|
477
|
+
switch (data.type) {
|
|
478
|
+
case "otp:stdout":
|
|
479
|
+
case "otp:stderr":
|
|
480
|
+
case "otp:error":
|
|
481
|
+
case "otp:message":
|
|
482
|
+
case "otp:run_js":
|
|
483
|
+
case "otp:tracked-value-delete":
|
|
484
|
+
case "popcorn:boot-vm-ready":
|
|
485
|
+
case "popcorn:boot-end":
|
|
486
|
+
case "popcorn:boot-fail":
|
|
487
|
+
case "popcorn:send-end":
|
|
488
|
+
return data;
|
|
489
|
+
case "otp:stdin-consumed":
|
|
490
|
+
check(Number(data.payload) > 0);
|
|
491
|
+
return data;
|
|
492
|
+
default:
|
|
493
|
+
unreachable();
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
function serializeSendPayload(target, payload, mapper) {
|
|
497
|
+
if (isNameTarget(target)) {
|
|
498
|
+
check(target.name.length > 0);
|
|
499
|
+
}
|
|
500
|
+
else {
|
|
501
|
+
check(target.pid.byteLength > 0);
|
|
502
|
+
}
|
|
503
|
+
const etf = encode(payload, mapper);
|
|
504
|
+
if (!etf.ok)
|
|
505
|
+
return etf;
|
|
506
|
+
return { ok: true, data: { target, etf: etf.data } };
|
|
507
|
+
}
|
|
508
|
+
function isNameTarget(target) {
|
|
509
|
+
return Object.hasOwn(target, "name");
|
|
510
|
+
}
|
|
511
|
+
/** Usable only from main context. */
|
|
512
|
+
function toVm(worker, event, transfer) {
|
|
513
|
+
worker.postMessage(event, transfer ?? []);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
const TRACKED_REF_KEY = "popcorn_ref";
|
|
517
|
+
const PID_REF_KEY = "popcorn_pid";
|
|
518
|
+
const UTF8 = new TextEncoder();
|
|
519
|
+
const STDIN_QUEUE_CAPACITY_BYTES = 64 * 1024;
|
|
520
|
+
const DEFAULT_TTY_SIZE = { columns: 80, rows: 24 };
|
|
521
|
+
const DEFAULT_TIMEOUTS_MS = {
|
|
522
|
+
boot: 10_000,
|
|
523
|
+
appStartup: 60_000,
|
|
524
|
+
send: 5_000,
|
|
525
|
+
};
|
|
526
|
+
const LOG_PREFIX = "[Popcorn]";
|
|
527
|
+
const DEFAULT_PROXY_NAME = "popcorn_proxy";
|
|
528
|
+
const DEFAULT_CALL_TIMEOUT_MS = 5_000;
|
|
529
|
+
function createPidClass() {
|
|
530
|
+
return class {
|
|
531
|
+
bytes;
|
|
532
|
+
constructor(bytes) {
|
|
533
|
+
this.bytes = bytes;
|
|
534
|
+
}
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
function assertRunJsFn(value) {
|
|
538
|
+
check(typeof value === "function");
|
|
539
|
+
}
|
|
540
|
+
/**
|
|
541
|
+
* A BEAM VM in a browser worker.
|
|
542
|
+
*
|
|
543
|
+
* Use {@link Popcorn.init} to create and start an instance.
|
|
544
|
+
**/
|
|
545
|
+
class Popcorn {
|
|
546
|
+
vmWorker;
|
|
547
|
+
state = { status: "created" };
|
|
548
|
+
opts;
|
|
549
|
+
ttySize;
|
|
550
|
+
output;
|
|
551
|
+
requestSeq = 0;
|
|
552
|
+
settleBoot = null;
|
|
553
|
+
eventHandlers = new Set();
|
|
554
|
+
pendingSends = new Map();
|
|
555
|
+
pendingCalls = new Map();
|
|
556
|
+
callSeq = 0;
|
|
557
|
+
trackedValues = new Map();
|
|
558
|
+
trackedKeySeq = 0;
|
|
559
|
+
io = createIoState();
|
|
560
|
+
vmReady = false;
|
|
561
|
+
genserver = {
|
|
562
|
+
call: (target, request, opts) => this.call(target, request, opts),
|
|
563
|
+
cast: (target, request, opts) => this.cast(target, request, opts),
|
|
564
|
+
};
|
|
565
|
+
TrackedValue = class {
|
|
566
|
+
value;
|
|
567
|
+
cleanup;
|
|
568
|
+
constructor(value, cleanup) {
|
|
569
|
+
this.value = value;
|
|
570
|
+
this.cleanup = cleanup;
|
|
571
|
+
}
|
|
572
|
+
};
|
|
573
|
+
Pid = createPidClass();
|
|
574
|
+
onWorkerMessage = (event) => {
|
|
575
|
+
const data = readWorkerEvent(event.data);
|
|
576
|
+
switch (data.type) {
|
|
577
|
+
case "popcorn:boot-vm-ready":
|
|
578
|
+
case "popcorn:boot-end":
|
|
579
|
+
case "popcorn:boot-fail":
|
|
580
|
+
return;
|
|
581
|
+
case "otp:message":
|
|
582
|
+
this.emit(this.reviveHandles(data.payload));
|
|
583
|
+
return;
|
|
584
|
+
case "otp:run_js":
|
|
585
|
+
this.vmReady = true;
|
|
586
|
+
this.runJs(data.payload);
|
|
587
|
+
return;
|
|
588
|
+
case "otp:tracked-value-delete":
|
|
589
|
+
this.deleteTrackedValue(data.payload);
|
|
590
|
+
return;
|
|
591
|
+
case "otp:stdout":
|
|
592
|
+
this.handleStdout(data.payload);
|
|
593
|
+
return;
|
|
594
|
+
case "otp:stderr":
|
|
595
|
+
this.handleStderr(data.payload);
|
|
596
|
+
return;
|
|
597
|
+
case "otp:stdin-consumed":
|
|
598
|
+
check(data.payload > 0 && data.payload <= this.io.stdin.reservedBytes);
|
|
599
|
+
this.io.stdin.reservedBytes -= data.payload;
|
|
600
|
+
return;
|
|
601
|
+
case "otp:error":
|
|
602
|
+
this.handleOtpError(data.payload);
|
|
603
|
+
return;
|
|
604
|
+
case "popcorn:send-end": {
|
|
605
|
+
this.completeSend(data.payload);
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
608
|
+
default:
|
|
609
|
+
unreachable();
|
|
610
|
+
}
|
|
611
|
+
};
|
|
612
|
+
/**
|
|
613
|
+
* Creates the worker.
|
|
614
|
+
*
|
|
615
|
+
* Call {@link boot} to start the VM.
|
|
616
|
+
**/
|
|
617
|
+
constructor(opts) {
|
|
618
|
+
const ttySize = opts.tty?.size ?? DEFAULT_TTY_SIZE;
|
|
619
|
+
check(isValidTtySize(ttySize));
|
|
620
|
+
check(opts.beam?.otpAssetsRoot === undefined ||
|
|
621
|
+
opts.beam.otpAssetsRoot.endsWith("/"), "otpAssetsRoot must end with a slash");
|
|
622
|
+
this.opts = {
|
|
623
|
+
...opts,
|
|
624
|
+
beam: {
|
|
625
|
+
...opts.beam,
|
|
626
|
+
emulatorArgs: opts.beam?.emulatorArgs ??
|
|
627
|
+
schedulers({ base: 1, dirtyCpu: 1, dirtyIo: 1 }),
|
|
628
|
+
},
|
|
629
|
+
};
|
|
630
|
+
this.ttySize = { ...ttySize };
|
|
631
|
+
this.output = resolveOutputHandlers(opts);
|
|
632
|
+
this.spawnWorker();
|
|
633
|
+
}
|
|
634
|
+
spawnWorker() {
|
|
635
|
+
this.vmWorker = this.opts.workerUrl
|
|
636
|
+
? new Worker(this.opts.workerUrl, { type: "module" })
|
|
637
|
+
: // Keep this as one expression so Vite recognizes and bundles the worker.
|
|
638
|
+
new Worker(new URL("./worker.mjs", import.meta.url), {
|
|
639
|
+
type: "module",
|
|
640
|
+
});
|
|
641
|
+
this.vmWorker.addEventListener("message", this.onWorkerMessage);
|
|
642
|
+
}
|
|
643
|
+
/**
|
|
644
|
+
* Creates an instance and waits for {@link boot}.
|
|
645
|
+
*
|
|
646
|
+
* For startup messages, use the constructor and register {@link onEvent} before boot.
|
|
647
|
+
*
|
|
648
|
+
* @returns Ok tuple or `runtime:eval-unavailable` if the page blocks JavaScript evaluation.
|
|
649
|
+
*/
|
|
650
|
+
static async init(opts) {
|
|
651
|
+
if (!canEval()) {
|
|
652
|
+
return { ok: false, error: err$1("runtime:eval-unavailable", {}) };
|
|
653
|
+
}
|
|
654
|
+
const popcorn = new Popcorn(opts);
|
|
655
|
+
const result = await popcorn.boot();
|
|
656
|
+
if (!result.ok) {
|
|
657
|
+
return result;
|
|
658
|
+
}
|
|
659
|
+
return { ok: true, data: popcorn };
|
|
660
|
+
}
|
|
661
|
+
/**
|
|
662
|
+
* Starts the VM and waits for its bridge and entrypoint application.
|
|
663
|
+
*
|
|
664
|
+
* Without an entrypoint, waits only for the bridge.
|
|
665
|
+
*
|
|
666
|
+
* After shutdown, starts a fresh VM with the original options.
|
|
667
|
+
*
|
|
668
|
+
* @returns Ok tuple with `this` if boot completes or error tuple.
|
|
669
|
+
*
|
|
670
|
+
* @example
|
|
671
|
+
* ```ts
|
|
672
|
+
* const popcorn = new Popcorn({});
|
|
673
|
+
* popcorn.onEvent((message) => console.log(message));
|
|
674
|
+
* const result = await popcorn.boot();
|
|
675
|
+
* if (!result.ok) throw result.error;
|
|
676
|
+
* ```
|
|
677
|
+
*/
|
|
678
|
+
async boot() {
|
|
679
|
+
if (this.state.status === "booted") {
|
|
680
|
+
return { ok: true, data: this };
|
|
681
|
+
}
|
|
682
|
+
if (this.state.status === "booting") {
|
|
683
|
+
// TODO(jgonet): make it easier to construct check() errors without throwing
|
|
684
|
+
const error = err$1("internal:check", {
|
|
685
|
+
detail: "Boot already in progress",
|
|
686
|
+
});
|
|
687
|
+
return { ok: false, error };
|
|
688
|
+
}
|
|
689
|
+
const reboot = this.state.status === "closed";
|
|
690
|
+
if (reboot) {
|
|
691
|
+
this.spawnWorker();
|
|
692
|
+
}
|
|
693
|
+
this.Pid = createPidClass();
|
|
694
|
+
this.io = createIoState();
|
|
695
|
+
this.output = resolveOutputHandlers(this.opts);
|
|
696
|
+
this.state = { status: "booting" };
|
|
697
|
+
return await new Promise((resolve) => {
|
|
698
|
+
const timeoutsMs = { ...DEFAULT_TIMEOUTS_MS, ...this.opts.timeoutsMs };
|
|
699
|
+
const settle = (result) => {
|
|
700
|
+
if (this.settleBoot === null)
|
|
701
|
+
return;
|
|
702
|
+
clearTimeout(timer);
|
|
703
|
+
cleanup();
|
|
704
|
+
if (!result.ok) {
|
|
705
|
+
this.deinit();
|
|
706
|
+
}
|
|
707
|
+
resolve(result);
|
|
708
|
+
};
|
|
709
|
+
this.settleBoot = settle;
|
|
710
|
+
const startPhase = (timeoutMs) => setTimeout(() => {
|
|
711
|
+
const error = err$1("timeout:init", { timeoutMs });
|
|
712
|
+
settle({ ok: false, error });
|
|
713
|
+
}, timeoutMs);
|
|
714
|
+
// The VM phase covers module instantiation and bridge readiness; the
|
|
715
|
+
// app phase covers the entrypoint's application tree, which runs
|
|
716
|
+
// arbitrary user startup code and can be much slower.
|
|
717
|
+
let timer = startPhase(timeoutsMs.boot);
|
|
718
|
+
const onBootMessage = (event) => {
|
|
719
|
+
const data = readWorkerEvent(event.data);
|
|
720
|
+
switch (data.type) {
|
|
721
|
+
case "popcorn:boot-vm-ready":
|
|
722
|
+
clearTimeout(timer);
|
|
723
|
+
timer = startPhase(timeoutsMs.appStartup);
|
|
724
|
+
break;
|
|
725
|
+
case "popcorn:boot-end":
|
|
726
|
+
this.state = { status: "booted" };
|
|
727
|
+
settle({ ok: true, data: this });
|
|
728
|
+
break;
|
|
729
|
+
case "popcorn:boot-fail": {
|
|
730
|
+
const error = PopcornError.deserialize(data.payload);
|
|
731
|
+
settle({ ok: false, error });
|
|
732
|
+
break;
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
};
|
|
736
|
+
const cleanup = () => {
|
|
737
|
+
this.settleBoot = null;
|
|
738
|
+
this.vmWorker.removeEventListener("message", onBootMessage);
|
|
739
|
+
};
|
|
740
|
+
this.vmWorker.addEventListener("message", onBootMessage);
|
|
741
|
+
toVm(this.vmWorker, {
|
|
742
|
+
type: "popcorn:boot",
|
|
743
|
+
payload: { ...this.opts.beam, ttySize: this.ttySize },
|
|
744
|
+
});
|
|
745
|
+
});
|
|
746
|
+
}
|
|
747
|
+
/**
|
|
748
|
+
* Queues terminal input.
|
|
749
|
+
*
|
|
750
|
+
* Encodes strings as UTF-8 and copies byte arrays. Does not append a newline.
|
|
751
|
+
*
|
|
752
|
+
* Returns `stdio:overflow` if the chunk exceeds the remaining 64 KiB queue capacity.
|
|
753
|
+
* An overflow leaves the queue unchanged.
|
|
754
|
+
*/
|
|
755
|
+
writeStdin(chunk) {
|
|
756
|
+
if (this.state.status === "closed") {
|
|
757
|
+
return { ok: false, error: this.state.error };
|
|
758
|
+
}
|
|
759
|
+
check(this.state.status === "booted");
|
|
760
|
+
const bytes = toBytes(chunk);
|
|
761
|
+
check(bytes.byteLength > 0);
|
|
762
|
+
const attemptedBytes = this.io.stdin.reservedBytes + bytes.byteLength;
|
|
763
|
+
if (attemptedBytes > STDIN_QUEUE_CAPACITY_BYTES) {
|
|
764
|
+
const error = err$1("stdio:overflow", {
|
|
765
|
+
capacityBytes: STDIN_QUEUE_CAPACITY_BYTES,
|
|
766
|
+
attemptedBytes,
|
|
767
|
+
});
|
|
768
|
+
return { ok: false, error };
|
|
769
|
+
}
|
|
770
|
+
this.io.stdin.reservedBytes = attemptedBytes;
|
|
771
|
+
const event = { type: "popcorn:stdin", payload: { chunk: bytes } };
|
|
772
|
+
toVm(this.vmWorker, event, [bytes.buffer]);
|
|
773
|
+
return { ok: true, data: null };
|
|
774
|
+
}
|
|
775
|
+
/**
|
|
776
|
+
* Sends new terminal dimensions to a booted VM.
|
|
777
|
+
*
|
|
778
|
+
* Each dimension must be between 1 and 65,535.
|
|
779
|
+
*/
|
|
780
|
+
resizeTty(columns, rows) {
|
|
781
|
+
if (this.state.status === "closed") {
|
|
782
|
+
return { ok: false, error: this.state.error };
|
|
783
|
+
}
|
|
784
|
+
check(this.state.status === "booted");
|
|
785
|
+
check(isValidTtySize({ columns, rows }));
|
|
786
|
+
toVm(this.vmWorker, {
|
|
787
|
+
type: "popcorn:tty-resize",
|
|
788
|
+
payload: { columns, rows },
|
|
789
|
+
});
|
|
790
|
+
return { ok: true, data: null };
|
|
791
|
+
}
|
|
792
|
+
/**
|
|
793
|
+
* Sends a payload to a registered process name or a {@link Pid} from this VM boot.
|
|
794
|
+
*
|
|
795
|
+
* The process receives `{wasm, Payload}`.
|
|
796
|
+
* A send timeout does not cancel delivery.
|
|
797
|
+
* Uses the value conversions in {@link AnyValue}. An omitted, `null`, or `undefined` payload becomes an empty map.
|
|
798
|
+
*
|
|
799
|
+
* @returns Ok tuple or `bridge:not-started` before boot and `vm:exited` after shutdown.
|
|
800
|
+
*
|
|
801
|
+
* @example
|
|
802
|
+
* Send an Erlang `{ok, <<"value">>}` tuple to a registered `receiver` process.
|
|
803
|
+
*
|
|
804
|
+
* ```ts
|
|
805
|
+
* const result = await popcorn.send("receiver", tuple(atom("ok"), "value"));
|
|
806
|
+
* if (!result.ok) throw result.error;
|
|
807
|
+
* ```
|
|
808
|
+
*
|
|
809
|
+
* @see {@link AnyValue}
|
|
810
|
+
* @see {@link atom}
|
|
811
|
+
* @see {@link tuple}
|
|
812
|
+
*/
|
|
813
|
+
async send(rawTarget, payload) {
|
|
814
|
+
if (this.state.status !== "booted") {
|
|
815
|
+
if (this.state.status === "closed") {
|
|
816
|
+
return { ok: false, error: this.state.error };
|
|
817
|
+
}
|
|
818
|
+
return { ok: false, error: err$1("bridge:not-started", {}) };
|
|
819
|
+
}
|
|
820
|
+
return await this.sendBridge(rawTarget, payload);
|
|
821
|
+
}
|
|
822
|
+
async sendBridge(rawTarget, payload) {
|
|
823
|
+
let target;
|
|
824
|
+
if (typeof rawTarget === "string" && rawTarget.length > 0) {
|
|
825
|
+
target = { name: rawTarget };
|
|
826
|
+
}
|
|
827
|
+
else if (rawTarget instanceof this.Pid) {
|
|
828
|
+
target = { pid: rawTarget.bytes };
|
|
829
|
+
}
|
|
830
|
+
else {
|
|
831
|
+
return { ok: false, error: err$1("bridge:invalid-target", {}) };
|
|
832
|
+
}
|
|
833
|
+
const tracked = [];
|
|
834
|
+
const command = serializeSendPayload(target, payload ?? {}, this.handleMapper(tracked));
|
|
835
|
+
if (!command.ok) {
|
|
836
|
+
return command;
|
|
837
|
+
}
|
|
838
|
+
for (const { key, value, cleanup } of tracked) {
|
|
839
|
+
this.trackedValues.set(key, { value, cleanup });
|
|
840
|
+
}
|
|
841
|
+
const requestId = this.nextRequestId();
|
|
842
|
+
const timeoutMs = { ...DEFAULT_TIMEOUTS_MS, ...this.opts.timeoutsMs }.send;
|
|
843
|
+
return await new Promise((resolve) => {
|
|
844
|
+
const timer = setTimeout(() => {
|
|
845
|
+
const wasMessageStale = this.pendingSends.delete(requestId);
|
|
846
|
+
if (wasMessageStale) {
|
|
847
|
+
resolve({ ok: false, error: err$1("timeout:send", { timeoutMs }) });
|
|
848
|
+
}
|
|
849
|
+
}, timeoutMs);
|
|
850
|
+
this.pendingSends.set(requestId, (result) => {
|
|
851
|
+
clearTimeout(timer);
|
|
852
|
+
resolve(result);
|
|
853
|
+
});
|
|
854
|
+
toVm(this.vmWorker, {
|
|
855
|
+
type: "popcorn:send",
|
|
856
|
+
payload: { id: requestId, message: command.data },
|
|
857
|
+
}, [command.data.etf.buffer]);
|
|
858
|
+
});
|
|
859
|
+
}
|
|
860
|
+
/**
|
|
861
|
+
* Registers a handler for BEAM message payloads.
|
|
862
|
+
*
|
|
863
|
+
* Messages with no handlers are lost. Startup messages can arrive before {@link boot} resolves.
|
|
864
|
+
* VM errors and terminal output use the callbacks in {@link PopcornOpts}.
|
|
865
|
+
*
|
|
866
|
+
* @returns a function that removes the handler.
|
|
867
|
+
*/
|
|
868
|
+
onEvent(handler) {
|
|
869
|
+
this.eventHandlers.add(handler);
|
|
870
|
+
return () => {
|
|
871
|
+
this.eventHandlers.delete(handler);
|
|
872
|
+
};
|
|
873
|
+
}
|
|
874
|
+
/**
|
|
875
|
+
* Stops the worker and completes pending sends and calls with `vm:exited`.
|
|
876
|
+
*
|
|
877
|
+
* Releases tracked values and runs their cleanup callbacks.
|
|
878
|
+
* Keeps event handlers for the next boot. Repeated calls have no effect.
|
|
879
|
+
*/
|
|
880
|
+
deinit(reason = { reason: "deinit" }) {
|
|
881
|
+
if (this.state.status === "closed") {
|
|
882
|
+
return;
|
|
883
|
+
}
|
|
884
|
+
const error = err$1("vm:exited", reason);
|
|
885
|
+
if (this.settleBoot !== null) {
|
|
886
|
+
this.settleBoot({ ok: false, error });
|
|
887
|
+
return;
|
|
888
|
+
}
|
|
889
|
+
this.state = { status: "closed", error };
|
|
890
|
+
this.vmReady = false;
|
|
891
|
+
for (const resolve of this.pendingSends.values()) {
|
|
892
|
+
resolve({ ok: false, error });
|
|
893
|
+
}
|
|
894
|
+
this.pendingSends.clear();
|
|
895
|
+
for (const pending of this.pendingCalls.values()) {
|
|
896
|
+
pending.settle({ ok: false, error });
|
|
897
|
+
}
|
|
898
|
+
this.pendingCalls.clear();
|
|
899
|
+
this.clearTrackedValues();
|
|
900
|
+
this.vmWorker.removeEventListener("message", this.onWorkerMessage);
|
|
901
|
+
this.vmWorker.terminate();
|
|
902
|
+
// we keep onEvent() callbacks across reboots
|
|
903
|
+
}
|
|
904
|
+
clearTrackedValues() {
|
|
905
|
+
for (const entry of this.trackedValues.values()) {
|
|
906
|
+
try {
|
|
907
|
+
entry.cleanup?.();
|
|
908
|
+
}
|
|
909
|
+
catch { }
|
|
910
|
+
}
|
|
911
|
+
this.trackedValues.clear();
|
|
912
|
+
}
|
|
913
|
+
emit(event) {
|
|
914
|
+
const popcorn = objectWithKeys(event, ["_popcorn"])?._popcorn;
|
|
915
|
+
const envelope = objectWithKeys(popcorn, ["t", "id", "payload"]);
|
|
916
|
+
if (envelope !== null) {
|
|
917
|
+
check(envelope.t === "proxy");
|
|
918
|
+
this.completeCall(envelope.id, envelope.payload);
|
|
919
|
+
return;
|
|
920
|
+
}
|
|
921
|
+
if (this.eventHandlers.size === 0) {
|
|
922
|
+
console.warn(`${LOG_PREFIX} Dropped message with no event handlers`, event);
|
|
923
|
+
}
|
|
924
|
+
for (const handler of this.eventHandlers) {
|
|
925
|
+
handler(event);
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
completeCall(id, payload) {
|
|
929
|
+
const pending = this.pendingCalls.get(id);
|
|
930
|
+
const lateReply = pending === undefined;
|
|
931
|
+
if (lateReply)
|
|
932
|
+
return;
|
|
933
|
+
this.pendingCalls.delete(id);
|
|
934
|
+
pending.settle(this.parseCallReply(pending, payload));
|
|
935
|
+
}
|
|
936
|
+
parseCallReply(pending, payload) {
|
|
937
|
+
const reply = payload;
|
|
938
|
+
if (reply.ok)
|
|
939
|
+
return { ok: true, data: reply.value };
|
|
940
|
+
switch (reply.error.kind) {
|
|
941
|
+
case "noproc": {
|
|
942
|
+
const rawTarget = pending.target;
|
|
943
|
+
const isName = typeof rawTarget === "string";
|
|
944
|
+
const target = isName ? rawTarget : "<pid>";
|
|
945
|
+
return {
|
|
946
|
+
ok: false,
|
|
947
|
+
error: err$1("genserver:noproc", { target }),
|
|
948
|
+
};
|
|
949
|
+
}
|
|
950
|
+
case "exit":
|
|
951
|
+
return {
|
|
952
|
+
ok: false,
|
|
953
|
+
error: err$1("genserver:exit", { reason: reply.error.reason }),
|
|
954
|
+
};
|
|
955
|
+
case "unserializable":
|
|
956
|
+
return { ok: false, error: err$1("genserver:unserializable", {}) };
|
|
957
|
+
case "timeout":
|
|
958
|
+
return {
|
|
959
|
+
ok: false,
|
|
960
|
+
error: err$1("timeout:call", { timeoutMs: pending.timeoutMs }),
|
|
961
|
+
};
|
|
962
|
+
default:
|
|
963
|
+
unreachable();
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
async call(rawTarget, request, opts) {
|
|
967
|
+
if (this.state.status !== "booted") {
|
|
968
|
+
if (this.state.status === "closed") {
|
|
969
|
+
return { ok: false, error: this.state.error };
|
|
970
|
+
}
|
|
971
|
+
return { ok: false, error: err$1("bridge:not-started", {}) };
|
|
972
|
+
}
|
|
973
|
+
return await this.callBridge(rawTarget, request, opts);
|
|
974
|
+
}
|
|
975
|
+
async callBridge(rawTarget, request, opts) {
|
|
976
|
+
const timeoutMs = opts?.timeoutMs ?? DEFAULT_CALL_TIMEOUT_MS;
|
|
977
|
+
const proxy = opts?.proxy ?? DEFAULT_PROXY_NAME;
|
|
978
|
+
const id = this.nextCallId();
|
|
979
|
+
const result = new Promise((resolve) => {
|
|
980
|
+
const timer = setTimeout(() => {
|
|
981
|
+
const isUnresolved = this.pendingCalls.delete(id);
|
|
982
|
+
if (isUnresolved) {
|
|
983
|
+
resolve({ ok: false, error: err$1("timeout:call", { timeoutMs }) });
|
|
984
|
+
}
|
|
985
|
+
}, timeoutMs);
|
|
986
|
+
this.pendingCalls.set(id, {
|
|
987
|
+
target: rawTarget,
|
|
988
|
+
timeoutMs,
|
|
989
|
+
settle: (settled) => {
|
|
990
|
+
clearTimeout(timer);
|
|
991
|
+
resolve(settled);
|
|
992
|
+
},
|
|
993
|
+
});
|
|
994
|
+
});
|
|
995
|
+
const sent = await this.sendBridge(proxy, {
|
|
996
|
+
kind: "call",
|
|
997
|
+
id,
|
|
998
|
+
target: rawTarget,
|
|
999
|
+
request: request,
|
|
1000
|
+
timeout_ms: timeoutMs,
|
|
1001
|
+
});
|
|
1002
|
+
if (!sent.ok) {
|
|
1003
|
+
const pending = this.pendingCalls.get(id);
|
|
1004
|
+
this.pendingCalls.delete(id);
|
|
1005
|
+
pending?.settle({ ok: false, error: sent.error });
|
|
1006
|
+
}
|
|
1007
|
+
return result;
|
|
1008
|
+
}
|
|
1009
|
+
async cast(rawTarget, request, opts) {
|
|
1010
|
+
if (this.state.status !== "booted") {
|
|
1011
|
+
if (this.state.status === "closed") {
|
|
1012
|
+
return { ok: false, error: this.state.error };
|
|
1013
|
+
}
|
|
1014
|
+
return { ok: false, error: err$1("bridge:not-started", {}) };
|
|
1015
|
+
}
|
|
1016
|
+
return await this.castBridge(rawTarget, request, opts);
|
|
1017
|
+
}
|
|
1018
|
+
async castBridge(rawTarget, request, opts) {
|
|
1019
|
+
const proxy = opts?.proxy ?? DEFAULT_PROXY_NAME;
|
|
1020
|
+
return await this.sendBridge(proxy, {
|
|
1021
|
+
kind: "cast",
|
|
1022
|
+
target: rawTarget,
|
|
1023
|
+
request: request,
|
|
1024
|
+
});
|
|
1025
|
+
}
|
|
1026
|
+
nextCallId() {
|
|
1027
|
+
this.callSeq += 1;
|
|
1028
|
+
return `call:${this.callSeq}`;
|
|
1029
|
+
}
|
|
1030
|
+
async runJs(request) {
|
|
1031
|
+
let payload;
|
|
1032
|
+
try {
|
|
1033
|
+
const fn = this.jsWithCurrentEnv(request.code);
|
|
1034
|
+
assertRunJsFn(fn);
|
|
1035
|
+
const args = this.reviveHandles(request.args);
|
|
1036
|
+
check(this.vmReady);
|
|
1037
|
+
const actions = {
|
|
1038
|
+
send: (target, payload) => this.sendBridge(target, payload),
|
|
1039
|
+
call: (target, payload, opts) => this.callBridge(target, payload, opts),
|
|
1040
|
+
cast: (target, payload, opts) => this.castBridge(target, payload, opts),
|
|
1041
|
+
};
|
|
1042
|
+
const result = await fn(args, actions);
|
|
1043
|
+
const value = request.return === "ref" ? this.asRef(result) : result;
|
|
1044
|
+
payload = { ok: true, value: value ?? null };
|
|
1045
|
+
}
|
|
1046
|
+
catch (error) {
|
|
1047
|
+
check(error instanceof Error);
|
|
1048
|
+
payload = { ok: false, error: error.toString() };
|
|
1049
|
+
}
|
|
1050
|
+
const target = { pid: request.replyTo };
|
|
1051
|
+
const tracked = [];
|
|
1052
|
+
const command = serializeSendPayload(target, payload, this.handleMapper(tracked));
|
|
1053
|
+
if (command.ok) {
|
|
1054
|
+
for (const { key, value, cleanup } of tracked) {
|
|
1055
|
+
this.trackedValues.set(key, { value, cleanup });
|
|
1056
|
+
}
|
|
1057
|
+
this.sendRunJsReply(command.data);
|
|
1058
|
+
return;
|
|
1059
|
+
}
|
|
1060
|
+
const failure = serializeSendPayload(target, {
|
|
1061
|
+
ok: false,
|
|
1062
|
+
error: { unserializable: command.error.data.reason },
|
|
1063
|
+
});
|
|
1064
|
+
check(failure.ok);
|
|
1065
|
+
this.sendRunJsReply(failure.data);
|
|
1066
|
+
}
|
|
1067
|
+
asRef(value) {
|
|
1068
|
+
if (value instanceof this.TrackedValue)
|
|
1069
|
+
return value;
|
|
1070
|
+
return new this.TrackedValue(value);
|
|
1071
|
+
}
|
|
1072
|
+
sendRunJsReply(message) {
|
|
1073
|
+
toVm(this.vmWorker, { type: "popcorn:run-js-reply", payload: { message } }, [message.etf.buffer]);
|
|
1074
|
+
}
|
|
1075
|
+
jsWithCurrentEnv(code) {
|
|
1076
|
+
const make = new Function("TrackedValue", `"use strict"; return (${code});`);
|
|
1077
|
+
return make(this.TrackedValue);
|
|
1078
|
+
}
|
|
1079
|
+
reviveHandles(value) {
|
|
1080
|
+
const key = trackedRefKey(value);
|
|
1081
|
+
if (key !== null) {
|
|
1082
|
+
const entry = this.trackedValues.get(key);
|
|
1083
|
+
check(entry !== undefined);
|
|
1084
|
+
return entry.value;
|
|
1085
|
+
}
|
|
1086
|
+
const pidToken = pidRefToken(value);
|
|
1087
|
+
if (pidToken !== null) {
|
|
1088
|
+
return new this.Pid(base64ToBytes(pidToken));
|
|
1089
|
+
}
|
|
1090
|
+
if (Array.isArray(value)) {
|
|
1091
|
+
return value.map((item) => this.reviveHandles(item));
|
|
1092
|
+
}
|
|
1093
|
+
const obj = objectWithKeys(value, []);
|
|
1094
|
+
if (obj !== null) {
|
|
1095
|
+
const revived = {};
|
|
1096
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
1097
|
+
revived[k] = this.reviveHandles(v);
|
|
1098
|
+
}
|
|
1099
|
+
return revived;
|
|
1100
|
+
}
|
|
1101
|
+
return value;
|
|
1102
|
+
}
|
|
1103
|
+
/** Maps pids and `TrackedValue`s during encoding, collecting handles into
|
|
1104
|
+
* `tracked` for the caller to register once encoding succeeds. */
|
|
1105
|
+
handleMapper(tracked) {
|
|
1106
|
+
return (value) => {
|
|
1107
|
+
if (value instanceof this.Pid) {
|
|
1108
|
+
return RawTerm.fromExternal(value.bytes);
|
|
1109
|
+
}
|
|
1110
|
+
if (value instanceof this.TrackedValue) {
|
|
1111
|
+
const key = (this.trackedKeySeq += 1);
|
|
1112
|
+
tracked.push({ key, value: value.value, cleanup: value.cleanup });
|
|
1113
|
+
return { [TRACKED_REF_KEY]: key };
|
|
1114
|
+
}
|
|
1115
|
+
return value;
|
|
1116
|
+
};
|
|
1117
|
+
}
|
|
1118
|
+
deleteTrackedValue(key) {
|
|
1119
|
+
const entry = this.trackedValues.get(key);
|
|
1120
|
+
check(entry !== undefined);
|
|
1121
|
+
try {
|
|
1122
|
+
entry.cleanup?.();
|
|
1123
|
+
}
|
|
1124
|
+
finally {
|
|
1125
|
+
this.trackedValues.delete(key);
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
completeSend(payload) {
|
|
1129
|
+
const resolve = this.pendingSends.get(payload.id) ?? null;
|
|
1130
|
+
const didTimeout = resolve === null;
|
|
1131
|
+
if (didTimeout)
|
|
1132
|
+
return;
|
|
1133
|
+
this.pendingSends.delete(payload.id);
|
|
1134
|
+
const result = payload.result;
|
|
1135
|
+
resolve(result.ok
|
|
1136
|
+
? { ok: true, data: null }
|
|
1137
|
+
: { ok: false, error: PopcornError.deserialize(result.error) });
|
|
1138
|
+
}
|
|
1139
|
+
nextRequestId() {
|
|
1140
|
+
this.requestSeq += 1;
|
|
1141
|
+
return `send:${this.requestSeq}`;
|
|
1142
|
+
}
|
|
1143
|
+
handleStdout(chunk) {
|
|
1144
|
+
this.output.stdout(chunk);
|
|
1145
|
+
}
|
|
1146
|
+
handleStderr(chunk) {
|
|
1147
|
+
this.output.stderr(chunk);
|
|
1148
|
+
}
|
|
1149
|
+
handleOtpError(payload) {
|
|
1150
|
+
const onError = this.opts.onError ?? defaultOnError;
|
|
1151
|
+
onError(payload);
|
|
1152
|
+
check(this.state.status === "booting" || this.state.status === "booted");
|
|
1153
|
+
// if failed while booting, settle early
|
|
1154
|
+
const booting = this.state.status === "booting";
|
|
1155
|
+
if (booting) {
|
|
1156
|
+
check(this.settleBoot !== null);
|
|
1157
|
+
const error = err$1("vm:exited", exitReason(payload));
|
|
1158
|
+
this.settleBoot({ ok: false, error });
|
|
1159
|
+
return;
|
|
1160
|
+
}
|
|
1161
|
+
this.deinit(exitReason(payload));
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
/**
|
|
1165
|
+
* Builds `beam.emulatorArgs` for scheduler counts.
|
|
1166
|
+
*
|
|
1167
|
+
* Defaults to one scheduler of each type.
|
|
1168
|
+
*/
|
|
1169
|
+
function schedulers(opts) {
|
|
1170
|
+
const { base, dirtyCpu, dirtyIo } = opts;
|
|
1171
|
+
check(base > 0);
|
|
1172
|
+
check(dirtyCpu > 0);
|
|
1173
|
+
check(dirtyIo > 0);
|
|
1174
|
+
return ["-S", base, "-SDcpu", dirtyCpu, "-SDio", dirtyIo].map(String);
|
|
1175
|
+
}
|
|
1176
|
+
function isValidTtySize({ columns, rows }) {
|
|
1177
|
+
const colInRange = 0 < columns && columns <= 0xffff;
|
|
1178
|
+
const rowInRange = 0 < rows && rows <= 0xffff;
|
|
1179
|
+
return colInRange && rowInRange;
|
|
1180
|
+
}
|
|
1181
|
+
function resolveOutputHandlers(opts) {
|
|
1182
|
+
if (opts.tty?.output === "bytes") {
|
|
1183
|
+
const onStdout = opts.onStdout;
|
|
1184
|
+
const onStderr = opts.onStderr;
|
|
1185
|
+
return {
|
|
1186
|
+
stdout: onStdout ?? defaultOnStdoutBytes,
|
|
1187
|
+
stderr: onStderr ?? defaultOnStderrBytes,
|
|
1188
|
+
};
|
|
1189
|
+
}
|
|
1190
|
+
const stdoutDecoder = new TextDecoder();
|
|
1191
|
+
const stderrDecoder = new TextDecoder();
|
|
1192
|
+
const onStdout = opts.onStdout ?? defaultOnStdout;
|
|
1193
|
+
const onStderr = opts.onStderr ?? defaultOnStderr;
|
|
1194
|
+
return {
|
|
1195
|
+
stdout: (chunk) => decodeOutput(stdoutDecoder, onStdout, chunk),
|
|
1196
|
+
stderr: (chunk) => decodeOutput(stderrDecoder, onStderr, chunk),
|
|
1197
|
+
};
|
|
1198
|
+
}
|
|
1199
|
+
function decodeOutput(decoder, onOutput, chunk) {
|
|
1200
|
+
const output = decoder.decode(chunk, { stream: true });
|
|
1201
|
+
if (output.length > 0)
|
|
1202
|
+
onOutput(output);
|
|
1203
|
+
}
|
|
1204
|
+
function createIoState() {
|
|
1205
|
+
return {
|
|
1206
|
+
stdin: {
|
|
1207
|
+
reservedBytes: 0,
|
|
1208
|
+
},
|
|
1209
|
+
};
|
|
1210
|
+
}
|
|
1211
|
+
function toBytes(chunk) {
|
|
1212
|
+
return typeof chunk === "string" ? UTF8.encode(chunk) : chunk.slice();
|
|
1213
|
+
}
|
|
1214
|
+
function exitReason(payload) {
|
|
1215
|
+
switch (payload.kind) {
|
|
1216
|
+
case "abort":
|
|
1217
|
+
return { reason: "abort", data: payload.data };
|
|
1218
|
+
case "error":
|
|
1219
|
+
return { reason: "error", data: payload.data };
|
|
1220
|
+
case "exit":
|
|
1221
|
+
return { reason: "exit", data: payload.data };
|
|
1222
|
+
default:
|
|
1223
|
+
return unreachable();
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
function trackedRefKey(value) {
|
|
1227
|
+
const marker = objectWithKeys(value, [TRACKED_REF_KEY]);
|
|
1228
|
+
const hasOnlyMarker = marker !== null && Object.keys(marker).length === 1;
|
|
1229
|
+
if (!hasOnlyMarker) {
|
|
1230
|
+
return null;
|
|
1231
|
+
}
|
|
1232
|
+
const key = marker[TRACKED_REF_KEY];
|
|
1233
|
+
check(typeof key === "number");
|
|
1234
|
+
return key;
|
|
1235
|
+
}
|
|
1236
|
+
function pidRefToken(value) {
|
|
1237
|
+
const marker = objectWithKeys(value, [PID_REF_KEY]);
|
|
1238
|
+
const hasOnlyMarker = marker !== null && Object.keys(marker).length === 1;
|
|
1239
|
+
if (!hasOnlyMarker) {
|
|
1240
|
+
return null;
|
|
1241
|
+
}
|
|
1242
|
+
const token = marker[PID_REF_KEY];
|
|
1243
|
+
check(typeof token === "string");
|
|
1244
|
+
return token;
|
|
1245
|
+
}
|
|
1246
|
+
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/eval#direct_and_indirect_eval
|
|
1247
|
+
function indirectEval(code) {
|
|
1248
|
+
return (0, eval)(code);
|
|
1249
|
+
}
|
|
1250
|
+
function canEval() {
|
|
1251
|
+
try {
|
|
1252
|
+
indirectEval("0");
|
|
1253
|
+
return true;
|
|
1254
|
+
}
|
|
1255
|
+
catch {
|
|
1256
|
+
return false;
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1259
|
+
function defaultOnStdout(chunk) {
|
|
1260
|
+
console.log(`${LOG_PREFIX} stdout:`, chunk);
|
|
1261
|
+
}
|
|
1262
|
+
function defaultOnStderr(chunk) {
|
|
1263
|
+
console.error(`${LOG_PREFIX} stderr:`, chunk);
|
|
1264
|
+
}
|
|
1265
|
+
function defaultOnStdoutBytes(chunk) {
|
|
1266
|
+
console.log(`${LOG_PREFIX} stdout:`, chunk);
|
|
1267
|
+
}
|
|
1268
|
+
function defaultOnStderrBytes(chunk) {
|
|
1269
|
+
console.error(`${LOG_PREFIX} stderr:`, chunk);
|
|
1270
|
+
}
|
|
1271
|
+
function defaultOnError(payload) {
|
|
1272
|
+
switch (payload.kind) {
|
|
1273
|
+
case "abort":
|
|
1274
|
+
console.error(`${LOG_PREFIX} abort:`, payload.data);
|
|
1275
|
+
return;
|
|
1276
|
+
case "error":
|
|
1277
|
+
console.error(`${LOG_PREFIX} error:`, payload.data);
|
|
1278
|
+
return;
|
|
1279
|
+
case "exit":
|
|
1280
|
+
console.info(`${LOG_PREFIX} exit:`, payload.data);
|
|
1281
|
+
return;
|
|
1282
|
+
default:
|
|
1283
|
+
unreachable();
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
export { Popcorn, PopcornError, a, atom, schedulers, t, tuple };
|