@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/worker.mjs
ADDED
|
@@ -0,0 +1,765 @@
|
|
|
1
|
+
import createModule from './beam.mjs';
|
|
2
|
+
|
|
3
|
+
function err(t, data) {
|
|
4
|
+
return new PopcornError({ t, data });
|
|
5
|
+
}
|
|
6
|
+
function isErr(error, t) {
|
|
7
|
+
const isInstance = error instanceof PopcornError;
|
|
8
|
+
if (!isInstance)
|
|
9
|
+
return false;
|
|
10
|
+
return true;
|
|
11
|
+
}
|
|
12
|
+
/** @hidden */
|
|
13
|
+
class PopcornError extends Error {
|
|
14
|
+
cause;
|
|
15
|
+
serialized;
|
|
16
|
+
constructor(cause) {
|
|
17
|
+
super(message(cause), { cause });
|
|
18
|
+
this.name = "PopcornError";
|
|
19
|
+
Object.setPrototypeOf(this, new.target.prototype);
|
|
20
|
+
this.cause = cause;
|
|
21
|
+
this.serialized = cause;
|
|
22
|
+
}
|
|
23
|
+
get t() {
|
|
24
|
+
return this.serialized.t;
|
|
25
|
+
}
|
|
26
|
+
get data() {
|
|
27
|
+
return this.serialized.data;
|
|
28
|
+
}
|
|
29
|
+
/** Returns the tag and a shallow copy of its details. */
|
|
30
|
+
serialize() {
|
|
31
|
+
return {
|
|
32
|
+
t: this.serialized.t,
|
|
33
|
+
data: { ...this.serialized.data },
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
/** Restores a serialized error. Throws if validation fails. */
|
|
37
|
+
static deserialize(value) {
|
|
38
|
+
return new PopcornError(parse(value));
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
function message(error) {
|
|
42
|
+
switch (error.t) {
|
|
43
|
+
case "timeout:init":
|
|
44
|
+
return `Init timed out after ${error.data.timeoutMs}ms`;
|
|
45
|
+
case "timeout:send":
|
|
46
|
+
return `Send timed out after ${error.data.timeoutMs}ms`;
|
|
47
|
+
case "timeout:call":
|
|
48
|
+
return `Call timed out after ${error.data.timeoutMs}ms`;
|
|
49
|
+
case "worker:load":
|
|
50
|
+
return error.data.message;
|
|
51
|
+
case "vm:exited":
|
|
52
|
+
return "VM exited";
|
|
53
|
+
case "bridge:not-started":
|
|
54
|
+
return "Bridge did not start";
|
|
55
|
+
case "bridge:invalid-target":
|
|
56
|
+
return "Target must be a non-empty name or a PID from this VM boot";
|
|
57
|
+
case "bridge:unserializable":
|
|
58
|
+
return "Message can't be serialized to ETF";
|
|
59
|
+
case "bridge:listener-not-found":
|
|
60
|
+
return `Target listener not found: '${error.data.targetName}'`;
|
|
61
|
+
case "genserver:noproc":
|
|
62
|
+
return `No process registered for genserver target: '${error.data.target}'`;
|
|
63
|
+
case "genserver:exit":
|
|
64
|
+
return `Genserver exited: ${error.data.reason}`;
|
|
65
|
+
case "genserver:unserializable":
|
|
66
|
+
return "Genserver reply can't be serialized to JSON";
|
|
67
|
+
case "stdio:overflow":
|
|
68
|
+
return `Stdin chunk exceeds the ${error.data.capacityBytes} byte queue capacity`;
|
|
69
|
+
case "beam:missing-boot-script":
|
|
70
|
+
return `Missing boot script: '${error.data.url}'`;
|
|
71
|
+
case "beam:missing-manifest":
|
|
72
|
+
return `Missing tarball manifest: '${error.data.url}'`;
|
|
73
|
+
case "beam:missing-tarball":
|
|
74
|
+
return `Missing tarball: '${error.data.name}'. Available tarballs: ${error.data.all.join(", ")}`;
|
|
75
|
+
case "internal:check":
|
|
76
|
+
return error.data.detail === undefined
|
|
77
|
+
? "Check failed"
|
|
78
|
+
: `Check failed: ${error.data.detail}`;
|
|
79
|
+
case "internal:unreachable":
|
|
80
|
+
return "Entered unreachable code";
|
|
81
|
+
case "runtime:eval-unavailable":
|
|
82
|
+
return "JS eval is unavailable; run_js requires a Content-Security-Policy that allows 'unsafe-eval'";
|
|
83
|
+
default:
|
|
84
|
+
unreachable$1();
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
function parse(value) {
|
|
88
|
+
check$1(objectWithKeys$1(value, ["t", "data"]));
|
|
89
|
+
switch (value.t) {
|
|
90
|
+
case "timeout:init":
|
|
91
|
+
case "timeout:send":
|
|
92
|
+
check$1(isTimeoutData(value.data));
|
|
93
|
+
return { t: value.t, data: value.data };
|
|
94
|
+
case "worker:load":
|
|
95
|
+
check$1(isWorkerLoadData(value.data));
|
|
96
|
+
return { t: value.t, data: value.data };
|
|
97
|
+
case "vm:exited":
|
|
98
|
+
check$1(isVmExitedData(value.data));
|
|
99
|
+
return { t: value.t, data: value.data };
|
|
100
|
+
case "bridge:not-started":
|
|
101
|
+
check$1(isEmptyData(value.data));
|
|
102
|
+
return { t: value.t, data: value.data };
|
|
103
|
+
case "bridge:invalid-target":
|
|
104
|
+
check$1(isEmptyData(value.data));
|
|
105
|
+
return { t: value.t, data: value.data };
|
|
106
|
+
case "bridge:unserializable":
|
|
107
|
+
check$1(isUnserializableData(value.data));
|
|
108
|
+
return { t: value.t, data: value.data };
|
|
109
|
+
case "bridge:listener-not-found":
|
|
110
|
+
check$1(isListenerNotFoundData(value.data));
|
|
111
|
+
return { t: value.t, data: value.data };
|
|
112
|
+
case "stdio:overflow":
|
|
113
|
+
check$1(isStdioOverflowData(value.data));
|
|
114
|
+
return { t: value.t, data: value.data };
|
|
115
|
+
case "beam:missing-boot-script":
|
|
116
|
+
case "beam:missing-manifest":
|
|
117
|
+
check$1(isUrlData(value.data));
|
|
118
|
+
return { t: value.t, data: value.data };
|
|
119
|
+
case "beam:missing-tarball":
|
|
120
|
+
check$1(isMissingTarballData(value.data));
|
|
121
|
+
return { t: value.t, data: value.data };
|
|
122
|
+
case "internal:check":
|
|
123
|
+
check$1(isInternalCheckData(value.data));
|
|
124
|
+
return { t: value.t, data: value.data };
|
|
125
|
+
case "internal:unreachable":
|
|
126
|
+
case "runtime:eval-unavailable":
|
|
127
|
+
check$1(isEmptyData(value.data));
|
|
128
|
+
return { t: value.t, data: value.data };
|
|
129
|
+
default:
|
|
130
|
+
unreachable$1();
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
function isTimeoutData(value) {
|
|
134
|
+
return objectWithKeys$1(value, ["timeoutMs"]) !== null;
|
|
135
|
+
}
|
|
136
|
+
function isWorkerLoadData(value) {
|
|
137
|
+
return objectWithKeys$1(value, ["message"]) !== null;
|
|
138
|
+
}
|
|
139
|
+
function isVmExitedData(value) {
|
|
140
|
+
return objectWithKeys$1(value, ["reason"]) !== null;
|
|
141
|
+
}
|
|
142
|
+
function isListenerNotFoundData(value) {
|
|
143
|
+
return objectWithKeys$1(value, ["targetName"]) !== null;
|
|
144
|
+
}
|
|
145
|
+
function isStdioOverflowData(value) {
|
|
146
|
+
return objectWithKeys$1(value, ["capacityBytes", "attemptedBytes"]) !== null;
|
|
147
|
+
}
|
|
148
|
+
function isUnserializableData(value) {
|
|
149
|
+
return (objectWithKeys$1(value, ["data", "part", "reason"]) &&
|
|
150
|
+
isUnserializableReason(value.reason));
|
|
151
|
+
}
|
|
152
|
+
function isUnserializableReason(value) {
|
|
153
|
+
return (value === "cyclic-object" ||
|
|
154
|
+
value === "non-plain-object" ||
|
|
155
|
+
value === "lossy-int" ||
|
|
156
|
+
value === "non-finite-float" ||
|
|
157
|
+
value === "unsupported");
|
|
158
|
+
}
|
|
159
|
+
function isUrlData(value) {
|
|
160
|
+
return objectWithKeys$1(value, ["url"]) !== null;
|
|
161
|
+
}
|
|
162
|
+
function isMissingTarballData(value) {
|
|
163
|
+
return objectWithKeys$1(value, ["name", "all"]) !== null;
|
|
164
|
+
}
|
|
165
|
+
function isInternalCheckData(value) {
|
|
166
|
+
return objectWithKeys$1(value, []) !== null;
|
|
167
|
+
}
|
|
168
|
+
function isEmptyData(value) {
|
|
169
|
+
return objectWithKeys$1(value, []) !== null;
|
|
170
|
+
}
|
|
171
|
+
function objectWithKeys$1(value, keys) {
|
|
172
|
+
const isObject = value !== null && typeof value === "object";
|
|
173
|
+
return isObject && keys.every((key) => Object.hasOwn(value, key));
|
|
174
|
+
}
|
|
175
|
+
function unreachable$1() {
|
|
176
|
+
throw err("internal:unreachable", {});
|
|
177
|
+
}
|
|
178
|
+
function check$1(ok, msg) {
|
|
179
|
+
if (!ok) {
|
|
180
|
+
throw err("internal:check", { detail: msg });
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function dirname(path) {
|
|
185
|
+
const idx = path.lastIndexOf("/");
|
|
186
|
+
if (idx <= 0)
|
|
187
|
+
return "/";
|
|
188
|
+
return path.slice(0, idx);
|
|
189
|
+
}
|
|
190
|
+
async function fetchBinary(url) {
|
|
191
|
+
const response = await fetch(url);
|
|
192
|
+
if (response.ok !== true)
|
|
193
|
+
return null;
|
|
194
|
+
return new Uint8Array(await response.arrayBuffer());
|
|
195
|
+
}
|
|
196
|
+
async function fetchJson(url) {
|
|
197
|
+
const response = await fetch(url);
|
|
198
|
+
if (response.ok !== true)
|
|
199
|
+
return null;
|
|
200
|
+
try {
|
|
201
|
+
return await response.json();
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
function base64ToBytes(b64) {
|
|
208
|
+
const binary = atob(b64);
|
|
209
|
+
const bytes = new Uint8Array(binary.length);
|
|
210
|
+
for (let i = 0; i < binary.length; i++) {
|
|
211
|
+
bytes[i] = binary.charCodeAt(i);
|
|
212
|
+
}
|
|
213
|
+
return bytes;
|
|
214
|
+
}
|
|
215
|
+
function check(ok, msg) {
|
|
216
|
+
if (!ok)
|
|
217
|
+
throw err("internal:check", { detail: msg });
|
|
218
|
+
}
|
|
219
|
+
function unreachable() {
|
|
220
|
+
throw err("internal:unreachable", {});
|
|
221
|
+
}
|
|
222
|
+
function objectWithKeys(value, keys) {
|
|
223
|
+
if (value === null || typeof value !== "object")
|
|
224
|
+
return null;
|
|
225
|
+
if (value.constructor !== Object)
|
|
226
|
+
return null;
|
|
227
|
+
const hasAllKeys = keys.every((k) => Object.hasOwn(value, k));
|
|
228
|
+
if (!hasAllKeys)
|
|
229
|
+
return null;
|
|
230
|
+
return value;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
new TextEncoder();
|
|
234
|
+
|
|
235
|
+
function readMainEvent(value) {
|
|
236
|
+
const data = objectWithKeys(value, ["type", "payload"]);
|
|
237
|
+
check(data !== null && typeof data.type === "string");
|
|
238
|
+
switch (data.type) {
|
|
239
|
+
case "popcorn:boot":
|
|
240
|
+
case "popcorn:stdin":
|
|
241
|
+
case "popcorn:tty-resize":
|
|
242
|
+
case "popcorn:send":
|
|
243
|
+
case "popcorn:run-js-reply":
|
|
244
|
+
return data;
|
|
245
|
+
default:
|
|
246
|
+
unreachable();
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
function deserializeBridgeMessage(text) {
|
|
250
|
+
try {
|
|
251
|
+
const parsed = JSON.parse(text);
|
|
252
|
+
if (!isBridgeEnvelope(parsed))
|
|
253
|
+
return null;
|
|
254
|
+
switch (parsed.type) {
|
|
255
|
+
case "vm_message":
|
|
256
|
+
return { type: "otp:message", payload: parsed.data };
|
|
257
|
+
case "vm_error":
|
|
258
|
+
return {
|
|
259
|
+
type: "otp:error",
|
|
260
|
+
payload: { kind: "error", data: parsed.data },
|
|
261
|
+
};
|
|
262
|
+
case "run_js":
|
|
263
|
+
return {
|
|
264
|
+
type: "otp:run_js",
|
|
265
|
+
payload: {
|
|
266
|
+
code: parsed.code,
|
|
267
|
+
args: parsed.args,
|
|
268
|
+
replyTo: base64ToBytes(parsed.reply_to),
|
|
269
|
+
return: parsed.return,
|
|
270
|
+
},
|
|
271
|
+
};
|
|
272
|
+
default:
|
|
273
|
+
return null;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
catch {
|
|
277
|
+
return null;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
/** Usable only from webworkers. */
|
|
281
|
+
function toMain(event) {
|
|
282
|
+
self.postMessage(event, { transfer: getTransferables(event) });
|
|
283
|
+
}
|
|
284
|
+
function getTransferables(event) {
|
|
285
|
+
const isTtyEvent = event.type === "otp:stdout" || event.type === "otp:stderr";
|
|
286
|
+
if (!isTtyEvent)
|
|
287
|
+
return [];
|
|
288
|
+
check(event.payload.buffer instanceof ArrayBuffer);
|
|
289
|
+
return [event.payload.buffer];
|
|
290
|
+
}
|
|
291
|
+
function isBridgeEnvelope(value) {
|
|
292
|
+
const KNOWN_MESSAGE_TYPES = ["vm_message", "vm_error", "run_js"];
|
|
293
|
+
const data = objectWithKeys(value, ["type"]);
|
|
294
|
+
return data !== null && KNOWN_MESSAGE_TYPES.includes(data.type);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Tar format constants.
|
|
298
|
+
const T = {
|
|
299
|
+
/// entire block size (bytes)
|
|
300
|
+
BLK_N: 512,
|
|
301
|
+
/// filename field offset
|
|
302
|
+
NAME_OFF: 0,
|
|
303
|
+
/// name field size (bytes)
|
|
304
|
+
NAME_N: 100,
|
|
305
|
+
/// file size field offset
|
|
306
|
+
SIZE_OFF: 124,
|
|
307
|
+
/// file size field size (bytes). Stored as ASCII octal string.
|
|
308
|
+
SIZE_N: 12,
|
|
309
|
+
/// entry type field offset, 1 byte.
|
|
310
|
+
TYPEFLAG_OFF: 156,
|
|
311
|
+
/// prefix field offset, 1 byte. Used when name is longer than `name` field.
|
|
312
|
+
PREFIX_OFF: 345,
|
|
313
|
+
/// prefix field size (bytes)
|
|
314
|
+
PREFIX_N: 155,
|
|
315
|
+
/// entry type=dir, '5' in ASCII
|
|
316
|
+
TYPE_DIR: 53,
|
|
317
|
+
};
|
|
318
|
+
function extractTar(data, onDir, onFile) {
|
|
319
|
+
check(data.length % T.BLK_N === 0, "tar:bad_chunk");
|
|
320
|
+
const decoder = new TextDecoder();
|
|
321
|
+
let offset = 0;
|
|
322
|
+
while (offset + T.BLK_N <= data.length) {
|
|
323
|
+
const header = data.slice(offset, offset + T.BLK_N);
|
|
324
|
+
if (isZeroBlock(header))
|
|
325
|
+
break;
|
|
326
|
+
const name = readString(decoder, header, T.NAME_OFF, T.NAME_N);
|
|
327
|
+
const prefix = readString(decoder, header, T.PREFIX_OFF, T.PREFIX_N);
|
|
328
|
+
const fullName = prefix ? `${prefix}/${name}` : name;
|
|
329
|
+
const size = parseOctal(readString(decoder, header, T.SIZE_OFF, T.SIZE_N));
|
|
330
|
+
const type = header[T.TYPEFLAG_OFF];
|
|
331
|
+
offset += T.BLK_N;
|
|
332
|
+
const path = fullName.startsWith("/") ? fullName : `/${fullName}`;
|
|
333
|
+
if (type === T.TYPE_DIR) {
|
|
334
|
+
onDir(path);
|
|
335
|
+
}
|
|
336
|
+
else if (fullName) {
|
|
337
|
+
const contents = data.slice(offset, offset + size);
|
|
338
|
+
onFile(path, contents);
|
|
339
|
+
}
|
|
340
|
+
offset += Math.ceil(size / T.BLK_N) * T.BLK_N;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
function isZeroBlock(block) {
|
|
344
|
+
for (let i = 0; i < block.length; i++) {
|
|
345
|
+
if (block[i] !== 0)
|
|
346
|
+
return false;
|
|
347
|
+
}
|
|
348
|
+
return true;
|
|
349
|
+
}
|
|
350
|
+
function readString(decoder, data, start, length) {
|
|
351
|
+
check(length > 0, "tar:bad_string");
|
|
352
|
+
let end = start;
|
|
353
|
+
const max = start + length;
|
|
354
|
+
while (end < max && data[end] !== 0)
|
|
355
|
+
end++;
|
|
356
|
+
if (end === start)
|
|
357
|
+
return "";
|
|
358
|
+
return decoder.decode(data.slice(start, end));
|
|
359
|
+
}
|
|
360
|
+
function parseOctal(value) {
|
|
361
|
+
const parsed = parseInt(value, 8);
|
|
362
|
+
check(!Number.isNaN(parsed), "tar:bad_octal");
|
|
363
|
+
return parsed;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const DEFAULT_USER = "web_user";
|
|
367
|
+
const DEFAULT_HOME_DIR = "/home/web_user";
|
|
368
|
+
const FS_DIRS = ["/bin", "/lib", "/etc", "/tmp", "/home", DEFAULT_HOME_DIR];
|
|
369
|
+
const BOOT_NAME = "vm";
|
|
370
|
+
const BOOT_PATH = `/bin/${BOOT_NAME}.boot`;
|
|
371
|
+
const MANIFEST_NAME = "manifest.json";
|
|
372
|
+
const ENTRYPOINT_READY_EXPR = 'wasm:send(#{<<"_popcorn">> => #{<<"t">> => <<"boot_ready">>}})';
|
|
373
|
+
const ENTRYPOINT_FAILED_EXPR = 'wasm:send(#{<<"_popcorn">> => #{<<"t">> => <<"boot_failed">>}})';
|
|
374
|
+
// https://www.erlang.org/doc/apps/erts/inet_cfg.html
|
|
375
|
+
const INETRC_PATH = "/etc/inetrc";
|
|
376
|
+
// lookup types: `native | file | dns`
|
|
377
|
+
// We need `file` lookup to avoid spawning
|
|
378
|
+
// /bin/inet_gethost which is not available
|
|
379
|
+
const INETRC = "{lookup, [file]}.\n";
|
|
380
|
+
const STDOUT_FD = 1;
|
|
381
|
+
const UTF8 = new TextEncoder();
|
|
382
|
+
const BASE_ARGS = [
|
|
383
|
+
"-root",
|
|
384
|
+
"/",
|
|
385
|
+
"-bindir",
|
|
386
|
+
"/bin",
|
|
387
|
+
"-progname",
|
|
388
|
+
"erl",
|
|
389
|
+
"-home",
|
|
390
|
+
DEFAULT_HOME_DIR,
|
|
391
|
+
"-kernel",
|
|
392
|
+
"start_distribution",
|
|
393
|
+
"false",
|
|
394
|
+
];
|
|
395
|
+
const CORE_APPS = new Set(["kernel", "stdlib", "compiler"]);
|
|
396
|
+
function start(options) {
|
|
397
|
+
const state = { module: null, isVmReady: false };
|
|
398
|
+
const vm = trackVmReady(state);
|
|
399
|
+
return {
|
|
400
|
+
boot: boot(options, state, vm),
|
|
401
|
+
vmReady: vm.vmReady,
|
|
402
|
+
send: (message) => send(state.isVmReady ? state.module : null, message),
|
|
403
|
+
writeStdin: (chunk) => writeStdin(state.module, chunk),
|
|
404
|
+
resizeTty: (columns, rows) => resizeTty(state.module, columns, rows),
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
async function boot(opts, state, vm) {
|
|
408
|
+
const { otpAssetsRoot, emulatorArgs, extraArgs, env, ttySize, createModule, emit, } = opts;
|
|
409
|
+
const loadedFsData = await loadFsData(otpAssetsRoot);
|
|
410
|
+
if (!loadedFsData.ok) {
|
|
411
|
+
return { ok: false, error: loadedFsData.error };
|
|
412
|
+
}
|
|
413
|
+
const fsData = loadedFsData.data;
|
|
414
|
+
const { vmReady, handleVmReady } = vm;
|
|
415
|
+
const { appReady, handleAppReady } = trackAppReady(fsData.entrypoint);
|
|
416
|
+
const runtimeEnv = {
|
|
417
|
+
...env,
|
|
418
|
+
BINDIR: "/bin",
|
|
419
|
+
EMU: "beam",
|
|
420
|
+
HOME: DEFAULT_HOME_DIR,
|
|
421
|
+
USER: DEFAULT_USER,
|
|
422
|
+
LOGNAME: DEFAULT_USER,
|
|
423
|
+
COLUMNS: String(ttySize.columns),
|
|
424
|
+
LINES: String(ttySize.rows),
|
|
425
|
+
ERL_INETRC: INETRC_PATH,
|
|
426
|
+
};
|
|
427
|
+
const moduleConfig = {
|
|
428
|
+
print: (text) => emit({ type: "otp:stdout", payload: UTF8.encode(text) }),
|
|
429
|
+
printErr: (text) => emit({ type: "otp:stderr", payload: UTF8.encode(text) }),
|
|
430
|
+
onExit: (code) => emit({ type: "otp:error", payload: { kind: "exit", data: code } }),
|
|
431
|
+
onAbort: (text) => emit({ type: "otp:error", payload: { kind: "abort", data: text } }),
|
|
432
|
+
onBeamMessage: (text) => {
|
|
433
|
+
const event = deserializeBridgeMessage(text);
|
|
434
|
+
if (event === null)
|
|
435
|
+
return;
|
|
436
|
+
if (handleVmReady(event))
|
|
437
|
+
return;
|
|
438
|
+
if (handleAppReady(event))
|
|
439
|
+
return;
|
|
440
|
+
emit(event);
|
|
441
|
+
},
|
|
442
|
+
onError: (text) => emit({ type: "otp:error", payload: { kind: "error", data: text } }),
|
|
443
|
+
onStdinConsumed: (size) => emit({ type: "otp:stdin-consumed", payload: size }),
|
|
444
|
+
onTrackedValueDelete: (key) => emit({ type: "otp:tracked-value-delete", payload: key }),
|
|
445
|
+
onTtyChunk: (fd, chunk) => emit({
|
|
446
|
+
type: fd === STDOUT_FD ? "otp:stdout" : "otp:stderr",
|
|
447
|
+
payload: chunk,
|
|
448
|
+
}),
|
|
449
|
+
arguments: buildArgs({
|
|
450
|
+
appNames: fsData.appNames,
|
|
451
|
+
entrypoint: fsData.entrypoint,
|
|
452
|
+
emulator: emulatorArgs ?? [],
|
|
453
|
+
extra: extraArgs ?? [],
|
|
454
|
+
}),
|
|
455
|
+
preRun: [
|
|
456
|
+
(mod) => {
|
|
457
|
+
state.module = mod;
|
|
458
|
+
},
|
|
459
|
+
(mod) => {
|
|
460
|
+
Object.assign(mod.ENV, runtimeEnv);
|
|
461
|
+
initFs({ module: mod, fsData });
|
|
462
|
+
},
|
|
463
|
+
],
|
|
464
|
+
};
|
|
465
|
+
try {
|
|
466
|
+
const ready = Promise.all([vmReady, appReady]);
|
|
467
|
+
const module = await createModule(moduleConfig);
|
|
468
|
+
check(state.module === module);
|
|
469
|
+
await ready;
|
|
470
|
+
return { ok: true, data: null };
|
|
471
|
+
}
|
|
472
|
+
catch (error) {
|
|
473
|
+
return { ok: false, error: toPopcornError(error) };
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
function trackVmReady(state) {
|
|
477
|
+
let resolve = () => { };
|
|
478
|
+
const vmReady = new Promise((r) => {
|
|
479
|
+
resolve = r;
|
|
480
|
+
});
|
|
481
|
+
const handleVmReady = (event) => {
|
|
482
|
+
if (!isBridgeMarker(event, "vm_ready"))
|
|
483
|
+
return false;
|
|
484
|
+
state.isVmReady = true;
|
|
485
|
+
resolve();
|
|
486
|
+
return true;
|
|
487
|
+
};
|
|
488
|
+
return { vmReady, handleVmReady };
|
|
489
|
+
}
|
|
490
|
+
function trackAppReady(entrypoint) {
|
|
491
|
+
let resolve = () => { };
|
|
492
|
+
let reject = (_error) => { };
|
|
493
|
+
let appReady = Promise.resolve();
|
|
494
|
+
if (entrypoint !== null) {
|
|
495
|
+
appReady = new Promise((res, rej) => {
|
|
496
|
+
resolve = res;
|
|
497
|
+
reject = rej;
|
|
498
|
+
});
|
|
499
|
+
}
|
|
500
|
+
const handleAppReady = (event) => {
|
|
501
|
+
if (isBridgeMarker(event, "boot_ready")) {
|
|
502
|
+
resolve();
|
|
503
|
+
return true;
|
|
504
|
+
}
|
|
505
|
+
if (isBridgeMarker(event, "boot_failed")) {
|
|
506
|
+
reject(err("vm:exited", { reason: "exit", data: 1 }));
|
|
507
|
+
return true;
|
|
508
|
+
}
|
|
509
|
+
return false;
|
|
510
|
+
};
|
|
511
|
+
return { appReady, handleAppReady };
|
|
512
|
+
}
|
|
513
|
+
function toPopcornError(error) {
|
|
514
|
+
if (isErr(error))
|
|
515
|
+
return error;
|
|
516
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
517
|
+
return err("worker:load", { message });
|
|
518
|
+
}
|
|
519
|
+
function buildArgs({ appNames, entrypoint, emulator, extra, }) {
|
|
520
|
+
const args = [...emulator, "--", ...BASE_ARGS, "-boot", BOOT_NAME];
|
|
521
|
+
for (const app of CORE_APPS) {
|
|
522
|
+
args.push("-pa", `/lib/${app}/ebin`);
|
|
523
|
+
}
|
|
524
|
+
for (const app of appNames) {
|
|
525
|
+
if (CORE_APPS.has(app))
|
|
526
|
+
continue;
|
|
527
|
+
args.push("-pa", `/lib/${app}/ebin`);
|
|
528
|
+
}
|
|
529
|
+
if (entrypoint !== null) {
|
|
530
|
+
args.push("-eval", `case application:ensure_all_started(${entrypoint}) of {ok, _} -> ${ENTRYPOINT_READY_EXPR}; _ -> ${ENTRYPOINT_FAILED_EXPR}, erlang:halt(1) end.`);
|
|
531
|
+
}
|
|
532
|
+
for (const arg of extra) {
|
|
533
|
+
args.push(arg);
|
|
534
|
+
}
|
|
535
|
+
return args;
|
|
536
|
+
}
|
|
537
|
+
function isBridgeMarker(event, type) {
|
|
538
|
+
if (event === null || event.type !== "otp:message")
|
|
539
|
+
return false;
|
|
540
|
+
const popcorn = objectWithKeys(event.payload, ["_popcorn"])?._popcorn;
|
|
541
|
+
return objectWithKeys(popcorn, ["t"])?.t === type;
|
|
542
|
+
}
|
|
543
|
+
async function loadFsData(assetsRoot) {
|
|
544
|
+
const manifestUrl = resolveAssetsPath(assetsRoot, MANIFEST_NAME);
|
|
545
|
+
const manifest = await fetchJson(manifestUrl);
|
|
546
|
+
if (manifest === null) {
|
|
547
|
+
return {
|
|
548
|
+
ok: false,
|
|
549
|
+
error: err("beam:missing-manifest", { url: manifestUrl }),
|
|
550
|
+
};
|
|
551
|
+
}
|
|
552
|
+
const appNames = Object.keys(manifest.apps);
|
|
553
|
+
for (const name of CORE_APPS) {
|
|
554
|
+
if (!Object.hasOwn(manifest.apps, name)) {
|
|
555
|
+
return {
|
|
556
|
+
ok: false,
|
|
557
|
+
error: err("beam:missing-tarball", { name, all: appNames }),
|
|
558
|
+
};
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
const bootUrl = resolveAssetsPath(assetsRoot, manifest.vm.boot);
|
|
562
|
+
const bootFile = await fetchBinary(bootUrl);
|
|
563
|
+
if (bootFile === null) {
|
|
564
|
+
return {
|
|
565
|
+
ok: false,
|
|
566
|
+
error: err("beam:missing-boot-script", { url: bootUrl }),
|
|
567
|
+
};
|
|
568
|
+
}
|
|
569
|
+
const loadedTarballs = await Promise.all(appNames.map(async (name) => {
|
|
570
|
+
const entry = manifest.apps[name];
|
|
571
|
+
const tarUrl = resolveAssetsPath(assetsRoot, entry.tar);
|
|
572
|
+
const tar = await fetchBinary(tarUrl);
|
|
573
|
+
if (tar === null) {
|
|
574
|
+
return {
|
|
575
|
+
ok: false,
|
|
576
|
+
error: err("beam:missing-tarball", { name, all: appNames }),
|
|
577
|
+
};
|
|
578
|
+
}
|
|
579
|
+
return { ok: true, data: tar };
|
|
580
|
+
}));
|
|
581
|
+
const tarballs = [];
|
|
582
|
+
for (const tarball of loadedTarballs) {
|
|
583
|
+
if (!tarball.ok) {
|
|
584
|
+
return { ok: false, error: tarball.error };
|
|
585
|
+
}
|
|
586
|
+
tarballs.push(tarball.data);
|
|
587
|
+
}
|
|
588
|
+
return {
|
|
589
|
+
ok: true,
|
|
590
|
+
data: {
|
|
591
|
+
appNames,
|
|
592
|
+
entrypoint: manifest.entrypoint ?? null,
|
|
593
|
+
bootFile,
|
|
594
|
+
tarballs,
|
|
595
|
+
},
|
|
596
|
+
};
|
|
597
|
+
}
|
|
598
|
+
function initFs({ module, fsData }) {
|
|
599
|
+
const writeFile = (path, content) => {
|
|
600
|
+
module.FS_createDataFile(path, null, content, true, true, true);
|
|
601
|
+
};
|
|
602
|
+
for (const dir of FS_DIRS) {
|
|
603
|
+
module.FS_mkdirTree(dir);
|
|
604
|
+
}
|
|
605
|
+
writeFile(BOOT_PATH, fsData.bootFile);
|
|
606
|
+
writeFile(INETRC_PATH, UTF8.encode(INETRC));
|
|
607
|
+
const createDir = (dirPath) => {
|
|
608
|
+
module.FS_mkdirTree(dirPath);
|
|
609
|
+
};
|
|
610
|
+
const createFile = (path, content) => {
|
|
611
|
+
module.FS_mkdirTree(dirname(path));
|
|
612
|
+
writeFile(path, content);
|
|
613
|
+
};
|
|
614
|
+
for (const tarball of fsData.tarballs) {
|
|
615
|
+
extractTar(tarball, createDir, createFile);
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
function resolveAssetsPath(assetsRoot, relativePath) {
|
|
619
|
+
check(assetsRoot.endsWith("/"));
|
|
620
|
+
if (relativePath.startsWith("/") || isAbsoluteUrl(relativePath)) {
|
|
621
|
+
return relativePath;
|
|
622
|
+
}
|
|
623
|
+
const url = new URL(relativePath, new URL(assetsRoot, self.location.href));
|
|
624
|
+
if (assetsRoot.startsWith("/")) {
|
|
625
|
+
return url.pathname;
|
|
626
|
+
}
|
|
627
|
+
return url.toString();
|
|
628
|
+
}
|
|
629
|
+
function isAbsoluteUrl(path) {
|
|
630
|
+
return /^[a-zA-Z][a-zA-Z\d+\-.]*:/.test(path);
|
|
631
|
+
}
|
|
632
|
+
function send(module, message) {
|
|
633
|
+
if (module === null) {
|
|
634
|
+
return { ok: false, error: err("bridge:not-started", {}) };
|
|
635
|
+
}
|
|
636
|
+
let target;
|
|
637
|
+
if (isNameTarget(message.target)) {
|
|
638
|
+
const targetName = message.target.name;
|
|
639
|
+
target = {
|
|
640
|
+
kind: TARGET_REGISTERED_NAME,
|
|
641
|
+
argType: "string",
|
|
642
|
+
value: targetName,
|
|
643
|
+
length: utf8Length(targetName),
|
|
644
|
+
};
|
|
645
|
+
}
|
|
646
|
+
else {
|
|
647
|
+
const bytes = message.target.pid;
|
|
648
|
+
target = {
|
|
649
|
+
kind: TARGET_PID_BYTES,
|
|
650
|
+
argType: "array",
|
|
651
|
+
value: bytes,
|
|
652
|
+
length: bytes.length,
|
|
653
|
+
};
|
|
654
|
+
}
|
|
655
|
+
const status = module.ccall("sendVmMessage", "number", ["number", target.argType, "number", "array", "number"], [
|
|
656
|
+
target.kind,
|
|
657
|
+
target.value,
|
|
658
|
+
target.length,
|
|
659
|
+
message.etf,
|
|
660
|
+
message.etf.byteLength,
|
|
661
|
+
]);
|
|
662
|
+
if (status === 0) {
|
|
663
|
+
return { ok: true, data: null };
|
|
664
|
+
}
|
|
665
|
+
if (status === 1) {
|
|
666
|
+
const t = isNameTarget(message.target) ? message.target.name : "<pid>";
|
|
667
|
+
return {
|
|
668
|
+
ok: false,
|
|
669
|
+
error: err("bridge:listener-not-found", { targetName: t }),
|
|
670
|
+
};
|
|
671
|
+
}
|
|
672
|
+
if (status === 2) {
|
|
673
|
+
return {
|
|
674
|
+
ok: false,
|
|
675
|
+
error: err("bridge:unserializable", {
|
|
676
|
+
data: null,
|
|
677
|
+
part: null,
|
|
678
|
+
reason: "unsupported",
|
|
679
|
+
}),
|
|
680
|
+
};
|
|
681
|
+
}
|
|
682
|
+
unreachable();
|
|
683
|
+
}
|
|
684
|
+
function writeStdin(module, chunk) {
|
|
685
|
+
check(module !== null);
|
|
686
|
+
const status = module.ccall("popcornStdinEnqueue", "number", ["array", "number"], [chunk, chunk.byteLength]);
|
|
687
|
+
check(status === 0);
|
|
688
|
+
}
|
|
689
|
+
function resizeTty(module, columns, rows) {
|
|
690
|
+
check(module !== null);
|
|
691
|
+
const status = module.ccall("popcornTtyResize", "number", ["number", "number"], [columns, rows]);
|
|
692
|
+
check(status === 0);
|
|
693
|
+
}
|
|
694
|
+
const TARGET_REGISTERED_NAME = 0;
|
|
695
|
+
const TARGET_PID_BYTES = 1;
|
|
696
|
+
function isNameTarget(target) {
|
|
697
|
+
return Object.hasOwn(target, "name");
|
|
698
|
+
}
|
|
699
|
+
function utf8Length(text) {
|
|
700
|
+
return UTF8.encode(text).length;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
let instance = null;
|
|
704
|
+
self.onmessage = async (event) => {
|
|
705
|
+
const data = readMainEvent(event.data);
|
|
706
|
+
switch (data.type) {
|
|
707
|
+
case "popcorn:boot": {
|
|
708
|
+
check(instance === null);
|
|
709
|
+
instance = start({
|
|
710
|
+
otpAssetsRoot: data.payload.otpAssetsRoot ??
|
|
711
|
+
// The plugin generates this directory after Vite analyzes the worker.
|
|
712
|
+
new URL(/* @vite-ignore */ "./otp/", import.meta.url).href,
|
|
713
|
+
emulatorArgs: data.payload.emulatorArgs,
|
|
714
|
+
extraArgs: data.payload.extraArgs,
|
|
715
|
+
env: data.payload.env,
|
|
716
|
+
ttySize: data.payload.ttySize,
|
|
717
|
+
createModule,
|
|
718
|
+
emit: toMain,
|
|
719
|
+
});
|
|
720
|
+
void instance.vmReady.then(() => toMain({ type: "popcorn:boot-vm-ready", payload: {} }));
|
|
721
|
+
const result = await instance.boot;
|
|
722
|
+
if (!result.ok) {
|
|
723
|
+
toMain({
|
|
724
|
+
type: "popcorn:boot-fail",
|
|
725
|
+
payload: result.error.serialize(),
|
|
726
|
+
});
|
|
727
|
+
return;
|
|
728
|
+
}
|
|
729
|
+
toMain({ type: "popcorn:boot-end", payload: {} });
|
|
730
|
+
break;
|
|
731
|
+
}
|
|
732
|
+
case "popcorn:send": {
|
|
733
|
+
check(instance !== null);
|
|
734
|
+
const result = instance.send(data.payload.message);
|
|
735
|
+
toMain({
|
|
736
|
+
type: "popcorn:send-end",
|
|
737
|
+
payload: {
|
|
738
|
+
id: data.payload.id,
|
|
739
|
+
result: result.ok
|
|
740
|
+
? { ok: true, data: null }
|
|
741
|
+
: { ok: false, error: result.error.serialize() },
|
|
742
|
+
},
|
|
743
|
+
});
|
|
744
|
+
break;
|
|
745
|
+
}
|
|
746
|
+
case "popcorn:run-js-reply": {
|
|
747
|
+
// ignore the `send()` result, process could've died
|
|
748
|
+
check(instance !== null);
|
|
749
|
+
instance.send(data.payload.message);
|
|
750
|
+
break;
|
|
751
|
+
}
|
|
752
|
+
case "popcorn:stdin": {
|
|
753
|
+
check(instance !== null);
|
|
754
|
+
instance.writeStdin(data.payload.chunk);
|
|
755
|
+
break;
|
|
756
|
+
}
|
|
757
|
+
case "popcorn:tty-resize": {
|
|
758
|
+
check(instance !== null);
|
|
759
|
+
instance.resizeTty(data.payload.columns, data.payload.rows);
|
|
760
|
+
break;
|
|
761
|
+
}
|
|
762
|
+
default:
|
|
763
|
+
unreachable();
|
|
764
|
+
}
|
|
765
|
+
};
|