@swmansion/popcorn 0.2.0-rc.1

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.
@@ -0,0 +1,340 @@
1
+ import { IframeBridge } from './bridge.mjs';
2
+ import { HEARTBEAT_TIMEOUT_MS, MESSAGES, MAX_RELOAD_N, CALL_TIMEOUT_MS, INIT_VM_TIMEOUT_MS } from './types.mjs';
3
+ import { throwError } from './utils.mjs';
4
+
5
+ class PopcornDeinitializedError extends Error {
6
+ }
7
+ const INIT_TOKEN = Symbol();
8
+ const IFRAME_URL = new URL("./iframe.mjs", import.meta.url).href;
9
+ /**
10
+ * Manages Elixir by setting up iframe, WASM module, and event listeners. Used to sent messages to Elixir processes.
11
+ */
12
+ class Popcorn {
13
+ heartbeatTimeoutMs = null;
14
+ onReloadCallback;
15
+ bridge = null;
16
+ bridgeConfig;
17
+ debug = false;
18
+ bundleURL;
19
+ state = { status: "uninitialized" };
20
+ initProcess = null;
21
+ requestId = 0;
22
+ calls = new Map();
23
+ logListeners = {
24
+ stdout: new Set(),
25
+ stderr: new Set(),
26
+ };
27
+ awaitedMessage = null;
28
+ heartbeatTimeout = null;
29
+ reloadN = 0;
30
+ constructor(params, token) {
31
+ if (token !== INIT_TOKEN)
32
+ throwError({ t: "private_constructor" });
33
+ const bundlePath = params.bundlePath ?? "/bundle.avm";
34
+ const bundleURL = new URL(bundlePath, import.meta.url);
35
+ this.onReloadCallback = params.onReload ?? noop;
36
+ this.debug = params.debug ?? false;
37
+ this.bundleURL = bundleURL.href;
38
+ this.bridgeConfig = {
39
+ container: params.container,
40
+ script: { url: IFRAME_URL, entrypoint: "runIFrame" },
41
+ config: { "bundle-path": this.bundleURL },
42
+ debug: true,
43
+ onMessage: this.iframeHandler.bind(this),
44
+ };
45
+ this.logListeners.stdout.add(params.onStdout ?? console.log);
46
+ this.logListeners.stderr.add(params.onStderr ?? console.warn);
47
+ this.heartbeatTimeoutMs = params.heartbeatTimeoutMs ?? HEARTBEAT_TIMEOUT_MS;
48
+ }
49
+ /**
50
+ * Creates an iframe and sets up communication channels.
51
+ * Returns after Elixir code calls `Popcorn.Wasm.register/1`.
52
+ *
53
+ * @example
54
+ * import { Popcorn } from "@swmansion/popcorn";
55
+ * const popcorn = await Popcorn.init({
56
+ * onStdout: console.log,
57
+ * onStderr: console.error,
58
+ * debug: true,
59
+ * });
60
+ */
61
+ static async init(options) {
62
+ const { container, ...constructorParams } = options;
63
+ const containerWithDefault = container ?? document.documentElement;
64
+ const popcorn = new Popcorn({ ...constructorParams, container: containerWithDefault }, INIT_TOKEN);
65
+ popcorn.trace("Main: init, params: ", { container, ...constructorParams });
66
+ await popcorn.mount();
67
+ return popcorn;
68
+ }
69
+ async mount() {
70
+ if (this.bridge !== null)
71
+ throwError({ t: "already_mounted" });
72
+ this.assertStatus(["uninitialized", "reload"]);
73
+ this.transition({ status: "mount" });
74
+ this.trace("Main: mount, container: ", this.bridgeConfig.container);
75
+ this.bridge = new IframeBridge(this.bridgeConfig);
76
+ await this.awaitMessage(MESSAGES.INIT);
77
+ this.transition({ status: "await_vm" });
78
+ this.trace("Main: iframe loaded");
79
+ this.initProcess = await withTimeout(this.awaitMessage(MESSAGES.START_VM), INIT_VM_TIMEOUT_MS);
80
+ this.transition({ status: "ready" });
81
+ this.trace("Main: mounted, main process: ", this.initProcess);
82
+ this.onHeartbeat();
83
+ }
84
+ /**
85
+ * Sends a message to an Elixir process and awaits for the response.
86
+ *
87
+ * If Elixir doesn't respond in configured timeout, the returned promise will be rejected with "process timeout" error.
88
+ *
89
+ * Unless passed via options, the name passed in `Popcorn.Wasm.register/1` on the Elixir side is used.
90
+ * Throws "Unspecified target process" if default process is not set and no process is specified.
91
+ *
92
+ * @example
93
+ * const result = await popcorn.call(
94
+ * { action: "get_user", id: 123 },
95
+ * { process: "user_server", timeoutMs: 5_000 },
96
+ * );
97
+ * console.log(result.data); // Deserialized Elixir response
98
+ * console.log(result.durationMs); // Entire call duration
99
+ */
100
+ async call(args, { process, timeoutMs } = {}) {
101
+ this.assertStatus(["ready"]);
102
+ const targetProcess = process ?? this.initProcess;
103
+ if (this.bridge === null)
104
+ throwError({ t: "unmounted" });
105
+ if (targetProcess === null)
106
+ throwError({ t: "bad_target" });
107
+ const requestId = this.requestId++;
108
+ const callPromise = new Promise((resolve, reject) => {
109
+ if (this.bridge === null)
110
+ throwError({ t: "unmounted" });
111
+ this.trace("Main: call: ", { requestId, process, args });
112
+ this.bridge.sendIframeRequest({
113
+ type: MESSAGES.CALL,
114
+ value: { requestId, process: targetProcess, args },
115
+ });
116
+ this.calls.set(requestId, {
117
+ acknowledged: false,
118
+ startTimeMs: performance.now(),
119
+ resolve,
120
+ reject,
121
+ });
122
+ });
123
+ const result = await withTimeout(callPromise, timeoutMs ?? CALL_TIMEOUT_MS);
124
+ this.calls.delete(requestId);
125
+ return result;
126
+ }
127
+ /**
128
+ * Sends a message to an Elixir process (default or from options) and returns immediately.
129
+ *
130
+ * Unless passed via options, the name passed in `Popcorn.Wasm.register/1` on the Elixir side is used.
131
+ * Throws "Unspecified target process" if default process is not set and no process is specified.
132
+ */
133
+ cast(args, { process } = {}) {
134
+ this.assertStatus(["ready"]);
135
+ const targetProcess = process ?? this.initProcess;
136
+ if (this.bridge === null)
137
+ throwError({ t: "unmounted" });
138
+ if (targetProcess === null)
139
+ throwError({ t: "bad_target" });
140
+ const requestId = this.requestId++;
141
+ this.trace("Main: cast: ", { requestId, process, args });
142
+ this.bridge.sendIframeRequest({
143
+ type: MESSAGES.CAST,
144
+ value: { requestId, process: targetProcess, args },
145
+ });
146
+ }
147
+ /**
148
+ * Destroys an iframe and resets the instance.
149
+ */
150
+ deinit() {
151
+ if (this.bridge === null)
152
+ throwError({ t: "unmounted" });
153
+ this.trace("Main: deinit");
154
+ this.transition({ status: "deinit" });
155
+ this.bridge.deinit();
156
+ this.bridge = null;
157
+ this.awaitedMessage = null;
158
+ if (this.heartbeatTimeout) {
159
+ clearTimeout(this.heartbeatTimeout);
160
+ this.heartbeatTimeout = null;
161
+ }
162
+ this.logListeners.stdout.clear();
163
+ this.logListeners.stderr.clear();
164
+ for (const callData of this.calls.values()) {
165
+ const durationMs = performance.now() - callData.startTimeMs;
166
+ callData.reject({
167
+ error: new PopcornDeinitializedError("Call cancelled due to instance deinit"),
168
+ durationMs,
169
+ });
170
+ }
171
+ this.calls.clear();
172
+ }
173
+ /**
174
+ * Registers a log listener that will be called when output of the specified type is received.
175
+ */
176
+ registerLogListener(listener, type) {
177
+ this.logListeners[type].add(listener);
178
+ }
179
+ /**
180
+ * Unregisters a previously registered log listener.
181
+ */
182
+ unregisterLogListener(listener, type) {
183
+ this.logListeners[type].delete(listener);
184
+ }
185
+ notifyLogListeners(type, message) {
186
+ this.logListeners[type].forEach((listener) => {
187
+ listener(message);
188
+ });
189
+ }
190
+ iframeHandler(data) {
191
+ const awaitedMessage = this.awaitedMessage;
192
+ if (awaitedMessage && data.type == awaitedMessage.type) {
193
+ this.awaitedMessage = null;
194
+ awaitedMessage.resolve?.(data.value);
195
+ return;
196
+ }
197
+ if (data.type === MESSAGES.STDOUT) {
198
+ this.notifyLogListeners("stdout", data.value);
199
+ }
200
+ else if (data.type === MESSAGES.STDERR) {
201
+ this.notifyLogListeners("stderr", data.value);
202
+ }
203
+ else if (data.type === MESSAGES.CALL) {
204
+ this.onCall(data.value);
205
+ }
206
+ else if (data.type === MESSAGES.CALL_ACK) {
207
+ this.onCallAck(data.value);
208
+ }
209
+ else if (data.type === MESSAGES.HEARTBEAT) {
210
+ this.onHeartbeat();
211
+ }
212
+ else if (data.type === MESSAGES.RELOAD) {
213
+ this.reloadIframe();
214
+ }
215
+ else {
216
+ throwError({ t: "assert" });
217
+ }
218
+ }
219
+ onCallAck({ requestId }) {
220
+ this.assertStatus(["ready"]);
221
+ this.trace("Main: onCallAck: ", { requestId });
222
+ const callData = this.calls.get(requestId);
223
+ if (callData === undefined)
224
+ throwError({ t: "bad_ack" });
225
+ this.calls.set(requestId, { ...callData, acknowledged: true });
226
+ }
227
+ onCall({ requestId, error, data, }) {
228
+ this.assertStatus(["ready"]);
229
+ this.trace("Main: onCall: ", { requestId, error, data });
230
+ const callData = this.calls.get(requestId);
231
+ if (callData === undefined)
232
+ throwError({ t: "bad_call" });
233
+ if (!callData.acknowledged)
234
+ throwError({ t: "no_acked_call" });
235
+ this.calls.delete(requestId);
236
+ const durationMs = performance.now() - callData.startTimeMs;
237
+ if (error !== undefined) {
238
+ callData.resolve({ ok: false, error, durationMs });
239
+ }
240
+ else {
241
+ callData.resolve({ ok: true, data, durationMs });
242
+ }
243
+ }
244
+ onHeartbeat() {
245
+ if (this.heartbeatTimeout) {
246
+ clearTimeout(this.heartbeatTimeout);
247
+ }
248
+ this.heartbeatTimeout = setTimeout(() => {
249
+ this.trace("Main: heartbeat lost");
250
+ this.reloadIframe("heartbeat_lost");
251
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
252
+ }, this.heartbeatTimeoutMs);
253
+ }
254
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
255
+ reloadIframe(reason = "other") {
256
+ if (this.bridge === null) {
257
+ throw new Error("WASM iframe not mounted for reload");
258
+ }
259
+ if (document.hidden) {
260
+ this.trace("Main: reloading iframe skipped, window not visible");
261
+ return;
262
+ }
263
+ this.reloadN++;
264
+ if (this.reloadN > MAX_RELOAD_N) {
265
+ this.trace("Main: exceeded max reload number");
266
+ return;
267
+ }
268
+ this.trace("Main: reloading iframe");
269
+ this.transition({ status: "reload" });
270
+ this.bridge.deinit();
271
+ this.bridge = null;
272
+ this.awaitedMessage = null;
273
+ if (this.heartbeatTimeout) {
274
+ clearTimeout(this.heartbeatTimeout);
275
+ this.heartbeatTimeout = null;
276
+ }
277
+ for (const callData of this.calls.values()) {
278
+ const durationMs = performance.now() - callData.startTimeMs;
279
+ callData.reject({
280
+ error: new Error("Call cancelled due to iframe reload"),
281
+ durationMs,
282
+ });
283
+ }
284
+ this.calls.clear();
285
+ this.onReloadCallback(reason);
286
+ this.mount();
287
+ }
288
+ awaitMessage(type) {
289
+ if (this.awaitedMessage) {
290
+ throwError({
291
+ t: "already_awaited",
292
+ messageType: this.awaitedMessage.type,
293
+ awaitedMessageType: type,
294
+ });
295
+ }
296
+ this.awaitedMessage = { type };
297
+ return new Promise((resolve) => {
298
+ if (!this.awaitedMessage)
299
+ throwError({ t: "assert" });
300
+ this.awaitedMessage.resolve = resolve;
301
+ });
302
+ }
303
+ trace(...messages) {
304
+ if (this.debug) {
305
+ console.debug(...messages);
306
+ }
307
+ }
308
+ transition(to) {
309
+ this.trace(`State: ${this.state.status} -> ${to.status}`);
310
+ this.state = to;
311
+ }
312
+ assertStatus(validStatuses) {
313
+ const currentStatus = this.state.status;
314
+ if (!validStatuses.includes(currentStatus)) {
315
+ throwError({
316
+ t: "bad_status",
317
+ status: currentStatus,
318
+ expectedStatus: validStatuses.join(" | "),
319
+ });
320
+ }
321
+ }
322
+ }
323
+ async function withTimeout(promise, ms) {
324
+ let timeout = null;
325
+ const timeoutPromise = new Promise((_resolve, reject) => {
326
+ timeout = setTimeout(() => {
327
+ reject("Promise timeout");
328
+ }, ms);
329
+ });
330
+ const result = await Promise.race([promise, timeoutPromise]);
331
+ if (!timeout)
332
+ throwError({ t: "assert" });
333
+ clearTimeout(timeout);
334
+ return result;
335
+ }
336
+ function noop() {
337
+ /* noop */
338
+ }
339
+
340
+ export { Popcorn, PopcornDeinitializedError };
@@ -0,0 +1,72 @@
1
+ export declare const INIT_VM_TIMEOUT_MS = 30000;
2
+ export declare const CALL_TIMEOUT_MS = 60000;
3
+ export declare const HEARTBEAT_TIMEOUT_MS = 60000;
4
+ export declare const HEARTBEAT_INTERVAL_MS = 500;
5
+ export declare const MAX_RELOAD_N = 3;
6
+ export type AnySerializable = any;
7
+ export type CallRequest = {
8
+ requestId: number;
9
+ process: string;
10
+ args: AnySerializable;
11
+ };
12
+ export type CastRequest = {
13
+ requestId: number;
14
+ process: string;
15
+ args: AnySerializable;
16
+ };
17
+ /** Messages sent from parent window to iframe */
18
+ export type IframeRequest = {
19
+ type: "popcorn-call";
20
+ value: CallRequest;
21
+ } | {
22
+ type: "popcorn-cast";
23
+ value: CastRequest;
24
+ };
25
+ export type CallResponse = {
26
+ requestId: number;
27
+ error?: AnySerializable;
28
+ data?: AnySerializable;
29
+ };
30
+ export type CallAck = {
31
+ requestId: number;
32
+ };
33
+ /** Messages sent from iframe to parent window */
34
+ export type IframeResponse = {
35
+ type: "popcorn-init";
36
+ value: null;
37
+ } | {
38
+ type: "popcorn-startVm";
39
+ value: string;
40
+ } | {
41
+ type: "popcorn-call";
42
+ value: CallResponse;
43
+ } | {
44
+ type: "popcorn-callAck";
45
+ value: CallAck;
46
+ } | {
47
+ type: "popcorn-stdout";
48
+ value: string;
49
+ } | {
50
+ type: "popcorn-stderr";
51
+ value: string;
52
+ } | {
53
+ type: "popcorn-heartbeat";
54
+ value: null;
55
+ } | {
56
+ type: "popcorn-reload";
57
+ value: string | null;
58
+ };
59
+ /** Union of all messages (requests and responses) */
60
+ export type Message = IframeRequest | IframeResponse;
61
+ export declare const MESSAGES: {
62
+ readonly INIT: "popcorn-init";
63
+ readonly START_VM: "popcorn-startVm";
64
+ readonly CALL: "popcorn-call";
65
+ readonly CAST: "popcorn-cast";
66
+ readonly CALL_ACK: "popcorn-callAck";
67
+ readonly STDOUT: "popcorn-stdout";
68
+ readonly STDERR: "popcorn-stderr";
69
+ readonly HEARTBEAT: "popcorn-heartbeat";
70
+ readonly RELOAD: "popcorn-reload";
71
+ };
72
+ export declare function isMessageType(type: string): type is Message["type"];
package/dist/types.mjs ADDED
@@ -0,0 +1,22 @@
1
+ const INIT_VM_TIMEOUT_MS = 30_000;
2
+ const CALL_TIMEOUT_MS = 60_000;
3
+ const HEARTBEAT_TIMEOUT_MS = 60_000;
4
+ const HEARTBEAT_INTERVAL_MS = 500;
5
+ const MAX_RELOAD_N = 3;
6
+ const MESSAGES = {
7
+ INIT: "popcorn-init",
8
+ START_VM: "popcorn-startVm",
9
+ CALL: "popcorn-call",
10
+ CAST: "popcorn-cast",
11
+ CALL_ACK: "popcorn-callAck",
12
+ STDOUT: "popcorn-stdout",
13
+ STDERR: "popcorn-stderr",
14
+ HEARTBEAT: "popcorn-heartbeat",
15
+ RELOAD: "popcorn-reload",
16
+ };
17
+ const MESSAGES_TYPES = new Set(Object.values(MESSAGES));
18
+ function isMessageType(type) {
19
+ return MESSAGES_TYPES.has(type);
20
+ }
21
+
22
+ export { CALL_TIMEOUT_MS, HEARTBEAT_INTERVAL_MS, HEARTBEAT_TIMEOUT_MS, INIT_VM_TIMEOUT_MS, MAX_RELOAD_N, MESSAGES, isMessageType };
@@ -0,0 +1,27 @@
1
+ type ErrorData = {
2
+ t: "assert";
3
+ } | {
4
+ t: "bad_status";
5
+ status: string;
6
+ expectedStatus: string;
7
+ } | {
8
+ t: "private_constructor";
9
+ } | {
10
+ t: "bad_call";
11
+ } | {
12
+ t: "no_acked_call";
13
+ } | {
14
+ t: "bad_ack";
15
+ } | {
16
+ t: "unmounted";
17
+ } | {
18
+ t: "bad_target";
19
+ } | {
20
+ t: "already_awaited";
21
+ messageType: string;
22
+ awaitedMessageType: string;
23
+ } | {
24
+ t: "already_mounted";
25
+ };
26
+ export declare function throwError(error: ErrorData): never;
27
+ export {};
package/dist/utils.mjs ADDED
@@ -0,0 +1,26 @@
1
+ function throwError(error) {
2
+ switch (error.t) {
3
+ case "assert":
4
+ throw new Error("Assertion error");
5
+ case "bad_status":
6
+ throw new Error(`Unexpected status transition. Instance in ${error.status} status, expected ${error.expectedStatus}`);
7
+ case "private_constructor":
8
+ throw new Error("Don't construct the Popcorn object directly, use Popcorn.init() instead");
9
+ case "bad_call":
10
+ throw new Error("Response for non-existent call");
11
+ case "no_acked_call":
12
+ throw new Error("Response for non-acknowledged call");
13
+ case "bad_ack":
14
+ throw new Error("Ack for non-existent call");
15
+ case "unmounted":
16
+ throw new Error("WASM iframe not mounted");
17
+ case "bad_target":
18
+ throw new Error("Unspecified target process");
19
+ case "already_awaited":
20
+ throw new Error(`Cannot await message ${error.messageType} when a message ${error.awaitedMessageType} is already awaited on`);
21
+ case "already_mounted":
22
+ throw new Error("Iframe already mounted");
23
+ }
24
+ }
25
+
26
+ export { throwError };
package/package.json ADDED
@@ -0,0 +1,81 @@
1
+ {
2
+ "name": "@swmansion/popcorn",
3
+ "version": "0.2.0-rc.1",
4
+ "description": "JS bindings for Popcorn",
5
+ "type": "module",
6
+ "license": "Apache-2.0",
7
+ "homepage": "https://popcorn.swmansion.com",
8
+ "bugs": {
9
+ "url": "https://github.com/software-mansion/popcorn/issues"
10
+ },
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "git+https://github.com/software-mansion/popcorn.git"
14
+ },
15
+ "author": "Software Mansion",
16
+ "files": [
17
+ "dist"
18
+ ],
19
+ "main": "./dist/index.mjs",
20
+ "module": "./dist/index.mjs",
21
+ "types": "./dist/index.d.ts",
22
+ "exports": {
23
+ ".": {
24
+ "types": "./dist/index.d.ts",
25
+ "import": "./dist/index.mjs"
26
+ },
27
+ "./vite": {
28
+ "types": "./dist/plugins/vite.d.ts",
29
+ "import": "./dist/plugins/vite.mjs"
30
+ },
31
+ "./rollup": {
32
+ "types": "./dist/plugins/rollup.d.ts",
33
+ "import": "./dist/plugins/rollup.mjs"
34
+ },
35
+ "./esbuild": {
36
+ "types": "./dist/plugins/esbuild.d.ts",
37
+ "import": "./dist/plugins/esbuild.mjs"
38
+ },
39
+ "./package.json": "./package.json"
40
+ },
41
+ "scripts": {
42
+ "setup:dev": "npm run assets:dev",
43
+ "dev": "rollup -c --watch",
44
+ "lint": "tsc --noEmit && eslint && prettier . --check --log-level=warn",
45
+ "test": "vitest",
46
+ "build:prod": "pnpm run lint && (rm -r assets || true) && (rm -r dist || true) && pnpm run assets:prod && rollup -c",
47
+ "assets:dev": "./scripts/get_atomvm.sh assets/",
48
+ "assets:prod": "RUNTIME_SOURCE='https://github.com/software-mansion-labs/FissionVM.git#swm' ./scripts/get_atomvm.sh assets/"
49
+ },
50
+ "peerDependencies": {
51
+ "esbuild": ">=0.17.0",
52
+ "rollup": ">=4.0.0",
53
+ "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0"
54
+ },
55
+ "peerDependenciesMeta": {
56
+ "esbuild": {
57
+ "optional": true
58
+ },
59
+ "rollup": {
60
+ "optional": true
61
+ },
62
+ "vite": {
63
+ "optional": true
64
+ }
65
+ },
66
+ "devDependencies": {
67
+ "@eslint/js": "^9.39.0",
68
+ "@rollup/plugin-typescript": "^12.1.2",
69
+ "@types/node": "catalog:",
70
+ "esbuild": "^0.25.0",
71
+ "eslint": "catalog:",
72
+ "globals": "^16.2.0",
73
+ "prettier": "^3.7.4",
74
+ "rollup": "^4.55.1",
75
+ "tslib": "^2.8.1",
76
+ "typescript": "^5.8.3",
77
+ "typescript-eslint": "^8.53.0",
78
+ "vite": "catalog:",
79
+ "vitest": "^4.0.17"
80
+ }
81
+ }