@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.
Files changed (47) hide show
  1. package/LICENSE +1 -1
  2. package/NOTICE +12 -0
  3. package/README.md +78 -2
  4. package/dist/beam.d.ts +10 -0
  5. package/dist/errors.d.ts +96 -39
  6. package/dist/etf.d.ts +38 -0
  7. package/dist/events.d.ts +82 -0
  8. package/dist/index.d.ts +7 -3
  9. package/dist/index.mjs +1287 -2
  10. package/dist/plugins/beam_tools/lib/popcorn/beam_tools/beam_patcher.ex +184 -0
  11. package/dist/plugins/beam_tools/lib/popcorn/beam_tools/cli.ex +74 -0
  12. package/dist/plugins/beam_tools/lib/popcorn/beam_tools/packager.ex +540 -0
  13. package/dist/plugins/beam_tools/mix.exs +16 -0
  14. package/dist/plugins/beam_tools/patches/kernel/prim_tty.erl +13 -0
  15. package/dist/plugins/beam_tools/patches/stdlib/beam_lib.erl +27 -0
  16. package/dist/plugins/esbuild.d.ts +10 -2
  17. package/dist/plugins/esbuild.mjs +39 -34
  18. package/dist/plugins/rollup.d.ts +10 -2
  19. package/dist/plugins/rollup.mjs +46 -25
  20. package/dist/plugins/shared.d.ts +54 -4
  21. package/dist/plugins/shared.mjs +207 -0
  22. package/dist/plugins/vite.d.ts +17 -2
  23. package/dist/plugins/vite.mjs +201 -75
  24. package/dist/popcorn.d.ts +237 -110
  25. package/dist/runtimes/core/beam.emu.mjs +141 -0
  26. package/dist/runtimes/core/beam.mjs +141 -0
  27. package/dist/runtimes/core/beam.wasm +0 -0
  28. package/dist/runtimes/core/manifest.json +1 -0
  29. package/dist/runtimes/crypto/beam.emu.mjs +520 -0
  30. package/dist/runtimes/crypto/beam.mjs +520 -0
  31. package/dist/runtimes/crypto/beam.wasm +0 -0
  32. package/dist/runtimes/crypto/manifest.json +1 -0
  33. package/dist/tar.d.ts +4 -0
  34. package/dist/types.d.ts +108 -63
  35. package/dist/utils.d.ts +7 -0
  36. package/dist/worker.d.ts +1 -0
  37. package/dist/worker.mjs +765 -0
  38. package/package.json +20 -28
  39. package/dist/AtomVM.mjs +0 -7992
  40. package/dist/AtomVM.wasm +0 -0
  41. package/dist/bridge.d.ts +0 -22
  42. package/dist/bridge.mjs +0 -66
  43. package/dist/errors.mjs +0 -55
  44. package/dist/iframe.d.ts +0 -1
  45. package/dist/iframe.mjs +0 -215
  46. package/dist/popcorn.mjs +0 -381
  47. package/dist/types.mjs +0 -25
