@swmansion/popcorn 0.2.2 → 0.3.0-rc2
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/dist/AtomVM.mjs +267 -100
- package/dist/AtomVM.wasm +0 -0
- package/dist/bridge.mjs +5 -4
- package/dist/errors.d.ts +4 -5
- package/dist/errors.mjs +18 -14
- package/dist/iframe.d.ts +1 -1
- package/dist/iframe.mjs +18 -25
- package/dist/popcorn.d.ts +16 -9
- package/dist/popcorn.mjs +94 -91
- package/dist/types.d.ts +12 -7
- package/dist/types.mjs +7 -3
- package/package.json +1 -1
package/dist/AtomVM.wasm
CHANGED
|
Binary file
|
package/dist/bridge.mjs
CHANGED
|
@@ -41,10 +41,11 @@ class IframeBridge {
|
|
|
41
41
|
window.removeEventListener("message", this.handlerRef);
|
|
42
42
|
this.iframe.remove();
|
|
43
43
|
}
|
|
44
|
-
messageHandler(
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
44
|
+
messageHandler(event) {
|
|
45
|
+
const fromOurIframe = event.source === this.iframe.contentWindow;
|
|
46
|
+
if (!fromOurIframe || !isIframeResponse(event.data))
|
|
47
|
+
return;
|
|
48
|
+
this.onMessage(event.data);
|
|
48
49
|
}
|
|
49
50
|
}
|
|
50
51
|
function isIframeResponse(payload) {
|
package/dist/errors.d.ts
CHANGED
|
@@ -6,7 +6,7 @@ export declare class PopcornError extends Error {
|
|
|
6
6
|
constructor(code: PopcornErrorCode, message?: string);
|
|
7
7
|
}
|
|
8
8
|
/** Error codes for internal errors that indicate bugs or misuse */
|
|
9
|
-
export type PopcornInternalErrorCode = "assert" | "private_constructor" | "bad_call" | "no_acked_call" | "bad_ack" | "
|
|
9
|
+
export type PopcornInternalErrorCode = "assert" | "private_constructor" | "bad_call" | "no_acked_call" | "bad_ack" | "already_mounted" | "unmounted" | "bad_target" | "bad_status" | "app_ready_timeout" | "bundle_not_found";
|
|
10
10
|
/** Non-recoverable error indicating a bug or library misuse (always thrown) */
|
|
11
11
|
export declare class PopcornInternalError extends Error {
|
|
12
12
|
readonly code: PopcornInternalErrorCode;
|
|
@@ -23,10 +23,6 @@ type ErrorData = {
|
|
|
23
23
|
t: "no_acked_call";
|
|
24
24
|
} | {
|
|
25
25
|
t: "bad_ack";
|
|
26
|
-
} | {
|
|
27
|
-
t: "already_awaited";
|
|
28
|
-
messageType: string;
|
|
29
|
-
awaitedMessageType: string;
|
|
30
26
|
} | {
|
|
31
27
|
t: "already_mounted";
|
|
32
28
|
} | {
|
|
@@ -37,10 +33,13 @@ type ErrorData = {
|
|
|
37
33
|
t: "bad_status";
|
|
38
34
|
status: string;
|
|
39
35
|
expectedStatus: string;
|
|
36
|
+
} | {
|
|
37
|
+
t: "app_ready_timeout";
|
|
40
38
|
} | {
|
|
41
39
|
t: "bundle_not_found";
|
|
42
40
|
primary: string;
|
|
43
41
|
fallback: string;
|
|
44
42
|
};
|
|
43
|
+
export declare function buildError(error: ErrorData): PopcornInternalError;
|
|
45
44
|
export declare function throwError(error: ErrorData): never;
|
|
46
45
|
export {};
|
package/dist/errors.mjs
CHANGED
|
@@ -12,6 +12,7 @@ class PopcornError extends Error {
|
|
|
12
12
|
this.name = "PopcornError";
|
|
13
13
|
}
|
|
14
14
|
}
|
|
15
|
+
const INIT_VM_TIMEOUT_MS = 30_000;
|
|
15
16
|
/** Non-recoverable error indicating a bug or library misuse (always thrown) */
|
|
16
17
|
class PopcornInternalError extends Error {
|
|
17
18
|
code;
|
|
@@ -21,31 +22,34 @@ class PopcornInternalError extends Error {
|
|
|
21
22
|
this.name = "PopcornInternalError";
|
|
22
23
|
}
|
|
23
24
|
}
|
|
24
|
-
function
|
|
25
|
+
function buildError(error) {
|
|
25
26
|
switch (error.t) {
|
|
26
27
|
case "assert":
|
|
27
|
-
|
|
28
|
+
return new PopcornInternalError("assert", "Assertion error");
|
|
28
29
|
case "private_constructor":
|
|
29
|
-
|
|
30
|
+
return new PopcornInternalError("private_constructor", "Don't construct the Popcorn object directly, use Popcorn.init() instead");
|
|
30
31
|
case "bad_call":
|
|
31
|
-
|
|
32
|
+
return new PopcornInternalError("bad_call", "Response for non-existent call");
|
|
32
33
|
case "no_acked_call":
|
|
33
|
-
|
|
34
|
+
return new PopcornInternalError("no_acked_call", "Response for non-acknowledged call");
|
|
34
35
|
case "bad_ack":
|
|
35
|
-
|
|
36
|
-
case "already_awaited":
|
|
37
|
-
throw new PopcornInternalError("already_awaited", `Cannot await message "${error.messageType}" when message "${error.awaitedMessageType}" is already awaited`);
|
|
36
|
+
return new PopcornInternalError("bad_ack", "Ack for non-existent call");
|
|
38
37
|
case "already_mounted":
|
|
39
|
-
|
|
38
|
+
return new PopcornInternalError("already_mounted", "Iframe already mounted");
|
|
40
39
|
case "unmounted":
|
|
41
|
-
|
|
40
|
+
return new PopcornInternalError("unmounted", "WASM iframe not mounted");
|
|
42
41
|
case "bad_target":
|
|
43
|
-
|
|
42
|
+
return new PopcornInternalError("bad_target", "Unspecified target process");
|
|
44
43
|
case "bad_status":
|
|
45
|
-
|
|
44
|
+
return new PopcornInternalError("bad_status", `Operation not allowed: instance in "${error.status}" state, expected "${error.expectedStatus}"`);
|
|
45
|
+
case "app_ready_timeout":
|
|
46
|
+
return new PopcornInternalError("app_ready_timeout", `Elixir app did not call Popcorn.Wasm.ready() within ${INIT_VM_TIMEOUT_MS}ms`);
|
|
46
47
|
case "bundle_not_found":
|
|
47
|
-
|
|
48
|
+
return new PopcornInternalError("bundle_not_found", `Could not find a valid .avm bundle at "${error.primary}" or fallback "${error.fallback}"`);
|
|
48
49
|
}
|
|
49
50
|
}
|
|
51
|
+
function throwError(error) {
|
|
52
|
+
throw buildError(error);
|
|
53
|
+
}
|
|
50
54
|
|
|
51
|
-
export { PopcornError, PopcornInternalError, throwError };
|
|
55
|
+
export { PopcornError, PopcornInternalError, buildError, throwError };
|
package/dist/iframe.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare function
|
|
1
|
+
export declare function initVm(): Promise<void>;
|
package/dist/iframe.mjs
CHANGED
|
@@ -2,8 +2,7 @@ import init from './AtomVM.mjs';
|
|
|
2
2
|
|
|
3
3
|
const HEARTBEAT_INTERVAL_MS = 500;
|
|
4
4
|
const MESSAGES = {
|
|
5
|
-
|
|
6
|
-
START_VM: "popcorn-startVm",
|
|
5
|
+
EVENT: "popcorn-event",
|
|
7
6
|
CALL: "popcorn-call",
|
|
8
7
|
CAST: "popcorn-cast",
|
|
9
8
|
CALL_ACK: "popcorn-callAck",
|
|
@@ -32,16 +31,14 @@ class TrackedValue {
|
|
|
32
31
|
}
|
|
33
32
|
}
|
|
34
33
|
globalThis.TrackedValue = TrackedValue;
|
|
35
|
-
async function
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
sendIframeResponse(MESSAGES.INIT, null);
|
|
44
|
-
const initProcess = await startVm(bundle);
|
|
34
|
+
async function initVm() {
|
|
35
|
+
const bundlePaths = Array.from(document.querySelectorAll('meta[name^="bundle-path-"]'), (el) => el.content);
|
|
36
|
+
const bundles = await Promise.all(bundlePaths.map(async (p) => {
|
|
37
|
+
const resp = await fetch(p);
|
|
38
|
+
const buf = await resp.arrayBuffer();
|
|
39
|
+
return new Int8Array(buf);
|
|
40
|
+
}));
|
|
41
|
+
await startVm(bundles);
|
|
45
42
|
window.addEventListener("message", async ({ data }) => {
|
|
46
43
|
const type = data.type;
|
|
47
44
|
if (type === MESSAGES.CALL) {
|
|
@@ -51,22 +48,20 @@ async function runIFrame() {
|
|
|
51
48
|
handleCast(data.value);
|
|
52
49
|
}
|
|
53
50
|
});
|
|
54
|
-
sendIframeResponse(MESSAGES.START_VM, initProcess);
|
|
55
51
|
setInterval(() => sendIframeResponse(MESSAGES.HEARTBEAT, null), HEARTBEAT_INTERVAL_MS);
|
|
56
52
|
}
|
|
57
|
-
async function startVm(
|
|
58
|
-
|
|
59
|
-
const resultPromise = new Promise((resolve) => {
|
|
60
|
-
resolveResultPromise = resolve;
|
|
61
|
-
});
|
|
53
|
+
async function startVm(avmBundles) {
|
|
54
|
+
const bundleFilePaths = avmBundles.map((_, i) => `/data/bundle-${i}.avm`);
|
|
62
55
|
const moduleInstance = await init({
|
|
63
56
|
preRun: [
|
|
64
57
|
function ({ FS }) {
|
|
65
58
|
FS.mkdir("/data");
|
|
66
|
-
|
|
59
|
+
avmBundles.forEach((bundle, i) => {
|
|
60
|
+
FS.writeFile(bundleFilePaths[i], bundle);
|
|
61
|
+
});
|
|
67
62
|
},
|
|
68
63
|
],
|
|
69
|
-
arguments:
|
|
64
|
+
arguments: bundleFilePaths,
|
|
70
65
|
print(text) {
|
|
71
66
|
sendIframeResponse(MESSAGES.STDOUT, text);
|
|
72
67
|
},
|
|
@@ -152,11 +147,9 @@ async function startVm(avmBundle) {
|
|
|
152
147
|
};
|
|
153
148
|
return keys.map(getTrackedObject);
|
|
154
149
|
};
|
|
155
|
-
moduleInstance["
|
|
156
|
-
|
|
157
|
-
resolveResultPromise?.(initProcess);
|
|
150
|
+
moduleInstance["sendEvent"] = (eventName, payload) => {
|
|
151
|
+
sendIframeResponse(MESSAGES.EVENT, { eventName, payload });
|
|
158
152
|
};
|
|
159
|
-
return resultPromise;
|
|
160
153
|
}
|
|
161
154
|
async function handleCall(request) {
|
|
162
155
|
if (!Module) {
|
|
@@ -213,4 +206,4 @@ function deserialize(message) {
|
|
|
213
206
|
});
|
|
214
207
|
}
|
|
215
208
|
|
|
216
|
-
export {
|
|
209
|
+
export { initVm };
|
package/dist/popcorn.d.ts
CHANGED
|
@@ -7,8 +7,8 @@ export type { PopcornErrorCode, PopcornInternalErrorCode };
|
|
|
7
7
|
export type PopcornInitOptions = {
|
|
8
8
|
/** DOM element to mount an iframe */
|
|
9
9
|
container?: HTMLElement;
|
|
10
|
-
/**
|
|
11
|
-
|
|
10
|
+
/** Paths to compiled Elixir bundles (`.avm` files). */
|
|
11
|
+
bundlePaths?: string[];
|
|
12
12
|
/** Handler for stderr messages. */
|
|
13
13
|
onStderr?: (message: string) => void;
|
|
14
14
|
/** Handler for stdout messages. */
|
|
@@ -49,6 +49,7 @@ type CallResult = {
|
|
|
49
49
|
};
|
|
50
50
|
type LogType = "stdout" | "stderr";
|
|
51
51
|
type LogListener = (message: string) => void;
|
|
52
|
+
type MessageHandler = (eventName: string, payload: AnySerializable) => void;
|
|
52
53
|
/**
|
|
53
54
|
* Manages Elixir by setting up iframe, WASM module, and event listeners. Used to sent messages to Elixir processes.
|
|
54
55
|
*/
|
|
@@ -58,19 +59,20 @@ export declare class Popcorn {
|
|
|
58
59
|
private bridge;
|
|
59
60
|
private bridgeConfig;
|
|
60
61
|
private debug;
|
|
61
|
-
private
|
|
62
|
+
private bundleURLs;
|
|
62
63
|
private state;
|
|
63
|
-
private
|
|
64
|
+
private defaultReceiver;
|
|
64
65
|
private requestId;
|
|
65
66
|
private calls;
|
|
66
67
|
private logListeners;
|
|
67
|
-
private
|
|
68
|
+
private messageHandlers;
|
|
69
|
+
private mountResolve;
|
|
68
70
|
private heartbeatTimeout;
|
|
69
71
|
private reloadN;
|
|
70
72
|
private constructor();
|
|
71
73
|
/**
|
|
72
74
|
* Creates an iframe and sets up communication channels.
|
|
73
|
-
* Returns after Elixir
|
|
75
|
+
* Returns after the Elixir app calls `Popcorn.Wasm.ready/0,1`.
|
|
74
76
|
*
|
|
75
77
|
* @example
|
|
76
78
|
* import { Popcorn } from "@swmansion/popcorn";
|
|
@@ -87,7 +89,7 @@ export declare class Popcorn {
|
|
|
87
89
|
*
|
|
88
90
|
* If Elixir doesn't respond in configured timeout, the returned promise will be rejected with "process timeout" error.
|
|
89
91
|
*
|
|
90
|
-
* Unless passed via options, the name passed in `Popcorn.Wasm.
|
|
92
|
+
* Unless passed via options, the name passed in `Popcorn.Wasm.set_default_receiver/1` on the Elixir side is used.
|
|
91
93
|
* Throws "Unspecified target process" if default process is not set and no process is specified.
|
|
92
94
|
*
|
|
93
95
|
* @example
|
|
@@ -102,7 +104,7 @@ export declare class Popcorn {
|
|
|
102
104
|
/**
|
|
103
105
|
* Sends a message to an Elixir process (default or from options) and returns immediately.
|
|
104
106
|
*
|
|
105
|
-
* Unless passed via options, the name passed in `Popcorn.Wasm.
|
|
107
|
+
* Unless passed via options, the name passed in `Popcorn.Wasm.set_default_receiver/1` on the Elixir side is used.
|
|
106
108
|
* Throws "Unspecified target process" if default process is not set and no process is specified.
|
|
107
109
|
*/
|
|
108
110
|
cast(args: AnySerializable, { process }?: CastOptions): void;
|
|
@@ -110,6 +112,7 @@ export declare class Popcorn {
|
|
|
110
112
|
* Destroys an iframe and resets the instance.
|
|
111
113
|
*/
|
|
112
114
|
deinit(): void;
|
|
115
|
+
private teardownBridge;
|
|
113
116
|
/**
|
|
114
117
|
* Registers a log listener that will be called when output of the specified type is received.
|
|
115
118
|
*/
|
|
@@ -119,12 +122,16 @@ export declare class Popcorn {
|
|
|
119
122
|
*/
|
|
120
123
|
unregisterLogListener(listener: LogListener, type: LogType): void;
|
|
121
124
|
private notifyLogListeners;
|
|
125
|
+
/**
|
|
126
|
+
* Registers a catch-all event handler. Returns an unsubscribe function.
|
|
127
|
+
*/
|
|
128
|
+
onMessage(handler: MessageHandler): () => void;
|
|
129
|
+
private onEvent;
|
|
122
130
|
private iframeHandler;
|
|
123
131
|
private onCallAck;
|
|
124
132
|
private onCall;
|
|
125
133
|
private onHeartbeat;
|
|
126
134
|
private reloadIframe;
|
|
127
|
-
private awaitMessage;
|
|
128
135
|
trace(...messages: unknown[]): void;
|
|
129
136
|
private transition;
|
|
130
137
|
private assertStatus;
|
package/dist/popcorn.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { IframeBridge } from './bridge.mjs';
|
|
2
|
-
import { HEARTBEAT_TIMEOUT_MS, MESSAGES,
|
|
3
|
-
import { throwError, PopcornError } from './errors.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
4
|
export { PopcornInternalError } from './errors.mjs';
|
|
5
5
|
|
|
6
6
|
const INIT_TOKEN = Symbol();
|
|
@@ -14,30 +14,30 @@ class Popcorn {
|
|
|
14
14
|
bridge = null;
|
|
15
15
|
bridgeConfig;
|
|
16
16
|
debug = false;
|
|
17
|
-
|
|
17
|
+
bundleURLs;
|
|
18
18
|
state = { status: "uninitialized" };
|
|
19
|
-
|
|
19
|
+
defaultReceiver = null;
|
|
20
20
|
requestId = 0;
|
|
21
21
|
calls = new Map();
|
|
22
22
|
logListeners = {
|
|
23
23
|
stdout: new Set(),
|
|
24
24
|
stderr: new Set(),
|
|
25
25
|
};
|
|
26
|
-
|
|
26
|
+
messageHandlers = new Set();
|
|
27
|
+
mountResolve = null;
|
|
27
28
|
heartbeatTimeout = null;
|
|
28
29
|
reloadN = 0;
|
|
29
30
|
constructor(params, token) {
|
|
30
31
|
if (token !== INIT_TOKEN)
|
|
31
32
|
throwError({ t: "private_constructor" });
|
|
32
|
-
const
|
|
33
|
-
|
|
33
|
+
const bundlePaths = params.bundlePaths ?? ["/bundle.avm"];
|
|
34
|
+
this.bundleURLs = bundlePaths.map((p) => new URL(p, import.meta.url).href);
|
|
34
35
|
this.onReloadCallback = params.onReload ?? noop;
|
|
35
36
|
this.debug = params.debug ?? false;
|
|
36
|
-
this.bundleURL = bundleURL.href;
|
|
37
37
|
this.bridgeConfig = {
|
|
38
38
|
container: params.container,
|
|
39
|
-
script: { url: IFRAME_URL, entrypoint: "
|
|
40
|
-
config:
|
|
39
|
+
script: { url: IFRAME_URL, entrypoint: "initVm" },
|
|
40
|
+
config: Object.fromEntries(this.bundleURLs.map((url, i) => [`bundle-path-${i}`, url])),
|
|
41
41
|
debug: true,
|
|
42
42
|
onMessage: this.iframeHandler.bind(this),
|
|
43
43
|
};
|
|
@@ -47,7 +47,7 @@ class Popcorn {
|
|
|
47
47
|
}
|
|
48
48
|
/**
|
|
49
49
|
* Creates an iframe and sets up communication channels.
|
|
50
|
-
* Returns after Elixir
|
|
50
|
+
* Returns after the Elixir app calls `Popcorn.Wasm.ready/0,1`.
|
|
51
51
|
*
|
|
52
52
|
* @example
|
|
53
53
|
* import { Popcorn } from "@swmansion/popcorn";
|
|
@@ -60,10 +60,10 @@ class Popcorn {
|
|
|
60
60
|
static async init(options) {
|
|
61
61
|
const { container, ...constructorParams } = options;
|
|
62
62
|
const containerWithDefault = container ?? document.documentElement;
|
|
63
|
-
const
|
|
64
|
-
? constructorParams.
|
|
65
|
-
: await resolveBundleURL("/bundle.avm", "/assets/bundle.avm");
|
|
66
|
-
const popcorn = new Popcorn({ ...constructorParams,
|
|
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
67
|
popcorn.trace("Main: init, params: ", { container, ...constructorParams });
|
|
68
68
|
await popcorn.mount();
|
|
69
69
|
return popcorn;
|
|
@@ -76,20 +76,19 @@ class Popcorn {
|
|
|
76
76
|
this.trace("Main: mount, container: ", this.bridgeConfig.container);
|
|
77
77
|
this.bridge = new IframeBridge(this.bridgeConfig);
|
|
78
78
|
try {
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
this.initProcess = startVmResult.data;
|
|
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);
|
|
91
90
|
this.transition({ status: "ready" });
|
|
92
|
-
this.trace("Main: mounted
|
|
91
|
+
this.trace("Main: mounted");
|
|
93
92
|
this.onHeartbeat();
|
|
94
93
|
}
|
|
95
94
|
catch (error) {
|
|
@@ -102,7 +101,7 @@ class Popcorn {
|
|
|
102
101
|
*
|
|
103
102
|
* If Elixir doesn't respond in configured timeout, the returned promise will be rejected with "process timeout" error.
|
|
104
103
|
*
|
|
105
|
-
* Unless passed via options, the name passed in `Popcorn.Wasm.
|
|
104
|
+
* Unless passed via options, the name passed in `Popcorn.Wasm.set_default_receiver/1` on the Elixir side is used.
|
|
106
105
|
* Throws "Unspecified target process" if default process is not set and no process is specified.
|
|
107
106
|
*
|
|
108
107
|
* @example
|
|
@@ -115,26 +114,20 @@ class Popcorn {
|
|
|
115
114
|
*/
|
|
116
115
|
async call(args, { process, timeoutMs } = {}) {
|
|
117
116
|
this.assertStatus(["ready"]);
|
|
118
|
-
const targetProcess = process ?? this.
|
|
119
|
-
if (this.bridge === null)
|
|
120
|
-
throwError({ t: "unmounted" });
|
|
117
|
+
const targetProcess = process ?? this.defaultReceiver;
|
|
121
118
|
if (targetProcess === null)
|
|
122
119
|
throwError({ t: "bad_target" });
|
|
120
|
+
if (this.bridge === null)
|
|
121
|
+
throwError({ t: "unmounted" });
|
|
123
122
|
const requestId = this.requestId++;
|
|
124
123
|
const startTimeMs = performance.now();
|
|
125
124
|
const callPromise = new Promise((resolve) => {
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
});
|
|
133
|
-
this.calls.set(requestId, {
|
|
134
|
-
acknowledged: false,
|
|
135
|
-
startTimeMs,
|
|
136
|
-
resolve,
|
|
137
|
-
});
|
|
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 },
|
|
138
131
|
});
|
|
139
132
|
const result = await withTimeout(callPromise, timeoutMs ?? CALL_TIMEOUT_MS);
|
|
140
133
|
this.calls.delete(requestId);
|
|
@@ -143,16 +136,16 @@ class Popcorn {
|
|
|
143
136
|
/**
|
|
144
137
|
* Sends a message to an Elixir process (default or from options) and returns immediately.
|
|
145
138
|
*
|
|
146
|
-
* Unless passed via options, the name passed in `Popcorn.Wasm.
|
|
139
|
+
* Unless passed via options, the name passed in `Popcorn.Wasm.set_default_receiver/1` on the Elixir side is used.
|
|
147
140
|
* Throws "Unspecified target process" if default process is not set and no process is specified.
|
|
148
141
|
*/
|
|
149
142
|
cast(args, { process } = {}) {
|
|
150
143
|
this.assertStatus(["ready"]);
|
|
151
|
-
const targetProcess = process ?? this.
|
|
152
|
-
if (this.bridge === null)
|
|
153
|
-
throwError({ t: "unmounted" });
|
|
144
|
+
const targetProcess = process ?? this.defaultReceiver;
|
|
154
145
|
if (targetProcess === null)
|
|
155
146
|
throwError({ t: "bad_target" });
|
|
147
|
+
if (this.bridge === null)
|
|
148
|
+
throwError({ t: "unmounted" });
|
|
156
149
|
const requestId = this.requestId++;
|
|
157
150
|
this.trace("Main: cast: ", { requestId, process, args });
|
|
158
151
|
this.bridge.sendIframeRequest({
|
|
@@ -168,20 +161,27 @@ class Popcorn {
|
|
|
168
161
|
throwError({ t: "unmounted" });
|
|
169
162
|
this.trace("Main: deinit");
|
|
170
163
|
this.transition({ status: "deinit" });
|
|
171
|
-
this.
|
|
172
|
-
this.
|
|
173
|
-
this.
|
|
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;
|
|
174
176
|
if (this.heartbeatTimeout) {
|
|
175
177
|
clearTimeout(this.heartbeatTimeout);
|
|
176
178
|
this.heartbeatTimeout = null;
|
|
177
179
|
}
|
|
178
|
-
this.logListeners.stdout.clear();
|
|
179
|
-
this.logListeners.stderr.clear();
|
|
180
180
|
for (const callData of this.calls.values()) {
|
|
181
181
|
const durationMs = performance.now() - callData.startTimeMs;
|
|
182
182
|
callData.resolve({
|
|
183
183
|
ok: false,
|
|
184
|
-
error: new PopcornError(
|
|
184
|
+
error: new PopcornError(errorCode),
|
|
185
185
|
durationMs,
|
|
186
186
|
});
|
|
187
187
|
}
|
|
@@ -204,14 +204,47 @@ class Popcorn {
|
|
|
204
204
|
listener(message);
|
|
205
205
|
});
|
|
206
206
|
}
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
+
}
|
|
212
232
|
return;
|
|
213
233
|
}
|
|
214
|
-
|
|
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) {
|
|
215
248
|
this.notifyLogListeners("stdout", data.value);
|
|
216
249
|
}
|
|
217
250
|
else if (data.type === MESSAGES.STDERR) {
|
|
@@ -283,40 +316,10 @@ class Popcorn {
|
|
|
283
316
|
}
|
|
284
317
|
this.trace("Main: reloading iframe");
|
|
285
318
|
this.transition({ status: "reload" });
|
|
286
|
-
this.
|
|
287
|
-
this.bridge = null;
|
|
288
|
-
this.awaitedMessage = null;
|
|
289
|
-
if (this.heartbeatTimeout) {
|
|
290
|
-
clearTimeout(this.heartbeatTimeout);
|
|
291
|
-
this.heartbeatTimeout = null;
|
|
292
|
-
}
|
|
293
|
-
for (const callData of this.calls.values()) {
|
|
294
|
-
const durationMs = performance.now() - callData.startTimeMs;
|
|
295
|
-
callData.resolve({
|
|
296
|
-
ok: false,
|
|
297
|
-
error: new PopcornError("reload"),
|
|
298
|
-
durationMs,
|
|
299
|
-
});
|
|
300
|
-
}
|
|
301
|
-
this.calls.clear();
|
|
319
|
+
this.teardownBridge("reload");
|
|
302
320
|
this.onReloadCallback(reason);
|
|
303
321
|
this.mount();
|
|
304
322
|
}
|
|
305
|
-
awaitMessage(type) {
|
|
306
|
-
if (this.awaitedMessage) {
|
|
307
|
-
throwError({
|
|
308
|
-
t: "already_awaited",
|
|
309
|
-
messageType: this.awaitedMessage.type,
|
|
310
|
-
awaitedMessageType: type,
|
|
311
|
-
});
|
|
312
|
-
}
|
|
313
|
-
this.awaitedMessage = { type };
|
|
314
|
-
return new Promise((resolve) => {
|
|
315
|
-
if (!this.awaitedMessage)
|
|
316
|
-
throwError({ t: "assert" });
|
|
317
|
-
this.awaitedMessage.resolve = resolve;
|
|
318
|
-
});
|
|
319
|
-
}
|
|
320
323
|
trace(...messages) {
|
|
321
324
|
if (this.debug) {
|
|
322
325
|
console.debug(...messages);
|
package/dist/types.d.ts
CHANGED
|
@@ -4,6 +4,10 @@ export declare const HEARTBEAT_TIMEOUT_MS = 60000;
|
|
|
4
4
|
export declare const HEARTBEAT_INTERVAL_MS = 500;
|
|
5
5
|
export declare const MAX_RELOAD_N = 3;
|
|
6
6
|
export type AnySerializable = any;
|
|
7
|
+
export type ElixirEvent = {
|
|
8
|
+
eventName: string;
|
|
9
|
+
payload: AnySerializable;
|
|
10
|
+
};
|
|
7
11
|
export type CallRequest = {
|
|
8
12
|
requestId: number;
|
|
9
13
|
process: string;
|
|
@@ -32,11 +36,8 @@ export type CallAck = {
|
|
|
32
36
|
};
|
|
33
37
|
/** Messages sent from iframe to parent window */
|
|
34
38
|
export type IframeResponse = {
|
|
35
|
-
type: "popcorn-
|
|
36
|
-
value:
|
|
37
|
-
} | {
|
|
38
|
-
type: "popcorn-startVm";
|
|
39
|
-
value: string;
|
|
39
|
+
type: "popcorn-event";
|
|
40
|
+
value: ElixirEvent;
|
|
40
41
|
} | {
|
|
41
42
|
type: "popcorn-call";
|
|
42
43
|
value: CallResponse;
|
|
@@ -59,8 +60,7 @@ export type IframeResponse = {
|
|
|
59
60
|
/** Union of all messages (requests and responses) */
|
|
60
61
|
export type Message = IframeRequest | IframeResponse;
|
|
61
62
|
export declare const MESSAGES: {
|
|
62
|
-
readonly
|
|
63
|
-
readonly START_VM: "popcorn-startVm";
|
|
63
|
+
readonly EVENT: "popcorn-event";
|
|
64
64
|
readonly CALL: "popcorn-call";
|
|
65
65
|
readonly CAST: "popcorn-cast";
|
|
66
66
|
readonly CALL_ACK: "popcorn-callAck";
|
|
@@ -69,4 +69,9 @@ export declare const MESSAGES: {
|
|
|
69
69
|
readonly HEARTBEAT: "popcorn-heartbeat";
|
|
70
70
|
readonly RELOAD: "popcorn-reload";
|
|
71
71
|
};
|
|
72
|
+
export declare const EVENT_NAMES: {
|
|
73
|
+
readonly ELIXIR_READY: "popcorn_elixir_ready";
|
|
74
|
+
readonly APP_READY: "popcorn_app_ready";
|
|
75
|
+
readonly SET_DEFAULT_RECEIVER: "popcorn_set_default_receiver";
|
|
76
|
+
};
|
|
72
77
|
export declare function isMessageType(type: string): type is Message["type"];
|
package/dist/types.mjs
CHANGED
|
@@ -3,8 +3,7 @@ const CALL_TIMEOUT_MS = 60_000;
|
|
|
3
3
|
const HEARTBEAT_TIMEOUT_MS = 60_000;
|
|
4
4
|
const MAX_RELOAD_N = 3;
|
|
5
5
|
const MESSAGES = {
|
|
6
|
-
|
|
7
|
-
START_VM: "popcorn-startVm",
|
|
6
|
+
EVENT: "popcorn-event",
|
|
8
7
|
CALL: "popcorn-call",
|
|
9
8
|
CAST: "popcorn-cast",
|
|
10
9
|
CALL_ACK: "popcorn-callAck",
|
|
@@ -13,9 +12,14 @@ const MESSAGES = {
|
|
|
13
12
|
HEARTBEAT: "popcorn-heartbeat",
|
|
14
13
|
RELOAD: "popcorn-reload",
|
|
15
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
|
+
};
|
|
16
20
|
const MESSAGES_TYPES = new Set(Object.values(MESSAGES));
|
|
17
21
|
function isMessageType(type) {
|
|
18
22
|
return MESSAGES_TYPES.has(type);
|
|
19
23
|
}
|
|
20
24
|
|
|
21
|
-
export { CALL_TIMEOUT_MS, HEARTBEAT_TIMEOUT_MS, INIT_VM_TIMEOUT_MS, MAX_RELOAD_N, MESSAGES, isMessageType };
|
|
25
|
+
export { CALL_TIMEOUT_MS, EVENT_NAMES, HEARTBEAT_TIMEOUT_MS, INIT_VM_TIMEOUT_MS, MAX_RELOAD_N, MESSAGES, isMessageType };
|