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 +89 -55
- package/js/beam.com.mjs +76 -1
- package/js/download.mjs +31 -4
- package/js/node.mjs +61 -5
- package/js/wrangler.mjs +47 -0
- package/package.json +1 -1
- package/runtime/app-com.js +10 -6
- package/runtime/beam.mjs +1 -1
- package/runtime/beam.wasm +0 -0
- package/runtime/browser.js +16 -12
- package/runtime/cloudflare/durable.js +32 -2
- package/runtime/cloudflare/index.js +51 -3
- package/runtime/cloudflare/release.js +12 -3
- package/runtime/cloudflare/snapshot.js +11 -3
- package/runtime/cloudflare/worker.js +1180 -107
- package/runtime/deno/app.js +5 -1
- package/runtime/deno/release-bin.js +2 -2
- package/runtime/deno/worker.js +1180 -107
- package/runtime/deno.js +6 -3
- package/runtime/durable.js +32 -2
- package/runtime/global.js +6 -3
- package/runtime/page/app-com.js +10 -6
- package/runtime/page/beam.mjs +1 -1
- package/runtime/page/browser.js +16 -12
- package/runtime/page/runtime-id.js +1 -1
- package/runtime/page/worker.js +1180 -107
- package/runtime/release.json +1 -1
- package/runtime/runtime-id.js +1 -1
- package/runtime/worker.js +1180 -107
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,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`
|
|
141
|
-
|
|
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
|
-
|
|
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
|
|
170
|
-
|
|
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 --
|
|
175
|
-
`DIR
|
|
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
|
|
205
|
-
|
|
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
|
|
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).
|
|
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
|
|
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
|
-
|
|
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
|
|
50
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
// 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
|
+
}
|
package/js/wrangler.mjs
ADDED
|
@@ -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
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,
|