beam.com 0.1.0-rc.1 → 0.1.0-rc.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.
package/README.md CHANGED
@@ -20,10 +20,10 @@ With `beam.com` you can:
20
20
  - **Use Elixir and its tools** from one file: `mix.com`, `iex.com`,
21
21
  `elixir.com`. A Phoenix app with SQLite and `phx.gen.auth` runs from
22
22
  its source, with no C compiler.
23
- - **Deploy to Cloudflare Workers, Deno Deploy or a web page**:
24
- `beam.com INPUT -o DIR --target wasm32` makes one directory that runs
25
- the same program on all three, with a second runtime: ERTS compiled to
26
- WebAssembly.
23
+ - **Run the same file on Cloudflare Workers, Deno Deploy or a web page**:
24
+ the `app.com` of `beam.com INPUT -o app.com` also runs there, with a
25
+ second runtime: ERTS compiled to WebAssembly, in the npm package
26
+ `beam.com`.
27
27
 
28
28
  The file has the compiler, `crypto` and `ssl` (with OpenSSL), SQLite, and
29
29
  a WebAssembly runtime (WAMR). It is about 50 MB.
@@ -118,29 +118,81 @@ cd hello && ../mix.com deps.get && ../mix.com ecto.migrate
118
118
  ../iex.com -S mix phx.server # http://localhost:4000
119
119
  ```
120
120
 
121
- Make Cloudflare Workers of a program. The same directory runs on Deno:
122
-
123
- ```sh
124
- beam.com examples/worker -o worker --target wasm32
125
- cd worker
126
- npx workerd serve worker.capnp # test on this computer (Workers)
127
- deno serve -A deno.js # or on Deno
128
- ```
129
-
130
121
  One file runs natively and in the WebAssembly runtime. Each executable of
131
- `-o` also has its edge part, of 40 to 180 KB (`--no-edge` leaves it out).
122
+ `-o` also has its edge part, of 40 to 180 KB (`--no-edge` leaves it out,
123
+ and `--target wasm32 -o FILE.com` writes only the edge part, with no native
124
+ program).
132
125
  A release directory of `mix release` is an input too:
133
126
 
134
127
  ```sh
135
128
  beam.com _build/prod/rel/my_app -o my_app.com # a Phoenix app
136
129
  PHX_SERVER=true ./my_app.com # natively
137
- cd worker && BEAM_APP=../my_app.com deno serve -A deno.js # the same file on Deno
138
130
  ```
139
131
 
140
- The npm package `beam.com` has the runtime for Node.js, and `npx
141
- beam.com` (see [npm](#npm)).
132
+ The npm package `beam.com` runs the same file on Cloudflare Workers, on
133
+ Deno Deploy, in a web page and in Node.js. See [Deploy to the
134
+ edge](#deploy-to-the-edge).
135
+
136
+ ## Deploy to the edge
137
+
138
+ A project deploys its `app.com` to Cloudflare Workers and to Deno Deploy
139
+ at each git push, with four small files and the npm package `beam.com`.
140
+ The files do not change from one app to another:
141
+
142
+ | File | What |
143
+ |---|---|
144
+ | `package.json` | The dev dependencies `beam.com` and `wrangler`, the script `build` (it makes `app.com` with `npx beam.com`), and the script `deploy` (`wrangler deploy`). |
145
+ | `worker.js` | The entry, the same on both hosts. |
146
+ | `wrangler.jsonc` | The name of the Worker, the Data rule for `*.com`, and for a stateful app the Durable Object. |
147
+ | `deno.json` | The `deploy` key of Deno Deploy: `npm install`, `npm run build`, and the entrypoint `worker.js`. |
148
+
149
+ The entry:
150
+
151
+ ```js
152
+ import app from './app.com' with { type: 'bytes' };
153
+ import { serve } from 'beam.com';
154
+ export { Beam } from 'beam.com'; // stateful only
155
+
156
+ const beam = serve(app);
157
+
158
+ export default {
159
+ fetch(request, env, ctx) {
160
+ return beam.fetch(request, env, ctx);
161
+ },
162
+ };
163
+ ```
164
+
165
+ With the Durable Object `Beam` and its binding, the app is stateful: one
166
+ VM serves all the requests, its timers run between requests, and its
167
+ SQLite storage keeps the database. With no binding, it is stateless:
168
+ each isolate runs its own VM. A Phoenix app needs no secret and no
169
+ variable: the VM makes `SECRET_KEY_BASE` one time and keeps it, and
170
+ `PHX_HOST` is the host of the first request.
171
+
172
+ Test on this computer:
173
+
174
+ ```sh
175
+ npm install && npm run build
176
+ npx wrangler dev # Cloudflare Workers, in workerd
177
+ npx deno serve -A worker.js # Deno
178
+ ```
179
+
180
+ [`examples/phoenix_demo`](examples/phoenix_demo) (stateful) and
181
+ [`examples/worker`](examples/worker) (stateless) are such projects, with
182
+ one-click buttons for both hosts. Copy one to start. See "Deploy at each
183
+ git push" in [`docs/WORKERS.md`](docs/WORKERS.md).
184
+
185
+ The program does not change. Its HTTP server (Bandit or Cowboy) listens
186
+ with `gen_tcp`, and all of OTP is there. Outgoing HTTP goes through
187
+ `fetch()` of the host, and other TCP through `connect()`. Give the build
188
+ `--cacerts FILE` for HTTPS: the runtime has no root certificates of its
189
+ own.
142
190
 
143
- See "One file, natively and at the edge" in [`docs/WORKERS.md`](docs/WORKERS.md).
191
+ A Worker makes a snapshot of its booted VM at its first boot, and the
192
+ next isolates restore it. For the shortest cold start, the build can
193
+ also make one, and a stateless Worker then restores its VM before the
194
+ first request: see "The snapshot of the build" in
195
+ [`docs/WORKERS.md`](docs/WORKERS.md).
144
196
 
145
197
  ## Publish on GitHub Pages
146
198
 
@@ -166,14 +218,14 @@ jobs:
166
218
  ```
