@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.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({ data }) {
45
- if (isIframeResponse(data)) {
46
- this.onMessage(data);
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" | "already_awaited" | "already_mounted" | "unmounted" | "bad_target" | "bad_status" | "bundle_not_found";
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 throwError(error) {
25
+ function buildError(error) {
25
26
  switch (error.t) {
26
27
  case "assert":
27
- throw new PopcornInternalError("assert", "Assertion error");
28
+ return new PopcornInternalError("assert", "Assertion error");
28
29
  case "private_constructor":
29
- throw new PopcornInternalError("private_constructor", "Don't construct the Popcorn object directly, use Popcorn.init() instead");
30
+ return new PopcornInternalError("private_constructor", "Don't construct the Popcorn object directly, use Popcorn.init() instead");
30
31
  case "bad_call":
31
- throw new PopcornInternalError("bad_call", "Response for non-existent call");
32
+ return new PopcornInternalError("bad_call", "Response for non-existent call");
32
33
  case "no_acked_call":
33
- throw new PopcornInternalError("no_acked_call", "Response for non-acknowledged call");
34
+ return new PopcornInternalError("no_acked_call", "Response for non-acknowledged call");
34
35
  case "bad_ack":
35
- throw new PopcornInternalError("bad_ack", "Ack for non-existent call");
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
- throw new PopcornInternalError("already_mounted", "Iframe already mounted");
38
+ return new PopcornInternalError("already_mounted", "Iframe already mounted");
40
39
  case "unmounted":
41
- throw new PopcornInternalError("unmounted", "WASM iframe not mounted");
40
+ return new PopcornInternalError("unmounted", "WASM iframe not mounted");
42
41
  case "bad_target":
43
- throw new PopcornInternalError("bad_target", "Unspecified target process");
42
+ return new PopcornInternalError("bad_target", "Unspecified target process");
44
43
  case "bad_status":
45
- throw new PopcornInternalError("bad_status", `Operation not allowed: instance in "${error.status}" state, expected "${error.expectedStatus}"`);
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
- throw new PopcornInternalError("bundle_not_found", `Could not find a valid .avm bundle at "${error.primary}" or fallback "${error.fallback}"`);
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 runIFrame(): Promise<void>;
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
- INIT: "popcorn-init",
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 runIFrame() {
36
- const metaElement = document.querySelector('meta[name="bundle-path"]');
37
- if (!metaElement) {
38
- throw new Error("Missing meta[name='bundle-path'] element");
39
- }
40
- const bundlePath = metaElement.content;
41
- const bundleBuffer = await fetch(bundlePath).then((resp) => resp.arrayBuffer());
42
- const bundle = new Int8Array(bundleBuffer);
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(avmBundle) {
58
- let resolveResultPromise = null;
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
- FS.writeFile("/data/bundle.avm", avmBundle);
59
+ avmBundles.forEach((bundle, i) => {
60
+ FS.writeFile(bundleFilePaths[i], bundle);
61
+ });
67
62
  },
68
63
  ],
69
- arguments: ["/data/bundle.avm"],
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["onElixirReady"] = (initProcess) => {
156
- moduleInstance["onElixirReady"] = null;
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 { runIFrame };
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
- /** Path to compiled Elixir bundle (`.avm` file). */
11
- bundlePath?: string;
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 bundleURL;
62
+ private bundleURLs;
62
63
  private state;
63
- private initProcess;
64
+ private defaultReceiver;
64
65
  private requestId;
65
66
  private calls;
66
67
  private logListeners;
67
- private awaitedMessage;
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 code calls `Popcorn.Wasm.register/1`.
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.register/1` on the Elixir side is used.
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.register/1` on the Elixir side is used.
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, INIT_VM_TIMEOUT_MS, MAX_RELOAD_N, CALL_TIMEOUT_MS } from './types.mjs';
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
- bundleURL;
17
+ bundleURLs;
18
18
  state = { status: "uninitialized" };
19
- initProcess = null;
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
- awaitedMessage = null;
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 bundlePath = params.bundlePath ?? "/bundle.avm";
33
- const bundleURL = new URL(bundlePath, import.meta.url);
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: "runIFrame" },
40
- config: { "bundle-path": this.bundleURL },
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 code calls `Popcorn.Wasm.register/1`.
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 bundlePath = constructorParams.bundlePath
64
- ? constructorParams.bundlePath
65
- : await resolveBundleURL("/bundle.avm", "/assets/bundle.avm");
66
- const popcorn = new Popcorn({ ...constructorParams, bundlePath, container: containerWithDefault }, INIT_TOKEN);
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
- await this.awaitMessage(MESSAGES.INIT);
80
- this.transition({ status: "await_vm" });
81
- this.trace("Main: iframe loaded");
82
- const startTime = performance.now();
83
- const startVmResult = await withTimeout(this.awaitMessage(MESSAGES.START_VM).then((data) => ({
84
- ok: true,
85
- data,
86
- durationMs: performance.now() - startTime,
87
- })), INIT_VM_TIMEOUT_MS);
88
- if (!startVmResult.ok)
89
- throwError({ t: "assert" });
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, main process: ", this.initProcess);
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.register/1` on the Elixir side is used.
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.initProcess;
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
- if (this.bridge === null)
127
- throwError({ t: "unmounted" });
128
- this.trace("Main: call: ", { requestId, process, args });
129
- this.bridge.sendIframeRequest({
130
- type: MESSAGES.CALL,
131
- value: { requestId, process: targetProcess, args },
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.register/1` on the Elixir side is used.
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.initProcess;
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.bridge.deinit();
172
- this.bridge = null;
173
- this.awaitedMessage = null;
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("deinitialized"),
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
- iframeHandler(data) {
208
- const awaitedMessage = this.awaitedMessage;
209
- if (awaitedMessage && data.type == awaitedMessage.type) {
210
- this.awaitedMessage = null;
211
- awaitedMessage.resolve?.(data.value);
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
- if (data.type === MESSAGES.STDOUT) {
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.bridge.deinit();
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-init";
36
- value: null;
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 INIT: "popcorn-init";
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
- INIT: "popcorn-init",
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 };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swmansion/popcorn",
3
- "version": "0.2.2",
3
+ "version": "0.3.0-rc2",
4
4
  "description": "JS bindings for Popcorn",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",