@swmansion/popcorn 0.4.0-next.0 → 0.4.0-next.3

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.
Binary file
package/dist/types.d.ts CHANGED
@@ -45,6 +45,7 @@ export type BeamBootOptions = {
45
45
  /** VM environment variables. */
46
46
  env?: Record<string, string>;
47
47
  ttySize: TtySize;
48
+ noshell: boolean;
48
49
  createModule: CreateModuleFn<EmscriptenModule>;
49
50
  emit: (event: BeamEvent) => void;
50
51
  };
@@ -66,6 +67,9 @@ export type BeamEvent = {
66
67
  } | {
67
68
  type: "otp:run_js";
68
69
  payload: RunJsRequest;
70
+ } | {
71
+ type: "otp:await-ready";
72
+ payload: ReadyRequest;
69
73
  } | {
70
74
  type: "otp:tracked-value-delete";
71
75
  payload: number;
@@ -76,6 +80,9 @@ export type RunJsRequest = {
76
80
  replyTo: Uint8Array;
77
81
  return: "value" | "ref";
78
82
  };
83
+ export type ReadyRequest = {
84
+ replyTo: Uint8Array;
85
+ };
79
86
  /**
80
87
  * VM shutdown notification.
81
88
  *
package/dist/worker.mjs CHANGED
@@ -240,7 +240,7 @@ function readMainEvent(value) {
240
240
  case "popcorn:stdin":
241
241
  case "popcorn:tty-resize":
242
242
  case "popcorn:send":
243
- case "popcorn:run-js-reply":
243
+ case "popcorn:bridge-reply":
244
244
  return data;
245
245
  default:
246
246
  unreachable();
@@ -269,6 +269,11 @@ function deserializeBridgeMessage(text) {
269
269
  return: parsed.return,
270
270
  },
271
271
  };
272
+ case "await_ready":
273
+ return {
274
+ type: "otp:await-ready",
275
+ payload: { replyTo: base64ToBytes(parsed.reply_to) },
276
+ };
272
277
  default:
273
278
  return null;
274
279
  }
@@ -289,7 +294,12 @@ function getTransferables(event) {
289
294
  return [event.payload.buffer];
290
295
  }
291
296
  function isBridgeEnvelope(value) {
292
- const KNOWN_MESSAGE_TYPES = ["vm_message", "vm_error", "run_js"];
297
+ const KNOWN_MESSAGE_TYPES = [
298
+ "vm_message",
299
+ "vm_error",
300
+ "run_js",
301
+ "await_ready",
302
+ ];
293
303
  const data = objectWithKeys(value, ["type"]);
294
304
  return data !== null && KNOWN_MESSAGE_TYPES.includes(data.type);
295
305
  }
@@ -314,31 +324,82 @@ const T = {
314
324
  PREFIX_N: 155,
315
325
  /// entry type=dir, '5' in ASCII
316
326
  TYPE_DIR: 53,
327
+ /// entry type=PAX extended header, 'x' in ASCII
328
+ TYPE_PAX: 120,
317
329
  };
318
330
  function extractTar(data, onDir, onFile) {
319
331
  check(data.length % T.BLK_N === 0, "tar:bad_chunk");
320
332
  const decoder = new TextDecoder();
321
333
  let offset = 0;
334
+ let paxPath = null;
322
335
  while (offset + T.BLK_N <= data.length) {
323
336
  const header = data.slice(offset, offset + T.BLK_N);
324
- if (isZeroBlock(header))
337
+ if (isZeroBlock(header)) {
338
+ check(paxPath === null, "tar:dangling_pax");
325
339
  break;
340
+ }
326
341
  const name = readString(decoder, header, T.NAME_OFF, T.NAME_N);
327
342
  const prefix = readString(decoder, header, T.PREFIX_OFF, T.PREFIX_N);
328
- const fullName = prefix ? `${prefix}/${name}` : name;
343
+ const headerName = prefix ? `${prefix}/${name}` : name;
329
344
  const size = parseOctal(readString(decoder, header, T.SIZE_OFF, T.SIZE_N));
330
345
  const type = header[T.TYPEFLAG_OFF];
331
346
  offset += T.BLK_N;
332
- const path = fullName.startsWith("/") ? fullName : `/${fullName}`;
333
- if (type === T.TYPE_DIR) {
334
- onDir(path);
347
+ check(offset + size <= data.length, "tar:truncated_entry");
348
+ const contents = data.slice(offset, offset + size);
349
+ if (type === T.TYPE_PAX) {
350
+ check(paxPath === null, "tar:unexpected_pax");
351
+ paxPath = readPaxPath(decoder, contents);
335
352
  }
336
- else if (fullName) {
337
- const contents = data.slice(offset, offset + size);
338
- onFile(path, contents);
353
+ else {
354
+ const fullName = paxPath ?? headerName;
355
+ paxPath = null;
356
+ const path = fullName.startsWith("/") ? fullName : `/${fullName}`;
357
+ if (type === T.TYPE_DIR) {
358
+ onDir(path);
359
+ }
360
+ else if (fullName) {
361
+ onFile(path, contents);
362
+ }
339
363
  }
340
364
  offset += Math.ceil(size / T.BLK_N) * T.BLK_N;
341
365
  }
366
+ check(paxPath === null, "tar:dangling_pax");
367
+ }
368
+ function readPaxPath(decoder, data) {
369
+ let offset = 0;
370
+ let path = null;
371
+ while (offset < data.length) {
372
+ const { length, recordStart } = readPaxLength(decoder, data, offset);
373
+ const recordEnd = offset + length;
374
+ offset = recordEnd;
375
+ const [key, value] = readPaxRecord(decoder, data, recordStart, recordEnd);
376
+ if (key === "path") {
377
+ check(value.length > 0, "tar:bad_pax_path");
378
+ path = value;
379
+ }
380
+ }
381
+ check(path !== null, "tar:missing_pax_path");
382
+ return path;
383
+ }
384
+ function readPaxLength(decoder, data, offset) {
385
+ const SPACE = 32;
386
+ const separator = data.indexOf(SPACE, offset);
387
+ check(separator > offset, "tar:bad_pax_length");
388
+ const source = decoder.decode(data.subarray(offset, separator));
389
+ const length = Number(source);
390
+ const isInt = Number.isSafeInteger(length) && length >= 0 && String(length) === source;
391
+ check(isInt, "tar:bad_pax_length");
392
+ return { length, recordStart: separator + 1 };
393
+ }
394
+ function readPaxRecord(decoder, data, start, end) {
395
+ const record = decoder.decode(data.subarray(start, end));
396
+ check(record.endsWith("\n"), "tar:bad_pax_record");
397
+ const separator = record.indexOf("=");
398
+ check(separator > 0, "tar:bad_pax_record");
399
+ const key = record.slice(0, separator);
400
+ // drop '\n'
401
+ const value = record.slice(separator + 1, -1);
402
+ return [key, value];
342
403
  }
343
404
  function isZeroBlock(block) {
344
405
  for (let i = 0; i < block.length; i++) {
@@ -405,7 +466,7 @@ function start(options) {
405
466
  };
406
467
  }
407
468
  async function boot(opts, state, vm) {
408
- const { otpAssetsRoot, emulatorArgs, extraArgs, env, ttySize, createModule, emit, } = opts;
469
+ const { otpAssetsRoot, emulatorArgs, extraArgs, env, ttySize, noshell, createModule, emit, } = opts;
409
470
  const loadedFsData = await loadFsData(otpAssetsRoot);
410
471
  if (!loadedFsData.ok) {
411
472
  return { ok: false, error: loadedFsData.error };
@@ -424,22 +485,34 @@ async function boot(opts, state, vm) {
424
485
  LINES: String(ttySize.rows),
425
486
  ERL_INETRC: INETRC_PATH,
426
487
  };
488
+ let emitError = function (payload) {
489
+ emitError = null;
490
+ state.isVmReady = false;
491
+ emit({ type: "otp:error", payload });
492
+ };
427
493
  const moduleConfig = {
428
494
  print: (text) => emit({ type: "otp:stdout", payload: UTF8.encode(text) }),
429
495
  printErr: (text) => emit({ type: "otp:stderr", payload: UTF8.encode(text) }),
430
- onExit: (code) => emit({ type: "otp:error", payload: { kind: "exit", data: code } }),
431
- onAbort: (text) => emit({ type: "otp:error", payload: { kind: "abort", data: text } }),
496
+ onExit: (code) => emitError?.({ kind: "exit", data: code }),
497
+ onAbort: (text) => emitError?.({ kind: "abort", data: text }),
432
498
  onBeamMessage: (text) => {
499
+ if (emitError === null) {
500
+ return;
501
+ }
433
502
  const event = deserializeBridgeMessage(text);
434
503
  if (event === null)
435
504
  return;
505
+ if (event.type === "otp:error") {
506
+ emitError(event.payload);
507
+ return;
508
+ }
436
509
  if (handleVmReady(event))
437
510
  return;
438
511
  if (handleAppReady(event))
439
512
  return;
440
513
  emit(event);
441
514
  },
442
- onError: (text) => emit({ type: "otp:error", payload: { kind: "error", data: text } }),
515
+ onError: (text) => emitError?.({ kind: "error", data: text }),
443
516
  onStdinConsumed: (size) => emit({ type: "otp:stdin-consumed", payload: size }),
444
517
  onTrackedValueDelete: (key) => emit({ type: "otp:tracked-value-delete", payload: key }),
445
518
  onTtyChunk: (fd, chunk) => emit({
@@ -449,6 +522,7 @@ async function boot(opts, state, vm) {
449
522
  arguments: buildArgs({
450
523
  appNames: fsData.appNames,
451
524
  entrypoint: fsData.entrypoint,
525
+ noshell,
452
526
  emulator: emulatorArgs ?? [],
453
527
  extra: extraArgs ?? [],
454
528
  }),
@@ -516,8 +590,10 @@ function toPopcornError(error) {
516
590
  const message = error instanceof Error ? error.message : String(error);
517
591
  return err("worker:load", { message });
518
592
  }
519
- function buildArgs({ appNames, entrypoint, emulator, extra, }) {
593
+ function buildArgs({ appNames, entrypoint, noshell, emulator, extra, }) {
520
594
  const args = [...emulator, "--", ...BASE_ARGS, "-boot", BOOT_NAME];
595
+ if (noshell)
596
+ args.push("-noshell");
521
597
  for (const app of CORE_APPS) {
522
598
  args.push("-pa", `/lib/${app}/ebin`);
523
599
  }
@@ -714,6 +790,7 @@ self.onmessage = async (event) => {
714
790
  extraArgs: data.payload.extraArgs,
715
791
  env: data.payload.env,
716
792
  ttySize: data.payload.ttySize,
793
+ noshell: data.payload.noshell,
717
794
  createModule,
718
795
  emit: toMain,
719
796
  });
@@ -743,7 +820,7 @@ self.onmessage = async (event) => {
743
820
  });
744
821
  break;
745
822
  }
746
- case "popcorn:run-js-reply": {
823
+ case "popcorn:bridge-reply": {
747
824
  // ignore the `send()` result, process could've died
748
825
  check(instance !== null);
749
826
  instance.send(data.payload.message);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swmansion/popcorn",
3
- "version": "0.4.0-next.0",
3
+ "version": "0.4.0-next.3",
4
4
  "description": "JS bindings for BEAM/OTP in WebAssembly",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -56,14 +56,14 @@
56
56
  }
57
57
  },
58
58
  "devDependencies": {
59
- "@playwright/test": "^1.61.1",
59
+ "@playwright/test": "1.63.0",
60
60
  "@rollup/plugin-typescript": "^12.1.2",
61
61
  "@types/node": "^25.0.8",
62
62
  "esbuild": "^0.25.0",
63
63
  "rollup": "^4.55.1",
64
64
  "tslib": "^2.8.1",
65
65
  "typescript": "^5.9.3",
66
- "vite": "^7.3.1"
66
+ "vite": "^7.3.6"
67
67
  },
68
68
  "scripts": {
69
69
  "build": "rollup -c",
@@ -1,184 +0,0 @@
1
- defmodule Popcorn.BeamTools.BeamPatcher do
2
- @moduledoc """
3
- Replaces functions in a compiled BEAM with implementations taken from a patch
4
- module, by merging both modules as Core Erlang and recompiling with the host
5
- compiler.
6
-
7
- A patch function replaces the original of the same name and arity, including
8
- for calls made from the original module's own code. Exported patch functions
9
- listed in `-compile([{popcorn_patch_private, [{f, a}]}])` stay private.
10
- `popcorn_module:f(...)` calls the original implementation of `f`.
11
- """
12
-
13
- @orig_prefix "popcorn_orig"
14
- @patch_prefix "popcorn_patch"
15
-
16
- @doc """
17
- Compiles `patch_path` and merges it into the BEAM at `beam_path`, overwriting it.
18
- """
19
- def patch_beam(beam_path, patch_path) do
20
- with {:ok, original} <- read_ast(beam_path),
21
- {:ok, patch} <- compile_patch(patch_path) do
22
- beam =
23
- original
24
- |> merge_modules(patch)
25
- |> serialize()
26
-
27
- File.write!(beam_path, beam)
28
- :ok
29
- end
30
- end
31
-
32
- defp read_ast(beam_path) do
33
- case :beam_lib.chunks(to_charlist(beam_path), [:abstract_code]) do
34
- {:ok, {_module, [abstract_code: {_backend, abstract_code}]}} ->
35
- {:ok, _module, ast} = :compile.noenv_forms(abstract_code, [:to_core])
36
- {:ok, ast}
37
-
38
- _ ->
39
- err(:unpatchable_beam, beam_path)
40
- end
41
- end
42
-
43
- defp compile_patch(patch_path) do
44
- case :compile.file(to_charlist(patch_path), [:binary, :debug_info, :return_errors, :to_core]) do
45
- {:ok, _module, ast} ->
46
- {:ok, ast}
47
-
48
- {:error, errors, _warnings} ->
49
- err(:bad_patch, {patch_path, errors})
50
- end
51
- end
52
-
53
- defp serialize(ast) do
54
- {:ok, _module, beam} = :compile.noenv_forms(ast, [:from_core, debug_info: {:core_v1, ast}])
55
- beam
56
- end
57
-
58
- defp merge_modules(orig_ast, patch_ast) do
59
- {:c_module, _meta, module_spec, orig_exports, orig_specs, orig_body} = orig_ast
60
- {:c_module, _meta, _module_spec, patch_exports, patch_specs, patch_body} = patch_ast
61
- private_overrides = private_overrides(patch_specs)
62
- orig_exports = MapSet.new(orig_exports, fn {:c_var, _meta, fa} -> fa end)
63
-
64
- patch_exports =
65
- patch_exports
66
- |> MapSet.new(fn {:c_var, _meta, fa} -> fa end)
67
- |> MapSet.difference(private_overrides)
68
- |> MapSet.difference(MapSet.new(module_info: 0, module_info: 1))
69
-
70
- exports = MapSet.union(orig_exports, patch_exports)
71
- replaced = MapSet.union(patch_exports, private_overrides)
72
-
73
- # Everything the patch does not replace keeps its name: the module's own
74
- # `-nifs` declarations and internal calls refer to private functions by name.
75
- # Replaced originals are renamed rather than dropped so a patch can delegate
76
- # to them; an original nothing delegates to is dead code and the compiler
77
- # removes it. Patch-private helpers are renamed so they cannot collide with
78
- # an original of the same name.
79
- orig_body = rename_replaced_funs(orig_body, replaced)
80
-
81
- patch_body =
82
- patch_body
83
- |> rename_funs_and_local_calls(%{prefix: @patch_prefix, except: replaced})
84
- |> inject_original_calls(replaced)
85
-
86
- exports_ast = exports |> Enum.sort() |> Enum.map(&{:c_var, [], &1})
87
-
88
- {:c_module, [], module_spec, exports_ast, orig_specs ++ patch_specs, patch_body ++ orig_body}
89
- end
90
-
91
- defp private_overrides(patch_specs) do
92
- patch_specs
93
- |> Enum.flat_map(fn
94
- {{:c_literal, _meta1, :compile}, {:c_literal, _meta2, params}} -> params
95
- _other -> []
96
- end)
97
- |> Enum.flat_map(fn
98
- {:popcorn_patch_private, funs} -> List.wrap(funs)
99
- _other -> []
100
- end)
101
- |> MapSet.new()
102
- end
103
-
104
- defp rename_replaced_funs(ast, funs) do
105
- Enum.map(ast, fn
106
- {{:c_var, var_meta, {fun, arity}}, definition} ->
107
- if {fun, arity} in funs do
108
- {{:c_var, var_meta, {prefixed(@orig_prefix, fun), arity}}, definition}
109
- else
110
- {{:c_var, var_meta, {fun, arity}}, definition}
111
- end
112
-
113
- form ->
114
- form
115
- end)
116
- end
117
-
118
- defp inject_original_calls(ast, replaced) do
119
- with {:c_call, call_meta, mod_ast, fun_ast, args} <- ast,
120
- {:c_literal, _mod_meta, :popcorn_module} <- mod_ast,
121
- {:c_literal, fun_meta, fun} <- fun_ast do
122
- arity = length(args)
123
- original = if {fun, arity} in replaced, do: prefixed(@orig_prefix, fun), else: fun
124
-
125
- {:c_apply, call_meta, {:c_var, fun_meta, {original, arity}}, args}
126
- else
127
- _other -> traverse(ast, &inject_original_calls(&1, replaced))
128
- end
129
- end
130
-
131
- defp rename_funs_and_local_calls({:c_var, meta, {function, arity} = fa} = ast, ctx)
132
- when is_atom(function) and is_integer(arity) do
133
- if fa in ctx.except do
134
- ast
135
- else
136
- {:c_var, meta, {prefixed(ctx.prefix, function), arity}}
137
- end
138
- end
139
-
140
- defp rename_funs_and_local_calls({:function, {function, arity} = fa} = ast, ctx)
141
- when is_atom(function) and is_integer(arity) do
142
- if fa in ctx.except do
143
- ast
144
- else
145
- {:function, {prefixed(ctx.prefix, function), arity}}
146
- end
147
- end
148
-
149
- defp rename_funs_and_local_calls({:id, {line, col, id}}, ctx) do
150
- id =
151
- case Atom.to_string(id) do
152
- "-" <> id -> id
153
- id -> id
154
- end
155
-
156
- {:id, {line, col, prefixed("-" <> ctx.prefix, id)}}
157
- end
158
-
159
- defp rename_funs_and_local_calls(ast, ctx) do
160
- traverse(ast, &rename_funs_and_local_calls(&1, ctx))
161
- end
162
-
163
- defp prefixed(prefix, name), do: String.to_atom("#{prefix}_#{name}")
164
-
165
- defp traverse(ast, fun) when is_tuple(ast) do
166
- ast |> Tuple.to_list() |> fun.() |> List.to_tuple()
167
- end
168
-
169
- defp traverse([h | t], fun) do
170
- [fun.(h) | fun.(t)]
171
- end
172
-
173
- defp traverse(ast, _fun) do
174
- ast
175
- end
176
-
177
- defp err(:unpatchable_beam, beam_path) do
178
- {:error, %{code: "unpatchable_beam", beam: beam_path}}
179
- end
180
-
181
- defp err(:bad_patch, {patch_path, errors}) do
182
- {:error, %{code: "bad_patch", patch: patch_path, errors: inspect(errors)}}
183
- end
184
- end
@@ -1,74 +0,0 @@
1
- defmodule Popcorn.BeamTools.CLI do
2
- alias Popcorn.BeamTools.Packager
3
-
4
- @options [
5
- root_dir: :string,
6
- entrypoint_app: :string,
7
- extra_app: [:string, :keep],
8
- out_dir: :string,
9
- runtimes_dir: :string,
10
- runtime_variant: :string,
11
- strip: :boolean
12
- ]
13
-
14
- @required_options [:root_dir, :out_dir, :runtimes_dir]
15
-
16
- def main(argv) do
17
- if String.to_integer(System.otp_release()) < 27 do
18
- IO.puts(:stderr, "Popcorn requires OTP 27 or later")
19
- System.halt(1)
20
- end
21
-
22
- argv
23
- |> run()
24
- |> report()
25
- |> encode_json()
26
- |> IO.puts()
27
- end
28
-
29
- def run(argv) do
30
- with {:ok, options} <- parse_argv(argv) do
31
- Packager.run(options)
32
- end
33
- end
34
-
35
- defp parse_argv(argv) do
36
- {opts, [], invalid} = OptionParser.parse(argv, strict: @options)
37
- missing_opts = Enum.reject(@required_options, &Keyword.has_key?(opts, &1))
38
-
39
- case {invalid, missing_opts} do
40
- {[], []} ->
41
- options =
42
- opts
43
- |> Keyword.delete(:extra_app)
44
- |> Map.new()
45
- |> Map.put(:extra_apps, Keyword.get_values(opts, :extra_app))
46
- |> Map.put_new(:entrypoint_app, nil)
47
- |> Map.put_new(:runtime_variant, nil)
48
- |> Map.put_new(:strip, false)
49
-
50
- {:ok, options}
51
-
52
- _ ->
53
- bad_args(invalid, missing_opts)
54
- end
55
- end
56
-
57
- defp bad_args(invalid, missing_opts) do
58
- invalid =
59
- Enum.map(invalid, fn {option, value} ->
60
- %{option: option, value: value}
61
- end)
62
-
63
- missing = Enum.map(missing_opts, &to_string/1)
64
-
65
- {:error, %{code: "bad_args", invalid: invalid, missing: missing}}
66
- end
67
-
68
- defp report({:ok, report}), do: report
69
- defp report({:error, error}), do: %{ok: false, error: error}
70
-
71
- defp encode_json(term) do
72
- term |> :json.encode() |> IO.iodata_to_binary()
73
- end
74
- end