167
219
 
168
220
  Then turn on Pages one time: Settings, Pages, Source "GitHub Actions". The
169
- `GITHUB_TOKEN` cannot do this step. Until the first release (`v0.1.0`),
170
- use `@main` in place of `@v0.1.0`: the workflow then uses the `edge`
171
- build of `beam.com`.
221
+ `GITHUB_TOKEN` cannot do this step. Until the release `v0.1.0`, use the
222
+ release candidate `@v0.1.0-rc.3` in place of `@v0.1.0`. With `@main`, the
223
+ workflow uses the `edge` build of `beam.com`.
172
224
 
173
225
  The workflow ([`pages-app.yml`](.github/workflows/pages-app.yml)) builds
174
- the app with `beam.com --target wasm32`, and publishes the static site
175
- `DIR/page/`. A Mix project with Phoenix becomes a release first
176
- (`mix.com release`). Its inputs:
226
+ the app with `beam.com INPUT -o DIR --page`, and publishes the static site
227
+ `DIR`: the native file `app.com` and the page that runs it. A Mix project
228
+ with Phoenix becomes a release first (`mix.com release`). Its inputs:
177
229
 
178
230
  | Input | Default | What |
179
231
  |---|---|---|
@@ -201,8 +253,8 @@ NIFs of the WebAssembly runtime. The limits:
201
253
  site closes, the data goes, and the next visit starts from the snapshot
202
254
  of the boot.
203
255
  - No outgoing TCP: a connection of Erlang gets `econnrefused`.
204
- - The first visit downloads `beam.wasm` (about 6.5 MB) and `release.bin`
205
- (3.5 to 14 MB for a Phoenix app).
256
+ - The first visit downloads `beam.wasm` (about 6.5 MB) and the parts of
257
+ `app.com` that the release needs (about 13 MB for a Phoenix app).
206
258
  - The browser needs JSPI: Chrome and Edge 137 or later, or Firefox 153
207
259
  or later.
208
260
 
@@ -230,6 +282,13 @@ release, and checks its SHA-256 against the value that the package
230
282
  holds. Then the file stays in the cache (`~/.cache/beam.com/npm/`). A
231
283
  host that only uses the runtime never downloads it.
232
284
 
