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 +86 -54
- package/js/beam.com.mjs +45 -1
- package/js/node.mjs +56 -5
- package/package.json +1 -1
- package/runtime/app-com.js +10 -6
- package/runtime/browser.js +8 -5
- package/runtime/cloudflare/index.js +40 -3
- package/runtime/cloudflare/release.js +12 -3
- package/runtime/cloudflare/snapshot.js +11 -3
- package/runtime/cloudflare/worker.js +280 -31
- package/runtime/deno/app.js +5 -1
- package/runtime/deno/release-bin.js +2 -2
- package/runtime/deno/worker.js +280 -31
- package/runtime/deno.js +4 -3
- package/runtime/page/app-com.js +10 -6
- package/runtime/page/browser.js +8 -5
- package/runtime/page/runtime-id.js +1 -1
- package/runtime/page/worker.js +280 -31
- package/runtime/release.json +1 -1
- package/runtime/runtime-id.js +1 -1
- package/runtime/worker.js +280 -31
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
|
-
- **
|
|
24
|
-
`beam.com INPUT -o
|
|
25
|
-
|
|
26
|
-
|
|
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`
|
|
141
|
-
|
|
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
|
-
|
|
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
|
|
170
|
-
|
|
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 --
|
|
175
|
-
`DIR
|
|
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
|
|
205
|
-
|
|
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
|
|
272
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
70
|
-
|
|
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
package/runtime/app-com.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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,
|
package/runtime/browser.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
// Then:
|
|
12
12
|
//
|
|
13
13
|
// import { start } from './browser.js';
|
|
14
|
-
// const beam = await start({
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
53
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
2
|
-
//
|
|
3
|
-
|
|
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 };
|