@swmansion/popcorn 0.4.0-next.5 → 0.4.0-next.6

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/README.md CHANGED
@@ -4,7 +4,7 @@ Run Elixir in a browser with OTP/BEAM compiled to WebAssembly.
4
4
  This prerelease replaces the AtomVM runtime used by Popcorn 0.3.x.
5
5
 
6
6
  Start with the
7
- [versioned Popcorn guide](https://popcorn.hexdocs.pm/0.4.0-next.5/introduction.html).
7
+ [versioned Popcorn guide](https://popcorn.hexdocs.pm/0.4.0-next.6/introduction.html).
8
8
  It includes a complete Mix and Vite tutorial.
9
9
 
10
10
  ## Install
@@ -13,12 +13,12 @@ It includes a complete Mix and Vite tutorial.
13
13
  npm install @swmansion/popcorn@next
14
14
  ```
15
15
 
16
- Add `{:popcorn, "0.4.0-next.5"}` to your Elixir application's dependencies.
16
+ Add `{:popcorn, "0.4.0-next.6"}` to your Elixir application's dependencies.
17
17
  Run `mix deps.get` before building the JavaScript application. The bundler
18
18
  plugins invoke Mix locally to compile and package application and
19
19
  standard-library code.
20
20
 
21
- Use the toolchain pinned in [mise.toml](https://github.com/software-mansion/popcorn/blob/v0.4.0-next.5/mise.toml).
21
+ Use the toolchain pinned in [mise.toml](https://github.com/software-mansion/popcorn/blob/v0.4.0-next.6/mise.toml).
22
22
  The packager checks host OTP compatibility against the selected runtime's manifest.
23
23
 
24
24
  ## Configure Vite
@@ -37,8 +37,20 @@ export default defineConfig({
37
37
  ```
38
38
 
39
39
  Set `rootDir` to the Mix project directory. The current Mix application is the
40
- default entrypoint. Set `app` to select another OTP application explicitly.
41
- Use `app: null` to package the base runtime without starting an application.
40
+ default entrypoint. Configure cooking in the Mix project's `config/config.exs`:
41
+
42
+ ```elixir
43
+ import Config
44
+
45
+ config :popcorn,
46
+ extra_apps: [:eex],
47
+ treeshake: [preserved_apps: [:eex]]
48
+ ```
49
+
50
+ Set `app: :other_app` to select another OTP application, or `app: nil` to
51
+ package the base runtime without starting an application. Cooking settings
52
+ belong exclusively to Elixir config; the JS plugins accept only `rootDir`.
53
+ See `mix help popcorn.cook` for all settings.
42
54
 
43
55
  The npm package contains two variants:
44
56
 
@@ -47,8 +59,8 @@ The npm package contains two variants:
47
59
 
48
60
  The plugin emits only the selected variant. The browser does not download both.
49
61
  Both variants share one JavaScript API and one Hex package.
50
- The plugin selects `crypto` when the application's dependencies or `extraApps` require it; otherwise it selects `core`.
51
- Set `runtimeVariant: "core"` or `runtimeVariant: "crypto"` to override this choice.
62
+ The cook task selects `crypto` when the application's dependencies or configured `extra_apps` require it; otherwise it selects `core`.
63
+ Set `runtime_variant: :core` or `runtime_variant: :crypto` in Elixir config to override this choice.
52
64
  An explicit `"core"` selection produces a build error if the application requires crypto.
53
65
 
54
66
  Rollup and esbuild plugins accept the same options through `@swmansion/popcorn/rollup` and `@swmansion/popcorn/esbuild`.
@@ -59,18 +71,18 @@ Use ESM output with those bundlers.
59
71
  ```ts
60
72
  import { Popcorn } from "@swmansion/popcorn";
61
73
 
62
- const result = await Popcorn.init();
63
- if (!result.ok) throw result.error;
64
-
65
- const vm = result.data;
74
+ const vm = await Popcorn.init();
66
75
  // Stop the runtime when the application no longer needs it.
67
76
  vm.deinit();
68
77
  ```
69
78
 
70
79
  `Popcorn.init()` waits for the OTP application tree to start. Use
71
- `popcorn.genserver.call()` to call a supervised GenServer from JavaScript.
80
+ `popcorn.call()` to call a supervised GenServer from JavaScript.
72
81
  Use `popcorn.onEvent()` to receive events from BEAM processes.
73
82
 
83
+ Methods throw or reject on failure. Only `send`, `call`, and `cast` also have
84
+ `trySend`, `tryCall`, and `tryCast` variants that return `Result` objects.
85
+
74
86
  Each VM starts with 64 MiB of Wasm linear memory and can grow to 256 MiB on
75
87
  phones/tablets or 2 GiB on desktop. Device detection uses the browser's user
76
88
  agent and touch support. Configure the limits with
@@ -91,7 +103,7 @@ Cross-Origin-Embedder-Policy: require-corp
91
103
 
92
104
  Vite sets these headers for development and preview. Configure your production server separately.
93
105
  Serve `.wasm` as `application/wasm`. Serve compressed `.tar.gz` files with `Content-Encoding: gzip` for `.tar` requests.
94
- The package also emits uncompressed tar files and Brotli variants. Brotli uses standard effort by default. Set `brotliEffort: "max"` for maximum compression in release builds.
106
+ The package also emits uncompressed tar files and Brotli variants. Brotli uses standard effort by default. Set `config :popcorn, brotli_effort: :max` in `config/config.exs` for maximum compression in release builds.
95
107
 
96
108
  The JavaScript bridge currently requires a Content Security Policy that permits `unsafe-eval`.
97
- See the [versioned Elixir API](https://popcorn.hexdocs.pm/0.4.0-next.5/) for interoperability details.
109
+ See the [versioned Elixir API](https://popcorn.hexdocs.pm/0.4.0-next.6/) for interoperability details.
package/dist/errors.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * An operation result. Check `ok` before accessing `data` or `error`.
3
- * Expected failures use the error branch. Invalid usage can still throw.
3
+ * Expected failures from `try*` methods use the error branch. Invalid usage can still throw.
4
4
  */
5
5
  export type Result<T, E extends Tag = Tag> = {
6
6
  ok: true;
@@ -99,4 +99,5 @@ export declare class PopcornError<T extends Tag = Tag> extends Error {
99
99
  static deserialize(value: unknown): PopcornError;
100
100
  }
101
101
  export declare function isUnserializableReason(value: unknown): value is UnserializableReason;
102
+ export declare function unwrap<T>(result: Result<T>): T;
102
103
  export {};
package/dist/index.mjs CHANGED
@@ -172,6 +172,11 @@ function check$1(ok, msg) {
172
172
  throw err$1("internal:check", { detail: msg });
173
173
  }
174
174
  }
175
+ function unwrap(result) {
176
+ if (!result.ok)
177
+ throw result.error;
178
+ return result.data;
179
+ }
175
180
 
176
181
  function base64ToBytes(b64) {
177
182
  const binary = atob(b64);
@@ -191,7 +196,7 @@ function unreachable() {
191
196
  function objectWithKeys(value, keys) {
192
197
  if (value === null || typeof value !== "object")
193
198
  return null;
194
- if (value.constructor !== Object)
199
+ if (Object.getPrototypeOf(value) !== Object.prototype)
195
200
  return null;
196
201
  const hasAllKeys = keys.every((k) => Object.hasOwn(value, k));
197
202
  if (!hasAllKeys)
@@ -208,7 +213,9 @@ const LARGE_TUPLE_EXT = 0x69;
208
213
  const NIL_EXT = 0x6a;
209
214
  const LIST_EXT = 0x6c;
210
215
  const BINARY_EXT = 0x6d;
216
+ const BINARY_HEADER_SIZE = 1 + 4;
211
217
  const SMALL_BIG_EXT = 0x6e;
218
+ const LARGE_BIG_EXT = 0x6f;
212
219
  const MAP_EXT = 0x74;
213
220
  const ATOM_UTF8_EXT = 0x76;
214
221
  const SMALL_ATOM_UTF8_EXT = 0x77;
@@ -309,6 +316,16 @@ class Encoder {
309
316
  case "number":
310
317
  this.number(value);
311
318
  return;
319
+ case "bigint":
320
+ this.integer(value);
321
+ return;
322
+ case "symbol": {
323
+ const name = Symbol.keyFor(value) ?? value.description;
324
+ if (name === undefined)
325
+ throw err("unsupported", value);
326
+ this.atom(name);
327
+ return;
328
+ }
312
329
  case "object":
313
330
  this.object(value);
314
331
  return;
@@ -328,29 +345,31 @@ class Encoder {
328
345
  if (!Number.isSafeInteger(value)) {
329
346
  throw err("lossy-int", value);
330
347
  }
331
- if (value >= 0 && value < 2 ** 8) {
348
+ this.integer(BigInt(value));
349
+ }
350
+ integer(value) {
351
+ if (value >= 0n && value < 256n) {
332
352
  this.byte(SMALL_INTEGER_EXT);
333
- this.byte(value);
353
+ this.byte(Number(value));
334
354
  return;
335
355
  }
336
- if (value >= -2147483648 && value < 2 ** 31) {
356
+ if (value >= -2147483648n && value < 2147483648n) {
337
357
  this.byte(INTEGER_EXT);
338
- this.int32(value);
358
+ this.int32(Number(value));
339
359
  return;
340
360
  }
341
- this.smallBigInt(value);
342
- }
343
- // https://www.erlang.org/doc/apps/erts/erl_ext_dist.html#small_big_ext
344
- smallBigInt(value) {
345
- let magnitude = BigInt(Math.abs(value));
361
+ let magnitude = value < 0n ? -value : value;
346
362
  const digits = [];
347
363
  while (magnitude > 0n) {
348
364
  digits.push(Number(magnitude & 0xffn));
349
365
  magnitude >>= 8n;
350
366
  }
351
- this.byte(SMALL_BIG_EXT);
352
- this.byte(digits.length);
353
- this.byte(value < 0 ? 1 : 0);
367
+ this.byte(digits.length < 256 ? SMALL_BIG_EXT : LARGE_BIG_EXT);
368
+ if (digits.length < 256)
369
+ this.byte(digits.length);
370
+ else
371
+ this.uint32(digits.length);
372
+ this.byte(value < 0n ? 1 : 0);
354
373
  this.bytes(digits);
355
374
  }
356
375
  object(rawValue) {
@@ -376,12 +395,34 @@ class Encoder {
376
395
  this.array(value);
377
396
  return;
378
397
  }
398
+ if (value instanceof ArrayBuffer) {
399
+ const bytes = new Uint8Array(value);
400
+ this.binary(bytes);
401
+ return;
402
+ }
403
+ if (ArrayBuffer.isView(value)) {
404
+ const bytes = new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
405
+ this.binary(bytes);
406
+ return;
407
+ }
408
+ if (value instanceof Date) {
409
+ this.date(value);
410
+ return;
411
+ }
412
+ if (value instanceof Map) {
413
+ this.entries(value.entries(), value);
414
+ return;
415
+ }
416
+ if (value instanceof Set) {
417
+ this.set(value);
418
+ return;
419
+ }
379
420
  const prototype = Object.getPrototypeOf(value);
380
421
  const isObject = prototype === Object.prototype || prototype === null;
381
422
  if (!isObject) {
382
423
  throw err("non-plain-object", value);
383
424
  }
384
- this.map(value);
425
+ this.entries(Object.entries(value), value);
385
426
  }
386
427
  finally {
387
428
  this.ancestors.delete(value);
@@ -412,18 +453,84 @@ class Encoder {
412
453
  }
413
454
  this.byte(NIL_EXT);
414
455
  }
415
- map(value) {
416
- const keys = Object.keys(value).sort();
456
+ set(value) {
457
+ // Elixir 1.20 uses the version-2 Erlang sets map representation.
458
+ const members = [];
459
+ for (const member of value)
460
+ members.push([member, []]);
461
+ const memberBytes = this.mapBytes(members, value, true);
462
+ const map = new RawTerm(memberBytes);
463
+ const fields = [
464
+ [atom("__struct__"), atom("Elixir.MapSet")],
465
+ [atom("map"), map],
466
+ ];
467
+ this.entries(fields, value);
468
+ }
469
+ date(value) {
470
+ const year = value.getUTCFullYear();
471
+ const validDate = Number.isFinite(value.getTime());
472
+ const supportedYear = year >= -9999 && year <= 9999;
473
+ if (!validDate || !supportedYear) {
474
+ throw err("unsupported", value);
475
+ }
476
+ const fields = {
477
+ __struct__: atom("Elixir.DateTime"),
478
+ calendar: atom("Elixir.Calendar.ISO"),
479
+ year,
480
+ month: value.getUTCMonth() + 1,
481
+ day: value.getUTCDate(),
482
+ hour: value.getUTCHours(),
483
+ minute: value.getUTCMinutes(),
484
+ second: value.getUTCSeconds(),
485
+ microsecond: tuple(value.getUTCMilliseconds() * 1000, 3),
486
+ time_zone: "Etc/UTC",
487
+ zone_abbr: "UTC",
488
+ utc_offset: 0,
489
+ std_offset: 0,
490
+ };
491
+ const entries = [];
492
+ for (const [key, item] of Object.entries(fields)) {
493
+ entries.push([atom(key), item]);
494
+ }
495
+ this.entries(entries, value);
496
+ }
497
+ termBytes(value) {
498
+ const start = this.output.length;
499
+ this.value(value);
500
+ return this.output.splice(start);
501
+ }
502
+ mapBytes(entries, source, merge = false) {
503
+ const encoded = [...entries].map(([key, value]) => ({
504
+ key: this.termBytes(key),
505
+ value: this.termBytes(value),
506
+ }));
507
+ encoded.sort((left, right) => compareKeys(left.key, right.key));
508
+ const unique = [];
509
+ for (const entry of encoded) {
510
+ const previous = unique.at(-1);
511
+ const duplicate = previous !== undefined && compareBytes(entry.key, previous.key) === 0;
512
+ if (duplicate) {
513
+ if (!merge)
514
+ throw err("unsupported", source);
515
+ continue;
516
+ }
517
+ unique.push(entry);
518
+ }
519
+ const start = this.output.length;
417
520
  this.byte(MAP_EXT);
418
- this.uint32(keys.length);
419
- for (const key of keys) {
420
- this.binary(key);
421
- this.value(value[key]);
521
+ this.uint32(unique.length);
522
+ for (const entry of unique) {
523
+ this.bytes(entry.key);
524
+ this.bytes(entry.value);
422
525
  }
526
+ return new Uint8Array(this.output.splice(start));
527
+ }
528
+ entries(entries, source) {
529
+ this.bytes(this.mapBytes(entries, source));
423
530
  }
424
531
  atom(atom) {
425
532
  const bytes = UTF8$1.encode(atom);
426
- if (bytes.length >= 2 ** 16) {
533
+ if ([...atom].length > 255) {
427
534
  throw err("unsupported", atom);
428
535
  }
429
536
  if (bytes.length < 2 ** 8) {
@@ -437,7 +544,7 @@ class Encoder {
437
544
  this.bytes(bytes);
438
545
  }
439
546
  binary(value) {
440
- const bytes = UTF8$1.encode(value);
547
+ const bytes = typeof value === "string" ? UTF8$1.encode(value) : value;
441
548
  this.byte(BINARY_EXT);
442
549
  this.uint32(bytes.length);
443
550
  this.bytes(bytes);
@@ -470,6 +577,21 @@ class Encoder {
470
577
  function err(reason, part) {
471
578
  return new TypeError(reason, { cause: part });
472
579
  }
580
+ function compareBytes(a, b, start = 0) {
581
+ const length = Math.min(a.length, b.length);
582
+ for (let i = start; i < length; i++) {
583
+ if (a[i] !== b[i])
584
+ return a[i] - b[i];
585
+ }
586
+ return a.length - b.length;
587
+ }
588
+ function compareKeys(left, right) {
589
+ const leftTag = left[0];
590
+ const rightTag = right[0];
591
+ const binaryKeys = leftTag === BINARY_EXT && rightTag === BINARY_EXT;
592
+ const offset = binaryKeys ? BINARY_HEADER_SIZE : 0;
593
+ return compareBytes(left, right, offset);
594
+ }
473
595
 
474
596
  function readWorkerEvent(value) {
475
597
  const data = objectWithKeys(value, ["type", "payload"]);
@@ -544,6 +666,10 @@ function assertRunJsFn(value) {
544
666
  * Use {@link Popcorn.init} to create and start an instance.
545
667
  **/
546
668
  class Popcorn {
669
+ /** Receives stdout. Can be replaced at any time; `undefined` restores the default output handler. */
670
+ onStdout;
671
+ /** Receives stderr. Can be replaced at any time; `undefined` restores the default output handler. */
672
+ onStderr;
547
673
  vmWorker;
548
674
  state = { status: "created" };
549
675
  opts;
@@ -561,10 +687,14 @@ class Popcorn {
561
687
  trackedKeySeq = 0;
562
688
  io = createIoState();
563
689
  vmReady = false;
564
- genserver = {
565
- call: (target, request, opts) => this.call(target, request, opts),
566
- cast: (target, request, opts) => this.cast(target, request, opts),
567
- };
690
+ /** Calls a GenServer and returns its reply. See {@link tryCall} for a result instead of throwing. */
691
+ async call(target, request, opts) {
692
+ return unwrap(await this.tryCall(target, request, opts));
693
+ }
694
+ /** Sends a cast through the proxy. See {@link tryCast} for a result instead of throwing. */
695
+ async cast(target, request, opts) {
696
+ unwrap(await this.tryCast(target, request, opts));
697
+ }
568
698
  TrackedValue = class {
569
699
  value;
570
700
  cleanup;
@@ -635,7 +765,9 @@ class Popcorn {
635
765
  },
636
766
  };
637
767
  this.ttySize = { ...ttySize };
638
- this.output = resolveOutputHandlers(opts);
768
+ this.onStdout = opts.onStdout;
769
+ this.onStderr = opts.onStderr;
770
+ this.output = resolveOutputHandlers(this.opts, this);
639
771
  if (opts.onEvent)
640
772
  this.onEvent(opts.onEvent);
641
773
  this.spawnWorker();
@@ -654,18 +786,16 @@ class Popcorn {
654
786
  *
655
787
  * Use `opts.onEvent` to receive messages sent during startup.
656
788
  *
657
- * @returns Ok tuple or `runtime:eval-unavailable` if the page blocks JavaScript evaluation.
789
+ * @returns The instance. Rejects on failure.
790
+ *
791
+ * @see {@link PopcornOpts}
658
792
  */
659
793
  static async init(opts = {}) {
660
794
  if (!canEval()) {
661
- return { ok: false, error: err$1("runtime:eval-unavailable", {}) };
795
+ throw err$1("runtime:eval-unavailable", {});
662
796
  }
663
797
  const popcorn = new Popcorn(opts);
664
- const result = await popcorn.boot();
665
- if (!result.ok) {
666
- return result;
667
- }
668
- return { ok: true, data: popcorn };
798
+ return await popcorn.boot();
669
799
  }
670
800
  /**
671
801
  * Starts the VM and waits for its bridge and entrypoint application.
@@ -674,37 +804,29 @@ class Popcorn {
674
804
  *
675
805
  * After shutdown, starts a fresh VM with the original options.
676
806
  *
677
- * @returns Ok tuple with `this` if boot completes or error tuple.
807
+ * @returns This instance. Rejects on failure.
678
808
  *
679
809
  * @example
680
810
  * ```ts
681
811
  * const popcorn = new Popcorn({});
682
812
  * popcorn.onEvent((message) => console.log(message));
683
- * const result = await popcorn.boot();
684
- * if (!result.ok) throw result.error;
813
+ * await popcorn.boot();
685
814
  * ```
686
815
  */
687
816
  async boot() {
688
- if (this.state.status === "booted") {
689
- return { ok: true, data: this };
690
- }
691
- if (this.state.status === "booting") {
692
- // TODO(jgonet): make it easier to construct check() errors without throwing
693
- const error = err$1("internal:check", {
694
- detail: "Boot already in progress",
695
- });
696
- return { ok: false, error };
697
- }
817
+ if (this.state.status === "booted")
818
+ return this;
819
+ check(this.state.status !== "booting", "Boot already in progress");
698
820
  const reboot = this.state.status === "closed";
699
821
  if (reboot) {
700
822
  this.spawnWorker();
701
823
  }
702
824
  this.Pid = createPidClass();
703
825
  this.io = createIoState();
704
- this.output = resolveOutputHandlers(this.opts);
826
+ this.output = resolveOutputHandlers(this.opts, this);
705
827
  check(this.readyWaiters.length === 0);
706
828
  this.state = { status: "booting" };
707
- return await new Promise((resolve) => {
829
+ return await new Promise((resolve, reject) => {
708
830
  const timeoutsMs = { ...DEFAULT_TIMEOUTS_MS, ...this.opts.timeoutsMs };
709
831
  const settle = (result) => {
710
832
  if (this.settleBoot === null)
@@ -714,7 +836,10 @@ class Popcorn {
714
836
  if (!result.ok) {
715
837
  this.deinit();
716
838
  }
717
- resolve(result);
839
+ if (result.ok)
840
+ resolve(result.data);
841
+ else
842
+ reject(result.error);
718
843
  };
719
844
  this.settleBoot = settle;
720
845
  const startPhase = (timeoutMs) => setTimeout(() => {
@@ -765,13 +890,12 @@ class Popcorn {
765
890
  *
766
891
  * Encodes strings as UTF-8 and copies byte arrays. Does not append a newline.
767
892
  *
768
- * Returns `stdio:overflow` if the chunk exceeds the remaining 64 KiB queue capacity.
893
+ * Throws `stdio:overflow` if the chunk exceeds the remaining 64 KiB queue capacity.
769
894
  * An overflow leaves the queue unchanged.
770
895
  */
771
896
  writeStdin(chunk) {
772
- if (this.state.status === "closed") {
773
- return { ok: false, error: this.state.error };
774
- }
897
+ if (this.state.status === "closed")
898
+ throw this.state.error;
775
899
  check(this.state.status === "booted");
776
900
  const bytes = toBytes(chunk);
777
901
  check(bytes.byteLength > 0);
@@ -781,29 +905,28 @@ class Popcorn {
781
905
  capacityBytes: STDIN_QUEUE_CAPACITY_BYTES,
782
906
  attemptedBytes,
783
907
  });
784
- return { ok: false, error };
908
+ throw error;
785
909
  }
786
910
  this.io.stdin.reservedBytes = attemptedBytes;
787
911
  const event = { type: "popcorn:stdin", payload: { chunk: bytes } };
788
912
  toVm(this.vmWorker, event, [bytes.buffer]);
789
- return { ok: true, data: null };
790
913
  }
791
914
  /**
792
915
  * Sends new terminal dimensions to a booted VM.
793
916
  *
794
917
  * Each dimension must be between 1 and 65,535.
918
+ *
919
+ * Throws on failure.
795
920
  */
796
921
  resizeTty(columns, rows) {
797
- if (this.state.status === "closed") {
798
- return { ok: false, error: this.state.error };
799
- }
922
+ if (this.state.status === "closed")
923
+ throw this.state.error;
800
924
  check(this.state.status === "booted");
801
925
  check(isValidTtySize({ columns, rows }));
802
926
  toVm(this.vmWorker, {
803
927
  type: "popcorn:tty-resize",
804
928
  payload: { columns, rows },
805
929
  });
806
- return { ok: true, data: null };
807
930
  }
808
931
  /**
809
932
  * Sends a payload to a registered process name or a {@link Pid} from this VM boot.
@@ -812,21 +935,26 @@ class Popcorn {
812
935
  * A send timeout does not cancel delivery.
813
936
  * Uses the value conversions in {@link AnyValue}. An omitted, `null`, or `undefined` payload becomes an empty map.
814
937
  *
815
- * @returns Ok tuple or `bridge:not-started` before boot and `vm:exited` after shutdown.
938
+ * @returns Rejects on failure.
816
939
  *
817
940
  * @example
818
941
  * Send an Erlang `{ok, <<"value">>}` tuple to a registered `receiver` process.
819
942
  *
820
943
  * ```ts
821
- * const result = await popcorn.send("receiver", tuple(atom("ok"), "value"));
822
- * if (!result.ok) throw result.error;
944
+ * await popcorn.send("receiver", tuple(atom("ok"), "value"));
823
945
  * ```
824
946
  *
825
947
  * @see {@link AnyValue}
826
948
  * @see {@link atom}
827
949
  * @see {@link tuple}
950
+ *
951
+ * @see {@link trySend} for a result instead of throwing on expected failures.
828
952
  */
829
953
  async send(rawTarget, payload) {
954
+ unwrap(await this.trySend(rawTarget, payload));
955
+ }
956
+ /** See {@link send}. */
957
+ async trySend(rawTarget, payload) {
830
958
  if (this.state.status !== "booted") {
831
959
  if (this.state.status === "closed") {
832
960
  return { ok: false, error: this.state.error };
@@ -980,7 +1108,16 @@ class Popcorn {
980
1108
  unreachable();
981
1109
  }
982
1110
  }
983
- async call(rawTarget, request, opts) {
1111
+ /**
1112
+ * Calls a GenServer by registered name or {@link Pid} and waits for its reply.
1113
+ *
1114
+ * @param opts - `timeoutMs` defaults to 5 000 ms. `proxy` allows to select another registered proxy (see `Popcorn.Proxy`).
1115
+ * @returns A {@link Result} with the reply or a bridge, VM, timeout, or GenServer error.
1116
+ *
1117
+ * GenServer failures use `genserver:noproc`, `genserver:exit`, or `genserver:unserializable`.
1118
+ * A call timeout does not cancel server work.
1119
+ */
1120
+ async tryCall(rawTarget, request, opts) {
984
1121
  if (this.state.status !== "booted") {
985
1122
  if (this.state.status === "closed") {
986
1123
  return { ok: false, error: this.state.error };
@@ -1023,7 +1160,14 @@ class Popcorn {
1023
1160
  }
1024
1161
  return result;
1025
1162
  }
1026
- async cast(rawTarget, request, opts) {
1163
+ /**
1164
+ * Sends a cast through the proxy.
1165
+ *
1166
+ * Success confirms delivery to the proxy
1167
+ * @see {@link tryCall}
1168
+ * @param opts - `proxy` allows to select another registered proxy.
1169
+ */
1170
+ async tryCast(rawTarget, request, opts) {
1027
1171
  if (this.state.status !== "booted") {
1028
1172
  if (this.state.status === "closed") {
1029
1173
  return { ok: false, error: this.state.error };
@@ -1052,9 +1196,16 @@ class Popcorn {
1052
1196
  const args = this.reviveHandles(request.args);
1053
1197
  check(this.vmReady);
1054
1198
  const actions = {
1055
- send: (target, payload) => this.sendBridge(target, payload),
1056
- call: (target, payload, opts) => this.callBridge(target, payload, opts),
1057
- cast: (target, payload, opts) => this.castBridge(target, payload, opts),
1199
+ send: async (target, payload) => {
1200
+ unwrap(await this.sendBridge(target, payload));
1201
+ },
1202
+ call: async (target, payload, opts) => unwrap(await this.callBridge(target, payload, opts)),
1203
+ cast: async (target, payload, opts) => {
1204
+ unwrap(await this.castBridge(target, payload, opts));
1205
+ },
1206
+ trySend: (target, payload) => this.sendBridge(target, payload),
1207
+ tryCall: (target, payload, opts) => this.callBridge(target, payload, opts),
1208
+ tryCast: (target, payload, opts) => this.castBridge(target, payload, opts),
1058
1209
  };
1059
1210
  const result = await fn(args, actions);
1060
1211
  const value = request.return === "ref" ? this.asRef(result) : result;
@@ -1112,6 +1263,10 @@ class Popcorn {
1112
1263
  return make(this.TrackedValue);
1113
1264
  }
1114
1265
  reviveHandles(value) {
1266
+ const marker = objectWithKeys(value, ["popcorn_value"]);
1267
+ if (marker !== null && Object.keys(marker).length === 1) {
1268
+ return this.reviveValueTag(marker.popcorn_value);
1269
+ }
1115
1270
  const key = trackedRefKey(value);
1116
1271
  if (key !== null) {
1117
1272
  const entry = this.trackedValues.get(key);
@@ -1127,14 +1282,55 @@ class Popcorn {
1127
1282
  }
1128
1283
  const obj = objectWithKeys(value, []);
1129
1284
  if (obj !== null) {
1130
- const revived = {};
1131
- for (const [k, v] of Object.entries(obj)) {
1132
- revived[k] = this.reviveHandles(v);
1133
- }
1134
- return revived;
1285
+ const entries = Object.entries(obj).map(([key, item]) => [
1286
+ key,
1287
+ this.reviveHandles(item),
1288
+ ]);
1289
+ return Object.fromEntries(entries);
1135
1290
  }
1136
1291
  return value;
1137
1292
  }
1293
+ reviveValueTag(payload) {
1294
+ check(Array.isArray(payload) && payload.length === 2);
1295
+ const [tag, data] = payload;
1296
+ switch (tag) {
1297
+ case "binary":
1298
+ check(typeof data === "string");
1299
+ return base64ToBytes(data);
1300
+ case "integer": {
1301
+ const integer = readInteger(data);
1302
+ const safe = integer >= BigInt(Number.MIN_SAFE_INTEGER) &&
1303
+ integer <= BigInt(Number.MAX_SAFE_INTEGER);
1304
+ if (safe)
1305
+ return Number(integer);
1306
+ return integer;
1307
+ }
1308
+ case "datetime": {
1309
+ const milliseconds = readInteger(data);
1310
+ const inRange = milliseconds >= -377705116800000n && milliseconds <= 253402300799999n;
1311
+ check(inRange);
1312
+ return new Date(Number(milliseconds));
1313
+ }
1314
+ case "set":
1315
+ check(Array.isArray(data));
1316
+ return new Set(data.map((item) => this.reviveHandles(item)));
1317
+ case "object": {
1318
+ check(Array.isArray(data));
1319
+ const entries = [];
1320
+ for (const entry of data) {
1321
+ check(Array.isArray(entry) && entry.length === 2);
1322
+ const [key, value] = entry;
1323
+ check(typeof key === "string");
1324
+ entries.push([key, this.reviveHandles(value)]);
1325
+ }
1326
+ const object = Object.fromEntries(entries);
1327
+ check(Object.keys(object).length === entries.length);
1328
+ return object;
1329
+ }
1330
+ default:
1331
+ check(false, "unknown popcorn value tag");
1332
+ }
1333
+ }
1138
1334
  /** Maps pids and `TrackedValue`s during encoding, collecting handles into
1139
1335
  * `tracked` for the caller to register once encoding succeeds. */
1140
1336
  handleMapper(tracked) {
@@ -1230,22 +1426,20 @@ function isValidTtySize({ columns, rows }) {
1230
1426
  const rowInRange = 0 < rows && rows <= 0xffff;
1231
1427
  return colInRange && rowInRange;
1232
1428
  }
1233
- function resolveOutputHandlers(opts) {
1429
+ function resolveOutputHandlers(opts, callbacks) {
1234
1430
  if (opts.tty?.output === "bytes") {
1235
- const onStdout = opts.onStdout;
1236
- const onStderr = opts.onStderr;
1237
1431
  return {
1238
- stdout: onStdout ?? defaultOnStdoutBytes,
1239
- stderr: onStderr ?? defaultOnStderrBytes,
1432
+ stdout: (chunk) => (callbacks.onStdout ??
1433
+ defaultOnStdoutBytes)(chunk),
1434
+ stderr: (chunk) => (callbacks.onStderr ??
1435
+ defaultOnStderrBytes)(chunk),
1240
1436
  };
1241
1437
  }
1242
1438
  const stdoutDecoder = new TextDecoder();
1243
1439
  const stderrDecoder = new TextDecoder();
1244
- const onStdout = opts.onStdout ?? defaultOnStdout;
1245
- const onStderr = opts.onStderr ?? defaultOnStderr;
1246
1440
  return {
1247
- stdout: (chunk) => decodeOutput(stdoutDecoder, onStdout, chunk),
1248
- stderr: (chunk) => decodeOutput(stderrDecoder, onStderr, chunk),
1441
+ stdout: (chunk) => decodeOutput(stdoutDecoder, callbacks.onStdout ?? defaultOnStdout, chunk),
1442
+ stderr: (chunk) => decodeOutput(stderrDecoder, callbacks.onStderr ?? defaultOnStderr, chunk),
1249
1443
  };
1250
1444
  }
1251
1445
  function decodeOutput(decoder, onOutput, chunk) {
@@ -1335,5 +1529,10 @@ function defaultOnError(payload) {
1335
1529
  unreachable();
1336
1530
  }
1337
1531
  }
1532
+ function readInteger(value) {
1533
+ check(typeof value === "string");
1534
+ check(/^-?(0|[1-9][0-9]*)$/.test(value));
1535
+ return BigInt(value);
1536
+ }
1338
1537
 
1339
1538
  export { Popcorn, PopcornError, a, atom, schedulers, t, tuple };
@@ -6,26 +6,8 @@ type RuntimeVariant = "core" | "crypto";
6
6
  * compiles the project before packaging it.
7
7
  */
8
8
  export type Options = {
9
- /** Runtime variant override. */
10
- runtimeVariant?: RuntimeVariant;
11
- /** Mix project directory. */
9
+ /** Mix project directory. Cooking options belong in its config/config.exs. */
12
10
  rootDir: string;
13
- /**
14
- * OTP application to start after VM boot.
15
- * Defaults to the current Mix application.
16
- */
17
- app?: string | null;
18
- /** Additional applications to package with their dependencies. */
19
- extraApps?: string[];
20
- /** Brotli compression effort. Defaults to `standard`. */
21
- brotliEffort?: "standard" | "max";
22
- /** Removes nonessential BEAM chunks. Defaults to `true`. */
23
- strip?: boolean;
24
- /** Controls removal of unreachable modules and functions. Defaults to `none`. */
25
- treeshake?: TreeshakeOptions;
26
- };
27
- export type TreeshakeOptions = {
28
- preservedApps?: string[];
29
11
  };
30
12
  export type Prepared = {
31
13
  dir: string;
@@ -9,26 +9,6 @@ const execFileAsync = promisify(execFile);
9
9
  async function popcorn(options) {
10
10
  const dir = await mkdtemp(`${tmpdir()}/popcorn-otp-`);
11
11
  const args = ["popcorn.cook", "--out-dir", dir];
12
- if (options.app === null)
13
- args.push("--no-app");
14
- else if (options.app !== undefined)
15
- args.push("--app", options.app);
16
- for (const app of options.extraApps ?? [])
17
- args.push("--extra-app", app);
18
- if (options.runtimeVariant !== undefined) {
19
- args.push("--runtime-variant", options.runtimeVariant);
20
- }
21
- if (options.brotliEffort !== undefined) {
22
- args.push("--brotli-effort", options.brotliEffort);
23
- }
24
- if (options.strip === false)
25
- args.push("--no-strip");
26
- if (options.treeshake !== undefined) {
27
- args.push("--treeshake");
28
- for (const app of options.treeshake.preservedApps ?? []) {
29
- args.push("--preserved-app", app);
30
- }
31
- }
32
12
  try {
33
13
  await execFileAsync("mix", args, {
34
14
  cwd: resolve(options.rootDir),
package/dist/popcorn.d.ts CHANGED
@@ -54,14 +54,14 @@ export type PopcornOpts<Output extends TtyOutput = "text"> = {
54
54
  * Receives stdout.
55
55
  *
56
56
  * Defaults to `console.log`.
57
- * When `tty.output` is "bytes", we pass an `ArrayBuffer` as an argument and `string` otherwise
57
+ * Receives `Uint8Array` when `tty.output` is "bytes", and `string` otherwise.
58
58
  */
59
59
  onStdout?: (chunk: OutputChunk<Output>) => void;
60
60
  /**
61
61
  * Receives stderr.
62
62
  *
63
63
  * Defaults to `console.error`.
64
- * When `tty.output` is "bytes", we pass an `ArrayBuffer` as an argument and `string` otherwise
64
+ * Receives `Uint8Array` when `tty.output` is "bytes", and `string` otherwise.
65
65
  */
66
66
  onStderr?: (chunk: OutputChunk<Output>) => void;
67
67
  /**
@@ -95,34 +95,17 @@ type CallOpts = {
95
95
  timeoutMs?: number;
96
96
  proxy?: string;
97
97
  };
98
- export type GenServer = {
99
- /**
100
- * Calls a GenServer by registered name or {@link Pid} and waits for its reply.
101
- *
102
- * @param opts - `timeoutMs` defaults to 5 000 ms. `proxy` allows to select another registered proxy (see `Popcorn.Proxy`).
103
- * @returns A {@link Result} with the reply or a bridge, VM, timeout, or GenServer error.
104
- *
105
- * GenServer failures use `genserver:noproc`, `genserver:exit`, or `genserver:unserializable`.
106
- * A call timeout does not cancel server work.
107
- */
108
- call(target: string | Pid, request?: AnyValue, opts?: CallOpts): Promise<Result<AnyValue>>;
109
- /**
110
- * Sends a cast through the proxy.
111
- *
112
- * Success confirms delivery to the proxy
113
- * @see {@link call}
114
- * @param opts - `proxy` allows to select another registered proxy.
115
- */
116
- cast(target: string | Pid, request?: AnyValue, opts?: {
117
- proxy?: string;
118
- }): Promise<Result<null>>;
119
- };
98
+ export type GenServer = Pick<Popcorn, "call" | "cast" | "tryCall" | "tryCast">;
120
99
  /**
121
100
  * A BEAM VM in a browser worker.
122
101
  *
123
102
  * Use {@link Popcorn.init} to create and start an instance.
124
103
  **/
125
104
  export declare class Popcorn<Output extends TtyOutput = "text"> {
105
+ /** Receives stdout. Can be replaced at any time; `undefined` restores the default output handler. */
106
+ onStdout?: (chunk: OutputChunk<Output>) => void;
107
+ /** Receives stderr. Can be replaced at any time; `undefined` restores the default output handler. */
108
+ onStderr?: (chunk: OutputChunk<Output>) => void;
126
109
  private vmWorker;
127
110
  private state;
128
111
  private readonly opts;
@@ -140,7 +123,12 @@ export declare class Popcorn<Output extends TtyOutput = "text"> {
140
123
  private trackedKeySeq;
141
124
  private io;
142
125
  private vmReady;
143
- readonly genserver: GenServer;
126
+ /** Calls a GenServer and returns its reply. See {@link tryCall} for a result instead of throwing. */
127
+ call(target: string | Pid, request?: AnyValue, opts?: CallOpts): Promise<AnyValue>;
128
+ /** Sends a cast through the proxy. See {@link tryCast} for a result instead of throwing. */
129
+ cast(target: string | Pid, request?: AnyValue, opts?: {
130
+ proxy?: string;
131
+ }): Promise<void>;
144
132
  private readonly TrackedValue;
145
133
  private Pid;
146
134
  private readonly onWorkerMessage;
@@ -156,9 +144,11 @@ export declare class Popcorn<Output extends TtyOutput = "text"> {
156
144
  *
157
145
  * Use `opts.onEvent` to receive messages sent during startup.
158
146
  *
159
- * @returns Ok tuple or `runtime:eval-unavailable` if the page blocks JavaScript evaluation.
147
+ * @returns The instance. Rejects on failure.
148
+ *
149
+ * @see {@link PopcornOpts}
160
150
  */
161
- static init<Output extends TtyOutput = "text">(opts?: PopcornOpts<Output>): Promise<Result<Popcorn<Output>>>;
151
+ static init<Output extends TtyOutput = "text">(opts?: PopcornOpts<Output>): Promise<Popcorn<Output>>;
162
152
  /**
163
153
  * Starts the VM and waits for its bridge and entrypoint application.
164
154
  *
@@ -166,32 +156,33 @@ export declare class Popcorn<Output extends TtyOutput = "text"> {
166
156
  *
167
157
  * After shutdown, starts a fresh VM with the original options.
168
158
  *
169
- * @returns Ok tuple with `this` if boot completes or error tuple.
159
+ * @returns This instance. Rejects on failure.
170
160
  *
171
161
  * @example
172
162
  * ```ts
173
163
  * const popcorn = new Popcorn({});
174
164
  * popcorn.onEvent((message) => console.log(message));
175
- * const result = await popcorn.boot();
176
- * if (!result.ok) throw result.error;
165
+ * await popcorn.boot();
177
166
  * ```
178
167
  */
179
- boot(): Promise<Result<Popcorn<Output>>>;
168
+ boot(): Promise<Popcorn<Output>>;
180
169
  /**
181
170
  * Queues terminal input.
182
171
  *
183
172
  * Encodes strings as UTF-8 and copies byte arrays. Does not append a newline.
184
173
  *
185
- * Returns `stdio:overflow` if the chunk exceeds the remaining 64 KiB queue capacity.
174
+ * Throws `stdio:overflow` if the chunk exceeds the remaining 64 KiB queue capacity.
186
175
  * An overflow leaves the queue unchanged.
187
176
  */
188
- writeStdin(chunk: string | Uint8Array): Result<null>;
177
+ writeStdin(chunk: string | Uint8Array): void;
189
178
  /**
190
179
  * Sends new terminal dimensions to a booted VM.
191
180
  *
192
181
  * Each dimension must be between 1 and 65,535.
182
+ *
183
+ * Throws on failure.
193
184
  */
194
- resizeTty(columns: number, rows: number): Result<null>;
185
+ resizeTty(columns: number, rows: number): void;
195
186
  /**
196
187
  * Sends a payload to a registered process name or a {@link Pid} from this VM boot.
197
188
  *
@@ -199,21 +190,24 @@ export declare class Popcorn<Output extends TtyOutput = "text"> {
199
190
  * A send timeout does not cancel delivery.
200
191
  * Uses the value conversions in {@link AnyValue}. An omitted, `null`, or `undefined` payload becomes an empty map.
201
192
  *
202
- * @returns Ok tuple or `bridge:not-started` before boot and `vm:exited` after shutdown.
193
+ * @returns Rejects on failure.
203
194
  *
204
195
  * @example
205
196
  * Send an Erlang `{ok, <<"value">>}` tuple to a registered `receiver` process.
206
197
  *
207
198
  * ```ts
208
- * const result = await popcorn.send("receiver", tuple(atom("ok"), "value"));
209
- * if (!result.ok) throw result.error;
199
+ * await popcorn.send("receiver", tuple(atom("ok"), "value"));
210
200
  * ```
211
201
  *
212
202
  * @see {@link AnyValue}
213
203
  * @see {@link atom}
214
204
  * @see {@link tuple}
205
+ *
206
+ * @see {@link trySend} for a result instead of throwing on expected failures.
215
207
  */
216
- send(rawTarget: string | Pid, payload?: AnyValue): Promise<Result<null>>;
208
+ send(rawTarget: string | Pid, payload?: AnyValue): Promise<void>;
209
+ /** See {@link send}. */
210
+ trySend(rawTarget: string | Pid, payload?: AnyValue): Promise<Result<null>>;
217
211
  private sendBridge;
218
212
  /**
219
213
  * Registers a handler for BEAM message payloads.
@@ -235,9 +229,27 @@ export declare class Popcorn<Output extends TtyOutput = "text"> {
235
229
  private emit;
236
230
  private completeCall;
237
231
  private parseCallReply;
238
- private call;
232
+ /**
233
+ * Calls a GenServer by registered name or {@link Pid} and waits for its reply.
234
+ *
235
+ * @param opts - `timeoutMs` defaults to 5 000 ms. `proxy` allows to select another registered proxy (see `Popcorn.Proxy`).
236
+ * @returns A {@link Result} with the reply or a bridge, VM, timeout, or GenServer error.
237
+ *
238
+ * GenServer failures use `genserver:noproc`, `genserver:exit`, or `genserver:unserializable`.
239
+ * A call timeout does not cancel server work.
240
+ */
241
+ tryCall(rawTarget: string | Pid, request?: AnyValue, opts?: CallOpts): Promise<Result<AnyValue>>;
239
242
  private callBridge;
240
- private cast;
243
+ /**
244
+ * Sends a cast through the proxy.
245
+ *
246
+ * Success confirms delivery to the proxy
247
+ * @see {@link tryCall}
248
+ * @param opts - `proxy` allows to select another registered proxy.
249
+ */
250
+ tryCast(rawTarget: string | Pid, request?: AnyValue, opts?: {
251
+ proxy?: string;
252
+ }): Promise<Result<null>>;
241
253
  private castBridge;
242
254
  private nextCallId;
243
255
  private runJs;
@@ -248,6 +260,7 @@ export declare class Popcorn<Output extends TtyOutput = "text"> {
248
260
  private sendBridgeReply;
249
261
  private jsWithCurrentEnv;
250
262
  private reviveHandles;
263
+ private reviveValueTag;
251
264
  /** Maps pids and `TrackedValue`s during encoding, collecting handles into
252
265
  * `tracked` for the caller to register once encoding succeeds. */
253
266
  private handleMapper;
Binary file
Binary file
package/dist/types.d.ts CHANGED
@@ -1,15 +1,8 @@
1
1
  type CreateModuleFn<Mod> = (overrides?: Partial<Mod>) => Promise<Mod>;
2
2
  /**
3
- * A message value. JavaScript sends use these BEAM conversions:
3
+ * A value exchanged with BEAM. Containers convert their contents recursively.
4
4
  *
5
- * - Strings become UTF-8 binaries. Booleans become `true` and `false` atoms.
6
- * - Integers become integers. Other finite numbers become floats.
7
- * - Arrays become lists.
8
- * - Plain objects become maps with binary string keys.
9
- * - `null` and `undefined` become the `nil` atom.
10
- * - `atom()` and `tuple()` create atoms and tuples. PIDs retain their BEAM identity.
11
- *
12
- * Cycles, class instances, functions, symbols, bigints, unsafe integers, and non-finite numbers cause `bridge:unserializable`.
5
+ * See [Sharing values across languages](values.html) for supported types and conversions.
13
6
  */
14
7
  export type AnyValue = unknown;
15
8
  declare const pidBrand: unique symbol;
package/dist/worker.mjs CHANGED
@@ -222,7 +222,7 @@ function unreachable() {
222
222
  function objectWithKeys(value, keys) {
223
223
  if (value === null || typeof value !== "object")
224
224
  return null;
225
- if (value.constructor !== Object)
225
+ if (Object.getPrototypeOf(value) !== Object.prototype)
226
226
  return null;
227
227
  const hasAllKeys = keys.every((k) => Object.hasOwn(value, k));
228
228
  if (!hasAllKeys)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swmansion/popcorn",
3
- "version": "0.4.0-next.5",
3
+ "version": "0.4.0-next.6",
4
4
  "description": "JS bindings for BEAM/OTP in WebAssembly",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",