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

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.
@@ -5,9 +5,9 @@ import { r as runtimeDirectory, p as popcorn$1 } from './shared.mjs';
5
5
  import 'node:child_process';
6
6
  import 'node:os';
7
7
  import 'node:util';
8
- import 'node:zlib';
9
8
 
10
- const CORS_HEADERS = {
9
+ /** Headers required by the threaded WebAssembly runtime. */
10
+ const crossOriginIsolationHeaders = {
11
11
  "Cross-Origin-Opener-Policy": "same-origin",
12
12
  "Cross-Origin-Embedder-Policy": "require-corp",
13
13
  };
@@ -22,7 +22,7 @@ const distDir = resolve(dirname(fileURLToPath(import.meta.url)), "..");
22
22
  * import { popcorn } from "@swmansion/popcorn/vite";
23
23
  *
24
24
  * export default defineConfig({
25
- * plugins: [popcorn({ rootDir: "../my_app", app: "my_app" })],
25
+ * plugins: [popcorn({ rootDir: "../my_app" })],
26
26
  * });
27
27
  * ```
28
28
  *
@@ -43,8 +43,8 @@ function popcorn(options) {
43
43
  let dirty = false;
44
44
  let outDir;
45
45
  let assetsDir = "assets";
46
- // Dev/preview: pack once at first request, re-pack lazily after the app's
47
- // compiled beams change. A single in-flight promise dedupes concurrent
46
+ // Dev/preview: pack once at server startup, then re-pack lazily after the
47
+ // app's compiled beams change. A single in-flight promise dedupes concurrent
48
48
  // requests; a change during a re-pack re-sets `dirty` so it isn't lost.
49
49
  const ensurePrepared = () => {
50
50
  if (prepared !== undefined && !dirty)
@@ -95,24 +95,21 @@ function popcorn(options) {
95
95
  const filePath = resolve(assetsRoot, requestPath.slice(requestRoot.length));
96
96
  if (!isUnder(assetsRoot, filePath)) {
97
97
  res.statusCode = 403;
98
- setHeaders(res);
99
98
  res.end("Forbidden");
100
99
  return;
101
100
  }
102
101
  try {
103
102
  const compressible = isCompressible(filePath);
104
103
  const encoding = compressible
105
- ? selectEncoding(req.headers["accept-encoding"]?.toString(), options.brotli ?? false)
104
+ ? selectEncoding(req.headers["accept-encoding"]?.toString())
106
105
  : { name: null, suffix: "" };
107
106
  if (compressible && encoding.name === null) {
108
107
  res.statusCode = 406;
109
- setHeaders(res);
110
108
  res.setHeader("Vary", "Accept-Encoding");
111
109
  res.end("No supported content encoding");
112
110
  return;
113
111
  }
114
112
  const content = await readFile(`${filePath}${encoding.suffix}`);
115
- setHeaders(res);
116
113
  setContentType(res, filePath);
117
114
  res.setHeader("Vary", "Accept-Encoding");
118
115
  if (encoding.name !== null) {
@@ -122,7 +119,6 @@ function popcorn(options) {
122
119
  }
123
120
  catch {
124
121
  res.statusCode = 404;
125
- setHeaders(res);
126
122
  res.end("Not found");
127
123
  }
128
124
  };
@@ -144,10 +140,6 @@ function popcorn(options) {
144
140
  },
145
141
  ],
146
142
  },
147
- // COOP/COEP must be on every response (SharedArrayBuffer/pthreads),
148
- // including the worker/beam files Vite serves from the package itself.
149
- server: { headers: CORS_HEADERS },
150
- preview: { headers: CORS_HEADERS },
151
143
  };
152
144
  },
153
145
  configResolved(config) {
@@ -155,8 +147,9 @@ function popcorn(options) {
155
147
  outDir = resolve(config.root, config.build.outDir);
156
148
  assetsDir = config.build.assetsDir;
157
149
  },
158
- configureServer(server) {
150
+ async configureServer(server) {
159
151
  const rootDir = resolve(options.rootDir);
152
+ server.httpServer?.prependListener("request", setIsolationHeaders);
160
153
  server.watcher.add(rootDir);
161
154
  server.watcher.on("all", (_event, file) => {
162
155
  if (isUnder(rootDir, file))
@@ -164,10 +157,14 @@ function popcorn(options) {
164
157
  });
165
158
  server.middlewares.use(serve);
166
159
  server.httpServer?.once("close", cleanup);
160
+ await ensurePrepared();
161
+ dirty = false;
167
162
  },
168
- configurePreviewServer(server) {
163
+ async configurePreviewServer(server) {
164
+ server.httpServer?.prependListener("request", setIsolationHeaders);
169
165
  server.middlewares.use(serve);
170
166
  server.httpServer?.once("close", cleanup);
167
+ await ensurePrepared();
171
168
  },
172
169
  async closeBundle() {
173
170
  assert(outDir !== undefined, "outDir was not resolved");
@@ -187,8 +184,8 @@ function popcorn(options) {
187
184
  function isCompressible(path) {
188
185
  return path.endsWith(".tar") || path.endsWith(".wasm");
189
186
  }
190
- function selectEncoding(header, useBrotli) {
191
- if (useBrotli && header?.includes("br")) {
187
+ function selectEncoding(header) {
188
+ if (header?.includes("br")) {
192
189
  return { name: "br", suffix: ".br" };
193
190
  }
194
191
  if (header?.includes("gzip"))
@@ -199,8 +196,8 @@ function isUnder(dir, file) {
199
196
  const rel = relative(dir, file);
200
197
  return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
201
198
  }
202
- function setHeaders(res) {
203
- for (const [key, value] of Object.entries(CORS_HEADERS)) {
199
+ function setIsolationHeaders(_req, res) {
200
+ for (const [key, value] of Object.entries(crossOriginIsolationHeaders)) {
204
201
  res.setHeader(key, value);
205
202
  }
206
203
  }
@@ -224,4 +221,4 @@ function assert(ok, message) {
224
221
  }
225
222
  }
226
223
 
227
- export { popcorn };
224
+ export { crossOriginIsolationHeaders, popcorn };
package/dist/popcorn.d.ts CHANGED
@@ -123,6 +123,7 @@ export declare class Popcorn<Output extends TtyOutput = "text"> {
123
123
  private readonly eventHandlers;
124
124
  private readonly pendingSends;
125
125
  private readonly pendingCalls;
126
+ private readonly readyWaiters;
126
127
  private callSeq;
127
128
  private readonly trackedValues;
128
129
  private trackedKeySeq;
@@ -137,7 +138,7 @@ export declare class Popcorn<Output extends TtyOutput = "text"> {
137
138
  *
138
139
  * Call {@link boot} to start the VM.
139
140
  **/
140
- constructor(opts: PopcornOpts<Output>);
141
+ constructor(opts?: PopcornOpts<Output>);
141
142
  private spawnWorker;
142
143
  /**
143
144
  * Creates an instance and waits for {@link boot}.
@@ -146,7 +147,7 @@ export declare class Popcorn<Output extends TtyOutput = "text"> {
146
147
  *
147
148
  * @returns Ok tuple or `runtime:eval-unavailable` if the page blocks JavaScript evaluation.
148
149
  */
149
- static init<Output extends TtyOutput = "text">(opts: PopcornOpts<Output>): Promise<Result<Popcorn<Output>>>;
150
+ static init<Output extends TtyOutput = "text">(opts?: PopcornOpts<Output>): Promise<Result<Popcorn<Output>>>;
150
151
  /**
151
152
  * Starts the VM and waits for its bridge and entrypoint application.
152
153
  *
@@ -229,8 +230,11 @@ export declare class Popcorn<Output extends TtyOutput = "text"> {
229
230
  private castBridge;
230
231
  private nextCallId;
231
232
  private runJs;
233
+ private handleReadyRequest;
234
+ private flushReadyWaiters;
235
+ private sendReadyReply;
232
236
  private asRef;
233
- private sendRunJsReply;
237
+ private sendBridgeReply;
234
238
  private jsWithCurrentEnv;
235
239
  private reviveHandles;
236
240
  /** Maps pids and `TrackedValue`s during encoding, collecting handles into
Binary file
Binary file
package/dist/types.d.ts CHANGED
@@ -66,6 +66,9 @@ export type BeamEvent = {
66
66
  } | {
67
67
  type: "otp:run_js";
68
68
  payload: RunJsRequest;
69
+ } | {
70
+ type: "otp:await-ready";
71
+ payload: ReadyRequest;
69
72
  } | {
70
73
  type: "otp:tracked-value-delete";
71
74
  payload: number;
@@ -76,6 +79,9 @@ export type RunJsRequest = {
76
79
  replyTo: Uint8Array;
77
80
  return: "value" | "ref";
78
81
  };
82
+ export type ReadyRequest = {
83
+ replyTo: Uint8Array;
84
+ };
79
85
  /**
80
86
  * VM shutdown notification.
81
87
  *
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++) {
@@ -743,7 +804,7 @@ self.onmessage = async (event) => {
743
804
  });
744
805
  break;
745
806
  }
746
- case "popcorn:run-js-reply": {
807
+ case "popcorn:bridge-reply": {
747
808
  // ignore the `send()` result, process could've died
748
809
  check(instance !== null);
749
810
  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.2",
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