beam.com 0.1.0-rc.1 → 0.1.0-rc.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.
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,15 +118,6 @@ 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
122
  `-o` also has its edge part, of 40 to 180 KB (`--no-edge` leaves it out).
132
123
  A release directory of `mix release` is an input too:
@@ -134,13 +125,72 @@ A release directory of `mix release` is an input too:
134
125
  ```sh
135
126
  beam.com _build/prod/rel/my_app -o my_app.com # a Phoenix app
136
127
  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
128
  ```
139
129
 
140
- The npm package `beam.com` has the runtime for Node.js, and `npx
141
- beam.com` (see [npm](#npm)).
130
+ The npm package `beam.com` runs the same file on Cloudflare Workers, on
131
+ Deno Deploy, in a web page and in Node.js. See [Deploy to the
132
+ edge](#deploy-to-the-edge).
133
+
134
+ ## Deploy to the edge
135
+
136
+ A project deploys its `app.com` to Cloudflare Workers and to Deno Deploy
137
+ at each git push, with four small files and the npm package `beam.com`.
138
+ The files do not change from one app to another:
139
+
140
+ | File | What |
141
+ |---|---|
142
+ | `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`). |
143
+ | `worker.js` | The entry, the same on both hosts. |
144
+ | `wrangler.jsonc` | The name of the Worker, the Data rule for `*.com`, and for a stateful app the Durable Object. |
145
+ | `deno.json` | The `deploy` key of Deno Deploy: `npm install`, `npm run build`, and the entrypoint `worker.js`. |
146
+
147
+ The entry:
148
+
149
+ ```js
150
+ import app from './app.com' with { type: 'bytes' };
151
+ import { serve } from 'beam.com';
152
+ export { Beam } from 'beam.com'; // stateful only
153
+
154
+ const beam = serve(app);
155
+
156
+ export default {
157
+ fetch(request, env, ctx) {
158
+ return beam.fetch(request, env, ctx);
159
+ },
160
+ };
161
+ ```
162
+
163
+ With the Durable Object `Beam` and its binding, the app is stateful: one
164
+ VM serves all the requests, its timers run between requests, and its
165
+ SQLite storage keeps the database. With no binding, it is stateless:
166
+ each isolate runs its own VM. A Phoenix app needs no secret and no
167
+ variable: the VM makes `SECRET_KEY_BASE` one time and keeps it, and
168
+ `PHX_HOST` is the host of the first request.
169
+
170
+ Test on this computer:
171
+
172
+ ```sh
173
+ npm install && npm run build
174
+ npx wrangler dev # Cloudflare Workers, in workerd
175
+ npx deno serve -A worker.js # Deno
176
+ ```
177
+
178
+ [`examples/phoenix_demo`](examples/phoenix_demo) (stateful) and
179
+ [`examples/worker`](examples/worker) (stateless) are such projects, with
180
+ one-click buttons for both hosts. Copy one to start. See "Deploy at each
181
+ git push" in [`docs/WORKERS.md`](docs/WORKERS.md).
182
+
183
+ The program does not change. Its HTTP server (Bandit or Cowboy) listens
184
+ with `gen_tcp`, and all of OTP is there. Outgoing HTTP goes through
185
+ `fetch()` of the host, and other TCP through `connect()`. Give the build
186
+ `--cacerts FILE` for HTTPS: the runtime has no root certificates of its
187
+ own.
142
188
 
143
- See "One file, natively and at the edge" in [`docs/WORKERS.md`](docs/WORKERS.md).
189
+ A Worker makes a snapshot of its booted VM at its first boot, and the
190
+ next isolates restore it. For the shortest cold start, the build can
191
+ also make one, and a stateless Worker then restores its VM before the
192
+ first request: see "The snapshot of the build" in
193
+ [`docs/WORKERS.md`](docs/WORKERS.md).
144
194
 
145
195
  ## Publish on GitHub Pages
146
196
 
@@ -166,14 +216,14 @@ jobs:
166
216
  ```
167
217
 
168
218
  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`.
219
+ `GITHUB_TOKEN` cannot do this step. Until the release `v0.1.0`, use the
220
+ release candidate `@v0.1.0-rc.2` in place of `@v0.1.0`. With `@main`, the
221
+ workflow uses the `edge` build of `beam.com`.
172
222
 
173
223
  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:
224
+ the app with `beam.com INPUT -o DIR --page`, and publishes the static site
225
+ `DIR`: the native file `app.com` and the page that runs it. A Mix project
226
+ with Phoenix becomes a release first (`mix.com release`). Its inputs:
177
227
 
178
228
  | Input | Default | What |
179
229
  |---|---|---|
@@ -201,8 +251,8 @@ NIFs of the WebAssembly runtime. The limits:
201
251
  site closes, the data goes, and the next visit starts from the snapshot
202
252
  of the boot.
203
253
  - 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).
254
+ - The first visit downloads `beam.wasm` (about 6.5 MB) and the parts of
255
+ `app.com` that the release needs (about 13 MB for a Phoenix app).
206
256
  - The browser needs JSPI: Chrome and Edge 137 or later, or Firefox 153
207
257
  or later.
208
258
 
@@ -230,6 +280,13 @@ release, and checks its SHA-256 against the value that the package
230
280
  holds. Then the file stays in the cache (`~/.cache/beam.com/npm/`). A
231
281
  host that only uses the runtime never downloads it.
232
282
 
283
+ Each GitHub release from 0.1.0-rc.2 also holds the package, the same
284
+ file as on npm. npm installs it with no registry:
285
+
286
+ ```sh
287
+ npm install https://github.com/sntran/BEAM.com/releases/download/v0.1.0-rc.2/beam.com-0.1.0-rc.2.tgz
288
+ ```
289
+
233
290
  - `BEAM_COM`: the path of a `beam.com` to use. Then nothing is
234
291
  downloaded.
235
292
  - `BEAM_COM_DOWNLOAD`: the URL of a directory with the file `beam.com`,
@@ -268,34 +325,9 @@ The other modules of the package:
268
325
  | `beam.com/worker` | The runtime (`worker.js` of Cloudflare Workers). |
269
326
  | `beam.com/beam.wasm` | The VM: ERTS built for WebAssembly. |
270
327
 
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).
328
+ The same `app.com` runs on Cloudflare Workers and Deno Deploy with
329
+ `serve(app)` (see [Deploy to the edge](#deploy-to-the-edge)), and in a
330
+ web page with `main.js` of the package.
299
331
 
300
332
  The release job of CI makes the generated part of the package
301
333
  (`runtime/`, with `scripts/npm.sh`) and publishes it with each tag `v*`.
@@ -313,7 +345,7 @@ the Erlang shell in your browser is at
313
345
  |---|---|
314
346
  | [`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
347
  | [`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. |
348
+ | [`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
349
  | [`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
350
  | [`docs/LIBRARIES.md`](docs/LIBRARIES.md) | Crypto and TLS, SQLite, and WebAssembly in Erlang code. |
319
351
  | [`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,55 @@
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]...: the snapshot of the build of APP.com for
7
+ // serve(app, { snapshot }) of a Worker (snapshot() of node.mjs), in FILE
8
+ // (default APP.snapshot). It runs here, with no download of beam.com.
4
9
  import { spawn } from 'node:child_process';
5
- import { readFileSync } from 'node:fs';
10
+ import { readFileSync, writeFileSync } from 'node:fs';
6
11
  import { constants } from 'node:os';
7
12
  import { command, ensure } from './download.mjs';
8
13
 
14
+ if (process.argv[2] === '--snapshot') {
15
+ try {
16
+ await snapshotCommand(process.argv.slice(3));
17
+ process.exit(0);
18
+ } catch (e) {
19
+ console.error(`beam.com: ${e.message}`);
20
+ process.exit(1);
21
+ }
22
+ }
23
+
24
+ async function snapshotCommand(args) {
25
+ const opts = { env: {}, warm: [], kind: 'boot-point' };
26
+ let app = null, out = null;
27
+ while (args.length) {
28
+ const a = args.shift();
29
+ const value = () => {
30
+ if (!args.length) throw new Error(`${a} needs a value`);
31
+ return args.shift();
32
+ };
33
+ if (a === '-o') out = value();
34
+ else if (a === '--full') opts.kind = 'full';
35
+ else if (a === '--warm') opts.warm.push(value());
36
+ else if (a === '--env') {
37
+ const v = value(), at = v.indexOf('=');
38
+ if (at < 1) throw new Error(`--env ${v}: not NAME=VALUE`);
39
+ opts.env[v.slice(0, at)] = v.slice(at + 1);
40
+ } else if (!a.startsWith('-') && !app) app = a;
41
+ else throw new Error(`--snapshot: unknown argument ${a}`);
42
+ }
43
+ if (!app) throw new Error('usage: npx beam.com --snapshot APP.com [-o FILE] [--full] [--warm PATH]... [--env NAME=VALUE]...');
44
+ if (opts.warm.length && opts.kind !== 'full') throw new Error('--warm is for a full snapshot (--full)');
45
+ out ??= app.replace(/\.com$/, '') + '.snapshot';
46
+ const { snapshot } = await import('./node.mjs');
47
+ const t0 = performance.now();
48
+ const bytes = await snapshot(app, opts);
49
+ writeFileSync(out, bytes);
50
+ console.error(`beam.com: wrote ${out} (${(bytes.length / 1048576).toFixed(1)} MB, ${opts.kind}, in ${Math.round(performance.now() - t0)} ms)`);
51
+ }
52
+
9
53
  let release = null;
10
54
  try {
11
55
  release = JSON.parse(readFileSync(new URL('../runtime/release.json', import.meta.url), 'utf8'));
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,60 @@ 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
+ // Gives the bytes of the snapshot.
90
+ export async function snapshot(app, { env = {}, warm = [], kind = 'boot-point' } = {}) {
91
+ if (kind !== 'boot-point' && kind !== 'full') throw new Error(`the kind of snapshot is boot-point or full, not ${kind}`);
92
+ const bin = await readApp(app, appFiles);
93
+ const { Vm, releaseMeta } = await import(new URL('worker.js', runtimeUrl).href);
94
+ const meta = releaseMeta(bin);
95
+ if (kind === 'full') {
96
+ if (meta.sql) throw new Error(`${app}: the app has Ecto SQLite: use a snapshot at the boot point`);
97
+ const missing = ['SECRET_KEY_BASE', 'PHX_HOST'].filter((k) => meta.env?.PHX_SERVER === 'true' && !env[k]);
98
+ if (missing.length) throw new Error(`${app}: a full snapshot of a Phoenix app needs ${missing.join(' and ')}`);
99
+ }
100
+ const vm = new Vm({ BEAM_SNAPSHOT: 'off', ...env }, { release: bin, plain: false, capture: kind });
101
+ let bytes;
102
+ if (kind === 'boot-point') {
103
+ // wasm_host says "ready", and then it stops at the boot point. A boot
104
+ // that fails rejects ready.
105
+ let timer;
106
+ const late = new Promise((resolve, reject) => {
107
+ timer = setTimeout(() => reject(new Error(`${app}: the VM did not stop at the boot point in 120 s`)), 120000);
108
+ });
109
+ try {
110
+ bytes = await Promise.race([vm.captured, vm.ready.then(() => vm.captured), late]);
111
+ } finally {
112
+ clearTimeout(timer);
113
+ }
114
+ } else {
115
+ await vm.ready;
116
+ await vm.listening(Number(env.PORT ?? meta.env?.PORT ?? 4000));
117
+ for (const path of warm) {
118
+ const r = await vm.fetch(new Request(new URL(path, 'http://localhost')));
119
+ await r.arrayBuffer();
120
+ if (r.status >= 500) throw new Error(`${app}: the warm-up request ${path} gave ${r.status}`);
121
+ }
122
+ bytes = await vm.snapshot(false);
123
+ }
124
+ if (!(bytes instanceof Uint8Array)) throw new Error(`${app}: no snapshot (${bytes}): the VM was not quiet`);
125
+ return bytes;
126
+ }
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.2",
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,
@@ -11,7 +11,7 @@
11
11
  // Then:
12
12
  //
13
13
  // import { start } from './browser.js';
14
- // const beam = await start({ release: './release/release.bin' });
14
+ // const beam = await start({ app: './app.com' });
15
15
  // const response = await beam.fetch('/path'); // a Response of the app
16
16
  // const socket = await beam.socket('/ws'); // a WebSocket of the app
17
17
  //
@@ -127,7 +127,10 @@ async function urlReader(url) {
127
127
  // Boots the release of the URL release, or of the native app.com of the URL
128
128
  // app (its edge part, see app-com.js: for this runtime only), with the
129
129
  // environment env (the "vars" of a Worker). BEAM_HOST is "browser".
130
- export async function start({ release = './release.bin', app = null, env = {} } = {}) {
130
+ // statics: false keeps the static files of the app in the VM. Else the VM
131
+ // does not have them (appFiles): use false for an app that serves
132
+ // priv/static at a path other than "/" (Plug.Static with at:).
133
+ export async function start({ release = './release.bin', app = null, env = {}, statics = true } = {}) {
131
134
  if (typeof WebAssembly.Suspending !== 'function') {
132
135
  throw new Error('this browser has no JSPI (WebAssembly.Suspending)');
133
136
  }
@@ -137,10 +140,10 @@ export async function start({ release = './release.bin', app = null, env = {} }
137
140
  if (app) {
138
141
  const [{ appFiles }, { default: runtime }] = await Promise.all([import('./app-com.js'), import('./runtime-id.js')]);
139
142
  const { read, size } = await urlReader(new URL(app, where).href);
140
- const bytes = await appFiles(read, size, { runtime });
141
- vm = new Vm({ BEAM_HOST: 'browser', ...env }, { plain: false, release: bytes });
143
+ const bytes = await appFiles(read, size, { runtime, statics });
144
+ vm = new Vm({ BEAM_HOST: 'browser', ...env }, { plain: false, release: bytes, scheme: false });
142
145
  } else {
143
- vm = new Vm({ BEAM_HOST: 'browser', ...env, RELEASE_URL: new URL(release, where).href }, { plain: false });
146
+ vm = new Vm({ BEAM_HOST: 'browser', ...env, RELEASE_URL: new URL(release, where).href }, { plain: false, scheme: false });
144
147
  }
145
148
  await vm.ready;
146
149
  const origin = new URL('http://localhost');
@@ -24,8 +24,10 @@
24
24
  //
25
25
  // wrangler.jsonc gives app.com to the Worker as a Data module:
26
26
  // "rules": [{ "type": "Data", "globs": ["**/*.com"], "fallthrough": true }]
27
+ import { env as globalEnv } from 'cloudflare:workers';
27
28
  import { release, use } from './release.js';
28
- import plain, { staticResponse } from './worker.js';
29
+ import { useSnapshot } from './snapshot.js';
30
+ import plain, { snapshotHeader, staticResponse, Vm } from './worker.js';
29
31
  import front from './durable.js';
30
32
 
31
33
  export { Beam } from './durable.js';
@@ -49,8 +51,41 @@ export { Beam } from './durable.js';
49
51
  // scheduled(controller, env, ctx) {
50
52
  // return beam.scheduled(controller, env, ctx);
51
53
  // },
52
- export function serve(app, { binding = 'BEAM', name, nifs = null } = {}) {
53
- use(app, nifs);
54
+ //
55
+ // options.statics: true (the default) serves the static files of a
56
+ // Phoenix app (priv/static of the application of the release) at the root
57
+ // of the site, before the VM: the VM does not get them. An app that serves
58
+ // them at another path (Plug.Static with at:) gives false: then the VM
59
+ // keeps them and serves them.
60
+ //
61
+ // options.snapshot: the snapshot of the build of app.com (npx beam.com
62
+ // --snapshot app.com, imported as a Data module, as app.com):
63
+ // - a full one (--full): each new VM restores it in place of a boot. A
64
+ // stateless Worker restores its VM in the global scope, before the
65
+ // first request;
66
+ // - one at the boot point: a new VM restores it only when the store of
67
+ // snapshots has none (the first VM of a deploy), and then the VM makes
68
+ // its snapshot for the store, as with no snapshot of the build.
69
+ // The entry waits for the VM of the global scope:
70
+ //
71
+ // import snapshot from './app.snapshot';
72
+ // const beam = serve(app, { snapshot });
73
+ // await beam.ready;
74
+ //
75
+ // With the var BEAM_WARM (a path, as "/"), the global scope also sends one
76
+ // GET request of that path to the VM of a full snapshot (see global.js).
77
+ export function serve(app, { binding = 'BEAM', name, nifs = null, snapshot = null, statics: split = true } = {}) {
78
+ use(app, nifs, split);
79
+ useSnapshot(snapshot);
80
+ // The VM of a stateless Worker, restored from a full snapshot in the
81
+ // global scope.
82
+ let vm = null;
83
+ const full = !!snapshot && !snapshotHeader(snapshot).boot_point;
84
+ const ready = full && !globalEnv[binding] ? (async () => {
85
+ vm = new Vm(globalEnv, { release: await release(), snapshot });
86
+ await vm.ready;
87
+ if (globalEnv.BEAM_WARM) await vm.warm(globalEnv.BEAM_WARM);
88
+ })() : Promise.resolve();
54
89
  // The env of the front: the binding as BEAM, the name of the object, and
55
90
  // the static files of app.com (the front serves them, with no request
56
91
  // to the object).
@@ -62,8 +97,10 @@ export function serve(app, { binding = 'BEAM', name, nifs = null } = {}) {
62
97
  ...env, BEAM: env[binding], BEAM_OBJECT: name ?? env.BEAM_OBJECT, BEAM_STATICS: statics,
63
98
  });
64
99
  return {
100
+ ready,
65
101
  fetch(request, env, ctx) {
66
102
  const objects = env[binding];
103
+ if (!objects && vm) return vm.fetch(request, ctx);
67
104
  if (!objects) return plain.fetch(request, env, ctx);
68
105
  if (typeof name === 'function') return objects.getByName(name(request)).fetch(request);
69
106
  return front.fetch(request, frontEnv(env), ctx);
@@ -8,17 +8,26 @@
8
8
  // The file must come from the beam.com of the version of this package:
9
9
  // app-com.js refuses a file for another runtime. worker.js reads the
10
10
  // release at the first request of an isolate, not in the global scope.
11
+ import { inflateRawSync } from 'node:zlib';
11
12
  import { appFiles, bytesReader } from '../app-com.js';
12
13
  import runtime from '../runtime-id.js';
13
14
 
15
+ // The global scope of a Worker cannot use DecompressionStream (a VM that
16
+ // serve(app, { snapshot }) restores there reads the release first).
17
+ const inflate = async (raw) => new Uint8Array(inflateRawSync(raw));
18
+
14
19
  let app = null;
15
20
  let modules = null;
21
+ let split = true;
16
22
 
17
23
  // nifModules: the NIF libraries in WebAssembly of app.com, compiled by
18
- // Wrangler (nifs.js of "beam.com --nif-modules"), or null.
19
- export function use(bytes, nifModules = null) {
24
+ // Wrangler (nifs.js of "beam.com --nif-modules"), or null. statics: false
25
+ // keeps the static files of a Phoenix app in the release of the VM (see
26
+ // serve(app) of index.js).
27
+ export function use(bytes, nifModules = null, statics = true) {
20
28
  app = bytes;
21
29
  modules = nifModules;
30
+ split = statics;
22
31
  }
23
32
 
24
33
  // For worker.js: the path of each NIF library in /app, and its module.
@@ -36,7 +45,7 @@ export function release() {
36
45
  }
37
46
  if (!files) {
38
47
  const { read, size } = bytesReader(app);
39
- files = appFiles(read, size, { runtime });
48
+ files = appFiles(read, size, { runtime, inflate, statics: split });
40
49
  }
41
50
  return files;
42
51
  }
@@ -1,3 +1,11 @@
1
- // No snapshot of the build in a Worker that runs an app.com: the Worker
2
- // makes its own snapshot (the Cache API, or the R2 bucket SNAPSHOTS).
3
- export default null;
1
+ // The snapshot of the build of the app.com of the project (npx beam.com
2
+ // --snapshot), when the entry gives it to serve(app, { snapshot }): each
3
+ // new VM restores it in place of a boot. With none, the Worker makes its
4
+ // own snapshot (the Cache API, or the R2 bucket SNAPSHOTS).
5
+ let bytes = null;
6
+
7
+ export function useSnapshot(snapshot) {
8
+ bytes = snapshot;
9
+ }
10
+
11
+ export { bytes as default };