package/dist/popcorn.mjs DELETED
@@ -1,381 +0,0 @@
1
- import { IframeBridge } from './bridge.mjs';
2
- import { HEARTBEAT_TIMEOUT_MS, INIT_VM_TIMEOUT_MS, MESSAGES, EVENT_NAMES, MAX_RELOAD_N, CALL_TIMEOUT_MS } from './types.mjs';
3
- import { throwError, buildError, PopcornError } from './errors.mjs';
4
- export { PopcornInternalError } from './errors.mjs';
5
-
6
- const INIT_TOKEN = Symbol();
7
- const IFRAME_URL = new URL("./iframe.mjs", import.meta.url).href;
8
- /**
9
- * Manages Elixir by setting up iframe, WASM module, and event listeners. Used to sent messages to Elixir processes.
10
- */
11
- class Popcorn {
12
- heartbeatTimeoutMs = null;
13
- onReloadCallback;
14
- bridge = null;
15
- bridgeConfig;
16
- debug = false;
17
- bundleURLs;
18
- state = { status: "uninitialized" };
19
- defaultReceiver = null;
20
- requestId = 0;
21
- calls = new Map();
22
- logListeners = {
23
- stdout: new Set(),
24
- stderr: new Set(),
25
- };
26
- messageHandlers = new Set();
27
- mountResolve = null;
28
- heartbeatTimeout = null;
29
- reloadN = 0;
30
- constructor(params, token) {
31
- if (token !== INIT_TOKEN)
32
- throwError({ t: "private_constructor" });
33
- const bundlePaths = params.bundlePaths ?? ["/bundle.avm"];
34
- this.bundleURLs = bundlePaths.map((p) => new URL(p, import.meta.url).href);
35
- this.onReloadCallback = params.onReload ?? noop;
36
- this.debug = params.debug ?? false;
37
- this.bridgeConfig = {
38
- container: params.container,
39
- script: { url: IFRAME_URL, entrypoint: "initVm" },
40
- config: Object.fromEntries(this.bundleURLs.map((url, i) => [`bundle-path-${i}`, url])),
41
- debug: true,
42
- onMessage: this.iframeHandler.bind(this),
43
- };
44
- this.logListeners.stdout.add(params.onStdout ?? console.log);
45
- this.logListeners.stderr.add(params.onStderr ?? console.warn);
46
- this.heartbeatTimeoutMs = params.heartbeatTimeoutMs ?? HEARTBEAT_TIMEOUT_MS;
47
- }
48
- /**
49
- * Creates an iframe and sets up communication channels.
50
- * Returns after the Elixir app calls `Popcorn.Wasm.ready/0,1`.
51
- *
52
- * @example
53
- * import { Popcorn } from "@swmansion/popcorn";
54
- * const popcorn = await Popcorn.init({
55
- * onStdout: console.log,
56
- * onStderr: console.error,
57
- * debug: true,
58
- * });
59
- */
60
- static async init(options) {
61
- const { container, ...constructorParams } = options;
62
- const containerWithDefault = container ?? document.documentElement;
63
- const bundlePaths = constructorParams.bundlePaths && constructorParams.bundlePaths.length > 0
64
- ? constructorParams.bundlePaths
65
- : [await resolveBundleURL("/bundle.avm", "/assets/bundle.avm")];
66
- const popcorn = new Popcorn({ ...constructorParams, bundlePaths, container: containerWithDefault }, INIT_TOKEN);
67
- popcorn.trace("Main: init, params: ", { container, ...constructorParams });
68
- await popcorn.mount();
69
- return popcorn;
70
- }
71
- async mount() {
72
- if (this.bridge !== null)
73
- throwError({ t: "already_mounted" });
74
- this.assertStatus(["uninitialized", "reload"]);
75
- this.transition({ status: "mount" });
76
- this.trace("Main: mount, container: ", this.bridgeConfig.container);
77
- this.bridge = new IframeBridge(this.bridgeConfig);
78
- try {
79
- const mountPromise = new Promise((resolve) => {
80
- this.mountResolve = resolve;
81
- });
82
- let initTimeout;
83
- await Promise.race([
84
- mountPromise,
85
- new Promise((_, reject) => {
86
- initTimeout = setTimeout(() => reject(buildError({ t: "app_ready_timeout" })), INIT_VM_TIMEOUT_MS);
87
- }),
88
- ]);
89
- clearTimeout(initTimeout);
90
- this.transition({ status: "ready" });
91
- this.trace("Main: mounted");
92
- this.onHeartbeat();
93
- }
94
- catch (error) {
95
- this.deinit();
96
- throw error;
97
- }
98
- }
99
- /**
100
- * Sends a message to an Elixir process and awaits for the response.
101
- *
102
- * If Elixir doesn't respond in configured timeout, the returned promise will be rejected with "process timeout" error.
103
- *
104
- * Unless passed via options, the name passed in `Popcorn.Wasm.set_default_receiver/1` on the Elixir side is used.
105
- * Throws "Unspecified target process" if default process is not set and no process is specified.
106
- *
107
- * @example
108
- * const result = await popcorn.call(
109
- * { action: "get_user", id: 123 },
110
- * { process: "user_server", timeoutMs: 5_000 },
111
- * );
112
- * console.log(result.data); // Deserialized Elixir response
113
- * console.log(result.durationMs); // Entire call duration
114
- */
115
- async call(args, { process, timeoutMs } = {}) {
116
- this.assertStatus(["ready"]);
117
- const targetProcess = process ?? this.defaultReceiver;
118
- if (targetProcess === null)
119
- throwError({ t: "bad_target" });
120
- if (this.bridge === null)
121
- throwError({ t: "unmounted" });
122
- const requestId = this.requestId++;
123
- const startTimeMs = performance.now();
124
- const callPromise = new Promise((resolve) => {
125
- this.calls.set(requestId, { acknowledged: false, startTimeMs, resolve });
126
- });
127
- this.trace("Main: call: ", { requestId, process, args });
128
- this.bridge.sendIframeRequest({
129
- type: MESSAGES.CALL,
130
- value: { requestId, process: targetProcess, args },
131
- });
132
- const result = await withTimeout(callPromise, timeoutMs ?? CALL_TIMEOUT_MS);
133
- this.calls.delete(requestId);
134
- return result;
135
- }
136
- /**
137
- * Sends a message to an Elixir process (default or from options) and returns immediately.
138
- *
139
- * Unless passed via options, the name passed in `Popcorn.Wasm.set_default_receiver/1` on the Elixir side is used.
140
- * Throws "Unspecified target process" if default process is not set and no process is specified.
141
- */
142
- cast(args, { process } = {}) {
143
- this.assertStatus(["ready"]);
144
- const targetProcess = process ?? this.defaultReceiver;
145
- if (targetProcess === null)
146
- throwError({ t: "bad_target" });
147
- if (this.bridge === null)
148
- throwError({ t: "unmounted" });
149
- const requestId = this.requestId++;
150
- this.trace("Main: cast: ", { requestId, process, args });
151
- this.bridge.sendIframeRequest({
152
- type: MESSAGES.CAST,
153
- value: { requestId, process: targetProcess, args },
154
- });
155
- }
156
- /**
157
- * Destroys an iframe and resets the instance.
158
- */
159
- deinit() {
160
- if (this.bridge === null)
161
- throwError({ t: "unmounted" });
162
- this.trace("Main: deinit");
163
- this.transition({ status: "deinit" });
164
- this.teardownBridge("deinitialized");
165
- this.logListeners.stdout.clear();
166
- this.logListeners.stderr.clear();
167
- this.messageHandlers.clear();
168
- }
169
- teardownBridge(errorCode) {
170
- if (this.bridge) {
171
- this.bridge.deinit();
172
- this.bridge = null;
173
- }
174
- this.mountResolve = null;
175
- this.defaultReceiver = null;
176
- if (this.heartbeatTimeout) {
177
- clearTimeout(this.heartbeatTimeout);
178
- this.heartbeatTimeout = null;
179
- }
180
- for (const callData of this.calls.values()) {
181
- const durationMs = performance.now() - callData.startTimeMs;
182
- callData.resolve({
183
- ok: false,
184
- error: new PopcornError(errorCode),
185
- durationMs,
186
- });
187
- }
188
- this.calls.clear();
189
- }
190
- /**
191
- * Registers a log listener that will be called when output of the specified type is received.
192
- */
193
- registerLogListener(listener, type) {
194
- this.logListeners[type].add(listener);
195
- }
196
- /**
197
- * Unregisters a previously registered log listener.
198
- */
199
- unregisterLogListener(listener, type) {
200
- this.logListeners[type].delete(listener);
201
- }
202
- notifyLogListeners(type, message) {
203
- this.logListeners[type].forEach((listener) => {
204
- listener(message);
205
- });
206
- }
207
- /**
208
- * Registers a catch-all event handler. Returns an unsubscribe function.
209
- */
210
- onMessage(handler) {
211
- this.messageHandlers.add(handler);
212
- return () => {
213
- this.messageHandlers.delete(handler);
214
- };
215
- }
216
- onEvent({ eventName, payload }) {
217
- if (eventName.startsWith("popcorn")) {
218
- if (eventName === EVENT_NAMES.ELIXIR_READY) {
219
- this.trace("Main: elixir VM ready");
220
- }
221
- else if (eventName === EVENT_NAMES.APP_READY) {
222
- this.defaultReceiver = payload.name;
223
- this.mountResolve?.();
224
- this.mountResolve = null;
225
- }
226
- else if (eventName === EVENT_NAMES.SET_DEFAULT_RECEIVER) {
227
- this.defaultReceiver = payload.name;
228
- }
229
- else {
230
- this.trace("Unknown internal event:", eventName);
231
- }
232
- return;
233
- }
234
- this.messageHandlers.forEach((handler) => {
235
- try {
236
- handler(eventName, payload);
237
- }
238
- catch (error) {
239
- console.error(`Error in onMessage handler for '${eventName}':`, error);
240
- }
241
- });
242
- }
243
- iframeHandler(data) {
244
- if (data.type === MESSAGES.EVENT) {
245
- this.onEvent(data.value);
246
- }
247
- else if (data.type === MESSAGES.STDOUT) {
248
- this.notifyLogListeners("stdout", data.value);
249
- }
250
- else if (data.type === MESSAGES.STDERR) {
251
- this.notifyLogListeners("stderr", data.value);
252
- }
253
- else if (data.type === MESSAGES.CALL) {
254
- this.onCall(data.value);
255
- }
256
- else if (data.type === MESSAGES.CALL_ACK) {
257
- this.onCallAck(data.value);
258
- }
259
- else if (data.type === MESSAGES.HEARTBEAT) {
260
- this.onHeartbeat();
261
- }
262
- else if (data.type === MESSAGES.RELOAD) {
263
- this.reloadIframe();
264
- }
265
- else {
266
- throwError({ t: "assert" });
267
- }
268
- }
269
- onCallAck({ requestId }) {
270
- this.assertStatus(["ready"]);
271
- this.trace("Main: onCallAck: ", { requestId });
272
- const callData = this.calls.get(requestId);
273
- if (callData === undefined)
274
- throwError({ t: "bad_ack" });
275
- this.calls.set(requestId, { ...callData, acknowledged: true });
276
- }
277
- onCall({ requestId, error, data, }) {
278
- this.assertStatus(["ready"]);
279
- this.trace("Main: onCall: ", { requestId, error, data });
280
- const callData = this.calls.get(requestId);
281
- if (callData === undefined)
282
- throwError({ t: "bad_call" });
283
- if (!callData.acknowledged)
284
- throwError({ t: "no_acked_call" });
285
- this.calls.delete(requestId);
286
- const durationMs = performance.now() - callData.startTimeMs;
287
- if (error !== undefined) {
288
- callData.resolve({ ok: false, error, durationMs });
289
- }
290
- else {
291
- callData.resolve({ ok: true, data, durationMs });
292
- }
293
- }
294
- onHeartbeat() {
295
- if (this.heartbeatTimeout) {
296
- clearTimeout(this.heartbeatTimeout);
297
- }
298
- this.heartbeatTimeout = setTimeout(() => {
299
- this.trace("Main: heartbeat lost");
300
- this.reloadIframe("heartbeat_lost");
301
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
302
- }, this.heartbeatTimeoutMs);
303
- }
304
- reloadIframe(reason = "other") {
305
- if (this.bridge === null) {
306
- throwError({ t: "unmounted" });
307
- }
308
- if (document.hidden) {
309
- this.trace("Main: reloading iframe skipped, window not visible");
310
- return;
311
- }
312
- this.reloadN++;
313
- if (this.reloadN > MAX_RELOAD_N) {
314
- this.trace("Main: exceeded max reload number");
315
- return;
316
- }
317
- this.trace("Main: reloading iframe");
318
- this.transition({ status: "reload" });
319
- this.teardownBridge("reload");
320
- this.onReloadCallback(reason);
321
- this.mount();
322
- }
323
- trace(...messages) {
324
- if (this.debug) {
325
- console.debug(...messages);
326
- }
327
- }
328
- transition(to) {
329
- this.trace(`State: ${this.state.status} -> ${to.status}`);
330
- this.state = to;
331
- }
332
- assertStatus(validStatuses) {
333
- const currentStatus = this.state.status;
334
- if (!validStatuses.includes(currentStatus)) {
335
- throwError({
336
- t: "bad_status",
337
- status: currentStatus,
338
- expectedStatus: validStatuses.join(" | "),
339
- });
340
- }
341
- }
342
- }
343
- async function withTimeout(promise, ms) {
344
- let timeout = null;
345
- const timeoutPromise = new Promise((resolve) => {
346
- timeout = setTimeout(() => {
347
- resolve({
348
- ok: false,
349
- error: new PopcornError("timeout"),
350
- durationMs: ms,
351
- });
352
- }, ms);
353
- });
354
- const result = await Promise.race([promise, timeoutPromise]);
355
- if (!timeout)
356
- throwError({ t: "assert" });
357
- clearTimeout(timeout);
358
- return result;
359
- }
360
- function noop() {
361
- /* noop */
362
- }
363
- async function resolveBundleURL(primary, fallback) {
364
- const fetchBundle = async (path) => {
365
- const url = new URL(path, import.meta.url).href;
366
- const response = await fetch(url, { method: "HEAD" });
367
- const contentType = response.headers.get("Content-Type") ?? "";
368
- if (!response.ok || contentType.includes("text/html")) {
369
- throw new Error(`Bundle not found at "${path}"`);
370
- }
371
- return path;
372
- };
373
- try {
374
- return await Promise.any([fetchBundle(primary), fetchBundle(fallback)]);
375
- }
376
- catch {
377
- throwError({ t: "bundle_not_found", primary, fallback });
378
- }
379
- }
380
-
381
- export { Popcorn, PopcornError };
package/dist/types.mjs DELETED
@@ -1,25 +0,0 @@
1
- const INIT_VM_TIMEOUT_MS = 30_000;
2
- const CALL_TIMEOUT_MS = 60_000;
3
- const HEARTBEAT_TIMEOUT_MS = 60_000;
4
- const MAX_RELOAD_N = 3;
5
- const MESSAGES = {
6
- EVENT: "popcorn-event",
7
- CALL: "popcorn-call",
8
- CAST: "popcorn-cast",
9
- CALL_ACK: "popcorn-callAck",
10
- STDOUT: "popcorn-stdout",
11
- STDERR: "popcorn-stderr",
12
- HEARTBEAT: "popcorn-heartbeat",
13
- RELOAD: "popcorn-reload",
14
- };
15
- const EVENT_NAMES = {
16
- ELIXIR_READY: "popcorn_elixir_ready",
17
- APP_READY: "popcorn_app_ready",
18
- SET_DEFAULT_RECEIVER: "popcorn_set_default_receiver",
19
- };
20
- const MESSAGES_TYPES = new Set(Object.values(MESSAGES));
21
- function isMessageType(type) {
22
- return MESSAGES_TYPES.has(type);
23
- }
24
-
25
- export { CALL_TIMEOUT_MS, EVENT_NAMES, HEARTBEAT_TIMEOUT_MS, INIT_VM_TIMEOUT_MS, MAX_RELOAD_N, MESSAGES, isMessageType };