285
+ Each GitHub release from 0.1.0-rc.2 also holds the package, the same
286
+ file as on npm. npm installs it with no registry:
287
+
288
+ ```sh
289
+ npm install https://github.com/sntran/BEAM.com/releases/download/v0.1.0-rc.3/beam.com-0.1.0-rc.3.tgz
290
+ ```
291
+
233
292
  - `BEAM_COM`: the path of a `beam.com` to use. Then nothing is
234
293
  downloaded.
235
294
  - `BEAM_COM_DOWNLOAD`: the URL of a directory with the file `beam.com`,
@@ -268,34 +327,9 @@ The other modules of the package:
268
327
  | `beam.com/worker` | The runtime (`worker.js` of Cloudflare Workers). |
269
328
  | `beam.com/beam.wasm` | The VM: ERTS built for WebAssembly. |
270
329
 
271
- The same `app.com` runs on Cloudflare Workers, on Deno Deploy and in a
272
- web page, with the runtime of the package. One entry runs on both hosts:
273
-
274
- ```js
275
- import app from './app.com' with { type: 'bytes' };
276
- import { serve } from 'beam.com';
277
- export { Beam } from 'beam.com'; // stateful only
278
-
279
- const beam = serve(app);
280
-
281
- export default {
282
- fetch(request, env, ctx) {
283
- return beam.fetch(request, env, ctx);
284
- },
285
- };
286
- ```
287
-
288
- With the Durable Object `Beam` and its binding, the app is stateful: one
289
- VM serves all the requests, its timers run between requests, and its
290
- SQLite storage keeps the database. With no binding, it is stateless:
291
- each isolate runs its own VM. A project deploys at each git push: the
292
- build step of the host makes `app.com` (`npx beam.com`), and the host
293
- runs it. A Phoenix app needs no secret and no variable: the VM
294
- makes `SECRET_KEY_BASE` one time and keeps it, and `PHX_HOST` is the
295
- host of the first request. [`examples/phoenix_demo`](examples/phoenix_demo)
296
- (stateful) and [`examples/worker`](examples/worker) (stateless) are such
297
- projects, with one-click buttons for both hosts. See "Deploy at
298
- each git push" in [`docs/WORKERS.md`](docs/WORKERS.md).
330
+ The same `app.com` runs on Cloudflare Workers and Deno Deploy with
331
+ `serve(app)` (see [Deploy to the edge](#deploy-to-the-edge)), and in a
332
+ web page with `main.js` of the package.
299
333
 
300
334
  The release job of CI makes the generated part of the package
301
335
  (`runtime/`, with `scripts/npm.sh`) and publishes it with each tag `v*`.
@@ -313,7 +347,7 @@ the Erlang shell in your browser is at
313
347
  |---|---|
314
348
  | [`docs/PROGRAMS.md`](docs/PROGRAMS.md) | Run and build programs: the inputs, Hex packages, command line programs, native files (`--target`), distributed Erlang, a release in the zip, debugging. |
315
349
  | [`docs/ELIXIR.md`](docs/ELIXIR.md) | Elixir programs, the tools (`mix`, `iex`, `elixir`), Phoenix from source, the file watcher. |
316
- | [`docs/WORKERS.md`](docs/WORKERS.md) | Cloudflare Workers, Deno Deploy and web pages (`--target wasm32`): Durable Objects, Ecto SQLite, snapshots, tenants, limits. |
350
+ | [`docs/WORKERS.md`](docs/WORKERS.md) | Cloudflare Workers, Deno Deploy and web pages: `app.com` with the npm package, Durable Objects, Ecto SQLite, HTTP through `fetch()`, snapshots, tenants, static sites (`--page`), and limits. |
317
351
  | [`docs/NOTEBOOKS.md`](docs/NOTEBOOKS.md) | The documentation as notebooks that run in your browser: a tour of beam.com, the WebAssembly VM, the anatomy of the file, WebAssembly programs, hosts and storage, networking, and building programs. |
318
352
  | [`docs/LIBRARIES.md`](docs/LIBRARIES.md) | Crypto and TLS, SQLite, and WebAssembly in Erlang code. |
319
353
  | [`docs/SANDBOX.md`](docs/SANDBOX.md) | `--allow-read`, `--allow-write`, `--allow-net`, `--allow-run`: a program that gives up what it does not need. |
package/js/beam.com.mjs CHANGED
@@ -1,11 +1,86 @@
1
1
  #!/usr/bin/env node
2
2
  // npx beam.com ARGS: beam.com of the version of this package, with ARGS
3
3
  // (download.mjs). For example: npx beam.com app.erl -o app.com
4
+ //
5
+ // npx beam.com --snapshot APP.com [-o FILE] [--full] [--warm PATH]...
6
+ // [--env NAME=VALUE]... [--no-compress]: the snapshot of the build of
7
+ // APP.com for serve(app, { snapshot }) of a Worker (snapshot() of
8
+ // node.mjs), in FILE (default APP.snapshot), with its pages in gzip (not
9
+ // with --full: the global scope of a Worker cannot inflate). Give
10
+ // the BEAM_ERL_FLAGS of the Worker with --env, or in the vars of
11
+ // wrangler.jsonc of this directory: a Worker with other flags boots in
12
+ // place of the snapshot. It runs here, with no download of beam.com.
4
13
  import { spawn } from 'node:child_process';
5
- import { readFileSync } from 'node:fs';
14
+ import { closeSync, openSync, readFileSync, readSync, statSync, writeFileSync } from 'node:fs';
6
15
  import { constants } from 'node:os';
7
16
  import { command, ensure } from './download.mjs';
8
17
 
18
+ if (process.argv[2] === '--snapshot') {
19
+ try {
20
+ await snapshotCommand(process.argv.slice(3));
21
+ process.exit(0);
22
+ } catch (e) {
23
+ console.error(`beam.com: ${e.message}`);
24
+ process.exit(1);
25
+ }
26
+ }
27
+
28
+ async function snapshotCommand(args) {
29
+ const opts = { env: {}, warm: [], kind: 'boot-point' };
30
+ let app = null, out = null;
31
+ while (args.length) {
32
+ const a = args.shift();
33
+ const value = () => {
34
+ if (!args.length) throw new Error(`${a} needs a value`);
35
+ return args.shift();
36
+ };
37
+ if (a === '-o') out = value();
38
+ else if (a === '--full') opts.kind = 'full';
39
+ else if (a === '--no-compress') opts.compress = false;
40
+ else if (a === '--warm') opts.warm.push(value());
41
+ else if (a === '--env') {
42
+ const v = value(), at = v.indexOf('=');
43
+ if (at < 1) throw new Error(`--env ${v}: not NAME=VALUE`);
44
+ opts.env[v.slice(0, at)] = v.slice(at + 1);
45
+ } else if (!a.startsWith('-') && !app) app = a;
46
+ else throw new Error(`--snapshot: unknown argument ${a}`);
47
+ }
48
+ if (!app) throw new Error('usage: npx beam.com --snapshot APP.com [-o FILE] [--full] [--warm PATH]... [--env NAME=VALUE]... [--no-compress]');
49
+ if (opts.warm.length && opts.kind !== 'full') throw new Error('--warm is for a full snapshot (--full)');
50
+ out ??= app.replace(/\.com$/, '') + '.snapshot';
51
+ // The flags of the Worker, when --env does not give them: the vars of
52
+ // its wrangler.jsonc. A Worker with other flags boots in place of the
53
+ // snapshot.
54
+ let source = '--env';
55
+ if (opts.env.BEAM_ERL_FLAGS === undefined) {
56
+ const { wranglerVars } = await import('./wrangler.mjs');
57
+ const w = wranglerVars('.');
58
+ if (typeof w?.vars.BEAM_ERL_FLAGS === 'string') {
59
+ opts.env.BEAM_ERL_FLAGS = w.vars.BEAM_ERL_FLAGS;
60
+ source = w.file;
61
+ }
62
+ }
63
+ const { snapshot } = await import('./node.mjs');
64
+ const t0 = performance.now();
65
+ const bytes = await snapshot(app, opts);
66
+ writeFileSync(out, bytes);
67
+ const flags = (opts.env.BEAM_ERL_FLAGS ?? '').split(/\s+/).filter(Boolean).join(' ');
68
+ console.error(`beam.com: wrote ${out} (${(bytes.length / 1048576).toFixed(1)} MB, ${opts.kind}, `
69
+ + `BEAM_ERL_FLAGS "${flags}"${flags ? ` of ${source}` : ''}, in ${Math.round(performance.now() - t0)} ms)`);
70
+ if (!flags) console.error('beam.com: a Worker with BEAM_ERL_FLAGS boots in place of this snapshot: give its flags with --env BEAM_ERL_FLAGS=..., or in the vars of wrangler.jsonc');
71
+ // A Worker can be at most 64 MiB, with all its modules (gzip does not
72
+ // count): the app file and the snapshot are most of it.
73
+ const total = statSync(app).size + bytes.length;
74
+ if (total > 56 * 1048576) {
75
+ // An APE file starts with "MZ"; a file of --target wasm32 is a zip.
76
+ const head = Buffer.alloc(2), fd = openSync(app, 'r');
77
+ try { readSync(fd, head, 0, 2, 0); } finally { closeSync(fd); }
78
+ const native = head.toString() === 'MZ';
79
+ console.error(`beam.com: warning: ${app} and ${out} are ${(total / 1048576).toFixed(1)} MiB, and a Worker can be at most 64 MiB`
80
+ + (native ? `. Build the app with -o FILE.com --target wasm32: a file with no native program, about 25 MB smaller` : ''));
81
+ }
82
+ }
83
+
9
84
  let release = null;
10
85
  try {
11
86
  release = JSON.parse(readFileSync(new URL('../runtime/release.json', import.meta.url), 'utf8'));
package/js/download.mjs CHANGED
@@ -14,6 +14,9 @@ import path from 'node:path';
14
14
 
15
15
  export const REPOSITORY = 'sntran/BEAM.com';
16
16
 
17
+ // The time with no bytes that stops a download (ms).
18
+ const IDLE = 60000;
19
+
17
20
  // The cache directory of beam.com (beam_com:cache_dir/0).
18
21
  export function cacheDir(env = process.env) {
19
22
  if (env.BEAM_COM_CACHE) return env.BEAM_COM_CACHE;
@@ -37,7 +40,8 @@ export function downloadUrl({ version }, env = process.env) {
37
40
 
38
41
  // The path of a beam.com that runs. The file in the cache was checked when
39
42
  // it was downloaded: a file is in the cache only after the check.
40
- export async function ensure(release, { env = process.env, platform = process.platform, fetch = globalThis.fetch, log = () => {} } = {}) {
43
+ // idle: the download stops when no bytes come in this time (ms).
44
+ export async function ensure(release, { env = process.env, platform = process.platform, fetch = globalThis.fetch, log = () => {}, idle = IDLE } = {}) {
41
45
  if (env.BEAM_COM) return env.BEAM_COM;
42
46
  if (!release?.version || !/^[0-9a-f]{64}$/.test(release?.sha256 ?? '')) {
43
47
  throw new Error('this package has no release of beam.com (runtime/release.json): set BEAM_COM to the path of a beam.com');
@@ -46,16 +50,36 @@ export async function ensure(release, { env = process.env, platform = process.pl
46
50
  if (fs.existsSync(file)) return file;
47
51
  const url = downloadUrl(release, env);
48
52
  log(`beam.com: downloading ${url}`);
49
- const response = await fetch(url);
50
- if (!response.ok) throw new Error(`beam.com: ${url}: status ${response.status}`);
53
+ const ac = new AbortController();
54
+ let timer;
55
+ const wait = () => {
56
+ clearTimeout(timer);
57
+ timer = setTimeout(() => ac.abort(new Error(`beam.com: ${url}: no bytes in ${idle / 1000} s`)), idle);
58
+ };
59
+ wait();
60
+ let response;
61
+ try {
62
+ response = await fetch(url, { signal: ac.signal });
63
+ } catch (e) {
64
+ clearTimeout(timer);
65
+ throw ac.signal.aborted ? ac.signal.reason : e;
66
+ }
67
+ if (!response.ok) {
68
+ clearTimeout(timer);
69
+ throw new Error(`beam.com: ${url}: status ${response.status}`);
70
+ }
51
71
  fs.mkdirSync(path.dirname(file), { recursive: true });
52
72
  const part = `${file}.${process.pid}.part`;
53
73
  const out = fs.createWriteStream(part, { mode: 0o755 });
74
+ // An error of the file (a full disk): no drain comes after it.
75
+ const failed = new Promise((_, reject) => out.once('error', reject));
76
+ failed.catch(() => {});
54
77
  const hash = createHash('sha256');
55
78
  try {
56
79
  for await (const chunk of response.body) {
80
+ wait();
57
81
  hash.update(chunk);
58
- if (!out.write(chunk)) await new Promise((r) => out.once('drain', r));
82
+ if (!out.write(chunk)) await Promise.race([new Promise((r) => out.once('drain', r)), failed]);
59
83
  }
60
84
  await new Promise((resolve, reject) => out.end((e) => (e ? reject(e) : resolve())));
61
85
  const got = hash.digest('hex');
@@ -64,7 +88,10 @@ export async function ensure(release, { env = process.env, platform = process.pl
64
88
  }
65
89
  fs.chmodSync(part, 0o755);
66
90
  fs.renameSync(part, file);
91
+ } catch (e) {
92
+ throw ac.signal.aborted ? ac.signal.reason : e;
67
93
  } finally {
94
+ clearTimeout(timer);
68
95
  out.destroy();
69
96
  fs.rmSync(part, { force: true });
70
97
  }
package/js/node.mjs CHANGED
@@ -47,8 +47,9 @@ export function release(app) {
47
47
  return readApp(app, appRelease);
48
48
  }
49
49
 
50
- // The release of app with get (appRelease, or appFiles: no copy).
51
- async function readApp(app, get) {
50
+ // The release of app with get (appRelease, or appFiles: no copy), and the
51
+ // options of get.
52
+ async function readApp(app, get, options = {}) {
52
53
  const file = await fs.open(app);
53
54
  try {
54
55
  const { size } = await file.stat();
@@ -57,7 +58,7 @@ async function readApp(app, get) {
57
58
  const { bytesRead } = await file.read(b, 0, n, at);
58
59
  return b.subarray(0, bytesRead);
59
60
  };
60
- return await get(read, size, { runtime: runtimeId });
61
+ return await get(read, size, { ...options, runtime: runtimeId });
61
62
  } finally {
62
63
  await file.close();
63
64
  }
@@ -66,10 +67,65 @@ async function readApp(app, get) {
66
67
  // The VM of an app.com, when it is ready. env: the variables of the VM.
67
68
  // There is no cache for snapshots in Node.js, so BEAM_SNAPSHOT is "off".
68
69
  // The VM keeps its state between requests (plain: false), as in Deno.
69
- export async function boot(app, { env = {} } = {}) {
70
- const bin = await readApp(app, appFiles);
70
+ // statics: false keeps the static files of a Phoenix app in the VM, as
71
+ // serve(app) of a Worker does.
72
+ export async function boot(app, { env = {}, statics = true } = {}) {
73
+ const bin = await readApp(app, appFiles, { statics });
71
74
  const { Vm } = await import(new URL('worker.js', runtimeUrl).href);
72
75
  const vm = new Vm({ BEAM_SNAPSHOT: 'off', ...env }, { release: bin, plain: false });
73
76
  await vm.ready;
74
77
  return vm;
75
78
  }
79
+
80
+ // A snapshot of the build of an app.com, for serve(app, { snapshot }) of a
81
+ // Worker (npx beam.com --snapshot). kind:
82
+ // - 'boot-point' (the default): the VM loaded the modules of its boot, and
83
+ // the program did not start. It holds no variable of the app: each VM
84
+ // starts the program with the variables of its host.
85
+ // - 'full': the VM after its boot and the warm-up requests (warm, paths).
86
+ // It holds the variables of env and the state of the program. A Phoenix
87
+ // app must give SECRET_KEY_BASE and PHX_HOST, and an app with Ecto
88
+ // SQLite cannot use it (its boot changes the database).
89
+ // The header of the snapshot keeps BEAM_ERL_FLAGS of env: a Worker with
90
+ // other flags boots in place of the snapshot. compress gives the pages in
91
+ // gzip (BEAMSNZ1, about a quarter of the size). It is the default at the
92
+ // boot point. A full snapshot has no gzip by default: a stateless Worker
93
+ // restores it in the global scope, which cannot inflate.
94
+ // Gives the bytes of the snapshot.
95
+ export async function snapshot(app, { env = {}, warm = [], kind = 'boot-point', compress = kind !== 'full' } = {}) {
96
+ if (kind !== 'boot-point' && kind !== 'full') throw new Error(`the kind of snapshot is boot-point or full, not ${kind}`);
97
+ const bin = await readApp(app, appFiles);
98
+ const { Vm, releaseMeta, packSnapshot } = await import(new URL('worker.js', runtimeUrl).href);
99
+ const meta = releaseMeta(bin);
100
+ if (kind === 'full') {
101
+ if (meta.sql) throw new Error(`${app}: the app has Ecto SQLite: use a snapshot at the boot point`);
102
+ const missing = ['SECRET_KEY_BASE', 'PHX_HOST'].filter((k) => meta.env?.PHX_SERVER === 'true' && !env[k]);
103
+ if (missing.length) throw new Error(`${app}: a full snapshot of a Phoenix app needs ${missing.join(' and ')}`);
104
+ }
105
+ const vm = new Vm({ BEAM_SNAPSHOT: 'off', ...env }, { release: bin, plain: false, capture: kind });
106
+ let bytes;
107
+ if (kind === 'boot-point') {
108
+ // wasm_host says "ready", and then it stops at the boot point. A boot
109
+ // that fails rejects ready.
110
+ let timer;
111
+ const late = new Promise((resolve, reject) => {
112
+ timer = setTimeout(() => reject(new Error(`${app}: the VM did not stop at the boot point in 120 s`)), 120000);
113
+ });
114
+ try {
115
+ bytes = await Promise.race([vm.captured, vm.ready.then(() => vm.captured), late]);
116
+ } finally {
117
+ clearTimeout(timer);
118
+ }
119
+ } else {
120
+ await vm.ready;
121
+ await vm.listening(Number(env.PORT ?? meta.env?.PORT ?? 4000));
122
+ for (const path of warm) {
123
+ const r = await vm.fetch(new Request(new URL(path, 'http://localhost')));
124
+ await r.arrayBuffer();
125
+ if (r.status >= 500) throw new Error(`${app}: the warm-up request ${path} gave ${r.status}`);
126
+ }
127
+ bytes = await vm.snapshot(false);
128
+ }
129
+ if (!(bytes instanceof Uint8Array)) throw new Error(`${app}: no snapshot (${bytes}): the VM was not quiet`);
130
+ return compress ? packSnapshot(bytes) : bytes;
131
+ }
@@ -0,0 +1,47 @@
1
+ // The vars of the Wrangler file of a project (wrangler.jsonc, else
2
+ // wrangler.json), for npx beam.com --snapshot: the snapshot gets the
3
+ // BEAM_ERL_FLAGS of the Worker. Wrangler reads JSONC: comments (// and
4
+ // /* */) and a comma before } or ]. wrangler.toml is not read.
5
+ import { readFileSync } from 'node:fs';
6
+ import { join } from 'node:path';
7
+
8
+ // {file, vars} of the first Wrangler file of dir, or null.
9
+ export function wranglerVars(dir = '.') {
10
+ for (const name of ['wrangler.jsonc', 'wrangler.json']) {
11
+ let text;
12
+ try {
13
+ text = readFileSync(join(dir, name), 'utf8');
14
+ } catch {
15
+ continue;
16
+ }
17
+ const vars = jsonc(text).vars;
18
+ return { file: name, vars: vars && typeof vars === 'object' ? vars : {} };
19
+ }
20
+ return null;
21
+ }
22
+
23
+ // The value of a JSONC text.
24
+ export function jsonc(text) {
25
+ let out = '';
26
+ for (let i = 0; i < text.length; i++) {
27
+ const c = text[i];
28
+ if (c === '"') {
29
+ // A string, with its escapes, as it is.
30
+ let j = i + 1;
31
+ while (j < text.length && text[j] !== '"') j += text[j] === '\\' ? 2 : 1;
32
+ out += text.slice(i, j + 1);
33
+ i = j;
34
+ } else if (c === '/' && text[i + 1] === '/') {
35
+ while (i < text.length && text[i] !== '\n') i++;
36
+ out += '\n';
37
+ } else if (c === '/' && text[i + 1] === '*') {
38
+ const end = text.indexOf('*/', i + 2);
39
+ i = end < 0 ? text.length : end + 1;
40
+ out += ' ';
41
+ } else {
42
+ out += c;
43
+ }
44
+ }
45
+ // A comma before } or ] (outside the strings, which have no newline).
46
+ return JSON.parse(out.replace(/,(\s*[}\]])/g, '$1'));
47
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "beam.com",
3
- "version": "0.1.0-rc.1",
3
+ "version": "0.1.0-rc.3",
4
4
  "description": "Erlang/OTP and Elixir in one executable file: npx beam.com, and the WebAssembly runtime for a native app.com",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -111,7 +111,8 @@ function gzipOf(raw, e) {
111
111
  }
112
112
 
113
113
  // The data of the entry e, in span (the bytes of the file from offset base).
114
- async function entryData(span, base, e) {
114
+ // unzip: the inflate of a deflated entry.
115
+ async function entryData(span, base, e, unzip = inflate) {
115
116
  const h = e.offset - base;
116
117
  if (h < 0 || h + 30 > span.length || u32(span, h) !== LOCAL) fail(`${e.name}: no local header`);
117
118
  const start = h + 30 + u16(span, h + 26) + u16(span, h + 28);
@@ -120,7 +121,7 @@ async function entryData(span, base, e) {
120
121
  if (e.method === DEFLATED && e.name.endsWith('.beam')) return gzipOf(raw, e);
121
122
  let d;
122
123
  if (e.method === STORED) d = raw;
123
- else if (e.method === DEFLATED) d = await inflate(raw);
124
+ else if (e.method === DEFLATED) d = await unzip(raw);
124
125
  else fail(`${e.name}: the compression method ${e.method} is not supported`);
125
126
  if (d.length !== e.size || crc32(d) !== e.crc) fail(`${e.name}: the CRC-32 does not match`);
126
127
  return d;
@@ -134,7 +135,10 @@ export async function appRelease(read, size, options = {}) {
134
135
  return pack(meta, files);
135
136
  }
136
137
 
137
- export async function appFiles(read, size, { runtime, statics: split = true } = {}) {
138
+ // options.inflate: the inflate of a deflated entry, (raw) => bytes. The
139
+ // default uses DecompressionStream, which the global scope of a Worker
140
+ // cannot use (cloudflare/release.js gives inflateRawSync of node:zlib).
141
+ export async function appFiles(read, size, { runtime, statics: split = true, inflate: unzip = inflate } = {}) {
138
142
  const { entries, cdAt } = await zipEntries(read, size);
139
143
  const want = entries.filter((e) => inRelease(e.name) && !e.name.endsWith('/'));
140
144
  const json = want.find((e) => e.name === `${EDGE}.release.json`);
@@ -143,7 +147,7 @@ export async function appFiles(read, size, { runtime, statics: split = true } =
143
147
  const lo = Math.min(...want.map((e) => e.offset));
144
148
  const span = await read(lo, cdAt - lo);
145
149
  if (span.length !== cdAt - lo) fail('a short read of the release');
146
- const meta = await entryData(span, lo, json);
150
+ const meta = await entryData(span, lo, json, unzip);
147
151
  const release = JSON.parse(text.decode(meta));
148
152
  if (runtime && release.runtime !== runtime) {
149
153
  fail(`it was built for the runtime ${String(release.runtime).slice(0, 12)}, and this runtime is ${runtime.slice(0, 12)}: `
@@ -164,9 +168,9 @@ export async function appFiles(read, size, { runtime, statics: split = true } =
164
168
  statics.set(e.name.slice(prefix.length - 1), staticEntry(span, lo, e));
165
169
  continue;
166
170
  }
167
- files.set(e.name, await entryData(span, lo, e));
171
+ files.set(e.name, await entryData(span, lo, e, unzip));
168
172
  }
169
- for (const e of want) if (e.name.startsWith(EDGE) && e !== json) files.set(e.name.slice(EDGE.length), await entryData(span, lo, e));
173
+ for (const e of want) if (e.name.startsWith(EDGE) && e !== json) files.set(e.name.slice(EDGE.length), await entryData(span, lo, e, unzip));
170
174
  const list = [...files];
171
175
  return {
172
176
  meta, files: list, statics,