beam.com 0.1.0-rc.2 → 0.1.0-rc.4

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
@@ -119,7 +119,9 @@ cd hello && ../mix.com deps.get && ../mix.com ecto.migrate
119
119
  ```
120
120
 
121
121
  One file runs natively and in the WebAssembly runtime. Each executable of
122
- `-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).
123
125
  A release directory of `mix release` is an input too:
124
126
 
125
127
  ```sh
@@ -217,7 +219,7 @@ jobs:
217
219
 
218
220
  Then turn on Pages one time: Settings, Pages, Source "GitHub Actions". The
219
221
  `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
222
+ release candidate `@v0.1.0-rc.4` in place of `@v0.1.0`. With `@main`, the
221
223
  workflow uses the `edge` build of `beam.com`.
222
224
 
223
225
  The workflow ([`pages-app.yml`](.github/workflows/pages-app.yml)) builds
@@ -284,7 +286,7 @@ Each GitHub release from 0.1.0-rc.2 also holds the package, the same
284
286
  file as on npm. npm installs it with no registry:
285
287
 
286
288
  ```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
289
+ npm install https://github.com/sntran/BEAM.com/releases/download/v0.1.0-rc.4/beam.com-0.1.0-rc.4.tgz
288
290
  ```
289
291
 
290
292
  - `BEAM_COM`: the path of a `beam.com` to use. Then nothing is
package/js/beam.com.mjs CHANGED
@@ -3,11 +3,15 @@
3
3
  // (download.mjs). For example: npx beam.com app.erl -o app.com
4
4
  //
5
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.
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.
9
13
  import { spawn } from 'node:child_process';
10
- import { readFileSync, writeFileSync } from 'node:fs';
14
+ import { closeSync, openSync, readFileSync, readSync, statSync, writeFileSync } from 'node:fs';
11
15
  import { constants } from 'node:os';
12
16
  import { command, ensure } from './download.mjs';
13
17
 
@@ -32,6 +36,7 @@ async function snapshotCommand(args) {
32
36
  };
33
37
  if (a === '-o') out = value();
34
38
  else if (a === '--full') opts.kind = 'full';
39
+ else if (a === '--no-compress') opts.compress = false;
35
40
  else if (a === '--warm') opts.warm.push(value());
36
41
  else if (a === '--env') {
37
42
  const v = value(), at = v.indexOf('=');
@@ -40,14 +45,40 @@ async function snapshotCommand(args) {
40
45
  } else if (!a.startsWith('-') && !app) app = a;
41
46
  else throw new Error(`--snapshot: unknown argument ${a}`);
42
47
  }
43
- if (!app) throw new Error('usage: npx beam.com --snapshot APP.com [-o FILE] [--full] [--warm PATH]... [--env NAME=VALUE]...');
48
+ if (!app) throw new Error('usage: npx beam.com --snapshot APP.com [-o FILE] [--full] [--warm PATH]... [--env NAME=VALUE]... [--no-compress]');
44
49
  if (opts.warm.length && opts.kind !== 'full') throw new Error('--warm is for a full snapshot (--full)');
45
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
+ }
46
63
  const { snapshot } = await import('./node.mjs');
47
64
  const t0 = performance.now();
48
65
  const bytes = await snapshot(app, opts);
49
66
  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)`);
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
+ }
51
82
  }
52
83
 
53
84
  let release = null;
package/js/download.mjs CHANGED
@@ -14,6 +14,9 @@ import path from 'node:path';
14
14
 
15
15
  export const REPOSITORY = 'sntran/BEAM.com';
16
16
 
17
+ // The time with no bytes that stops a download (ms).
18
+ const IDLE = 60000;
19
+
17
20
  // The cache directory of beam.com (beam_com:cache_dir/0).
18
21
  export function cacheDir(env = process.env) {
19
22
  if (env.BEAM_COM_CACHE) return env.BEAM_COM_CACHE;
@@ -37,7 +40,8 @@ export function downloadUrl({ version }, env = process.env) {
37
40
 
38
41
  // The path of a beam.com that runs. The file in the cache was checked when
39
42
  // it was downloaded: a file is in the cache only after the check.
40
- export async function ensure(release, { env = process.env, platform = process.platform, fetch = globalThis.fetch, log = () => {} } = {}) {
43
+ // idle: the download stops when no bytes come in this time (ms).
44
+ export async function ensure(release, { env = process.env, platform = process.platform, fetch = globalThis.fetch, log = () => {}, idle = IDLE } = {}) {
41
45
  if (env.BEAM_COM) return env.BEAM_COM;
42
46
  if (!release?.version || !/^[0-9a-f]{64}$/.test(release?.sha256 ?? '')) {
43
47
  throw new Error('this package has no release of beam.com (runtime/release.json): set BEAM_COM to the path of a beam.com');
@@ -46,16 +50,36 @@ export async function ensure(release, { env = process.env, platform = process.pl
46
50
  if (fs.existsSync(file)) return file;
47
51
  const url = downloadUrl(release, env);
48
52
  log(`beam.com: downloading ${url}`);
49
- const response = await fetch(url);
50
- if (!response.ok) throw new Error(`beam.com: ${url}: status ${response.status}`);
53
+ const ac = new AbortController();
54
+ let timer;
55
+ const wait = () => {
56
+ clearTimeout(timer);
57
+ timer = setTimeout(() => ac.abort(new Error(`beam.com: ${url}: no bytes in ${idle / 1000} s`)), idle);
58
+ };
59
+ wait();
60
+ let response;
61
+ try {
62
+ response = await fetch(url, { signal: ac.signal });
63
+ } catch (e) {
64
+ clearTimeout(timer);
65
+ throw ac.signal.aborted ? ac.signal.reason : e;
66
+ }
67
+ if (!response.ok) {
68
+ clearTimeout(timer);
69
+ throw new Error(`beam.com: ${url}: status ${response.status}`);
70
+ }
51
71
  fs.mkdirSync(path.dirname(file), { recursive: true });
52
72
  const part = `${file}.${process.pid}.part`;
53
73
  const out = fs.createWriteStream(part, { mode: 0o755 });
74
+ // An error of the file (a full disk): no drain comes after it.
75
+ const failed = new Promise((_, reject) => out.once('error', reject));
76
+ failed.catch(() => {});
54
77
  const hash = createHash('sha256');
55
78
  try {
56
79
  for await (const chunk of response.body) {
80
+ wait();
57
81
  hash.update(chunk);
58
- if (!out.write(chunk)) await new Promise((r) => out.once('drain', r));
82
+ if (!out.write(chunk)) await Promise.race([new Promise((r) => out.once('drain', r)), failed]);
59
83
  }
60
84
  await new Promise((resolve, reject) => out.end((e) => (e ? reject(e) : resolve())));
61
85
  const got = hash.digest('hex');
@@ -64,7 +88,10 @@ export async function ensure(release, { env = process.env, platform = process.pl
64
88
  }
65
89
  fs.chmodSync(part, 0o755);
66
90
  fs.renameSync(part, file);
91
+ } catch (e) {
92
+ throw ac.signal.aborted ? ac.signal.reason : e;
67
93
  } finally {
94
+ clearTimeout(timer);
68
95
  out.destroy();
69
96
  fs.rmSync(part, { force: true });
70
97
  }
package/js/node.mjs CHANGED
@@ -86,11 +86,16 @@ export async function boot(app, { env = {}, statics = true } = {}) {
86
86
  // It holds the variables of env and the state of the program. A Phoenix
87
87
  // app must give SECRET_KEY_BASE and PHX_HOST, and an app with Ecto
88
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.
89
94
  // Gives the bytes of the snapshot.
90
- export async function snapshot(app, { env = {}, warm = [], kind = 'boot-point' } = {}) {
95
+ export async function snapshot(app, { env = {}, warm = [], kind = 'boot-point', compress = kind !== 'full' } = {}) {
91
96
  if (kind !== 'boot-point' && kind !== 'full') throw new Error(`the kind of snapshot is boot-point or full, not ${kind}`);
92
97
  const bin = await readApp(app, appFiles);
93
- const { Vm, releaseMeta } = await import(new URL('worker.js', runtimeUrl).href);
98
+ const { Vm, releaseMeta, packSnapshot } = await import(new URL('worker.js', runtimeUrl).href);
94
99
  const meta = releaseMeta(bin);
95
100
  if (kind === 'full') {
96
101
  if (meta.sql) throw new Error(`${app}: the app has Ecto SQLite: use a snapshot at the boot point`);
@@ -122,5 +127,5 @@ export async function snapshot(app, { env = {}, warm = [], kind = 'boot-point' }
122
127
  bytes = await vm.snapshot(false);
123
128
  }
124
129
  if (!(bytes instanceof Uint8Array)) throw new Error(`${app}: no snapshot (${bytes}): the VM was not quiet`);
125
- return bytes;
130
+ return compress ? packSnapshot(bytes) : bytes;
126
131
  }
@@ -0,0 +1,47 @@
1
+ // The vars of the Wrangler file of a project (wrangler.jsonc, else
2
+ // wrangler.json), for npx beam.com --snapshot: the snapshot gets the
3
+ // BEAM_ERL_FLAGS of the Worker. Wrangler reads JSONC: comments (// and
4
+ // /* */) and a comma before } or ]. wrangler.toml is not read.
5
+ import { readFileSync } from 'node:fs';
6
+ import { join } from 'node:path';
7
+
8
+ // {file, vars} of the first Wrangler file of dir, or null.
9
+ export function wranglerVars(dir = '.') {
10
+ for (const name of ['wrangler.jsonc', 'wrangler.json']) {
11
+ let text;
12
+ try {
13
+ text = readFileSync(join(dir, name), 'utf8');
14
+ } catch {
15
+ continue;
16
+ }
17
+ const vars = jsonc(text).vars;
18
+ return { file: name, vars: vars && typeof vars === 'object' ? vars : {} };
19
+ }
20
+ return null;
21
+ }
22
+
23
+ // The value of a JSONC text.
24
+ export function jsonc(text) {
25
+ let out = '';
26
+ for (let i = 0; i < text.length; i++) {
27
+ const c = text[i];
28
+ if (c === '"') {
29
+ // A string, with its escapes, as it is.
30
+ let j = i + 1;
31
+ while (j < text.length && text[j] !== '"') j += text[j] === '\\' ? 2 : 1;
32
+ out += text.slice(i, j + 1);
33
+ i = j;
34
+ } else if (c === '/' && text[i + 1] === '/') {
35
+ while (i < text.length && text[i] !== '\n') i++;
36
+ out += '\n';
37
+ } else if (c === '/' && text[i + 1] === '*') {
38
+ const end = text.indexOf('*/', i + 2);
39
+ i = end < 0 ? text.length : end + 1;
40
+ out += ' ';
41
+ } else {
42
+ out += c;
43
+ }
44
+ }
45
+ // A comma before } or ] (outside the strings, which have no newline).
46
+ return JSON.parse(out.replace(/,(\s*[}\]])/g, '$1'));
47
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "beam.com",
3
- "version": "0.1.0-rc.2",
3
+ "version": "0.1.0-rc.4",
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": {