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.
@@ -184,6 +184,9 @@ function capture(m, release, listeners, bootPoint = false, skip = () => false) {
184
184
  const head = new TextEncoder().encode(JSON.stringify({
185
185
  size: heap.length, pages, fs: { files, streams }, listeners: Object.fromEntries(listeners),
186
186
  boot_point: bootPoint,
187
+ // The release of this memory (snapshot_key of .release.json): a VM of
188
+ // another release does not restore it.
189
+ release: releaseMeta(release).snapshot_key ?? null,
187
190
  nifs: libs && { count: libs.count, libs: libs.libs.map(({ data, ...l }) => l) },
188
191
  }));
189
192
  const out = new Uint8Array(12 + head.length + (pages.length + libPages) * PAGE);
@@ -214,6 +217,12 @@ async function loadSnapshot(env) {
214
217
  }
215
218
  }
216
219
 
220
+ // The header of a snapshot, without its pages: boot_point, release, ...
221
+ export function snapshotHeader(bytes) {
222
+ const { pagesData, ...head } = parseSnapshot(bytes);
223
+ return head;
224
+ }
225
+
217
226
  function parseSnapshot(bytes) {
218
227
  const b = new Uint8Array(bytes);
219
228
  if (new TextDecoder().decode(b.subarray(0, 8)) !== 'BEAMSNP1') throw new Error('snapshot.bin: not a snapshot');
@@ -223,20 +232,93 @@ function parseSnapshot(bytes) {
223
232
  }
224
233
 
225
234
  // BEAM_CONNECT: the hosts that the VM can connect to, separated by commas:
226
- // "host", "host:port", or "*.domain" (the subdomains of domain). The host
227
- // resolves a name, so the VM cannot reach another address through it.
228
- // With no BEAM_CONNECT, the VM can connect to all hosts.
229
- function connectAllowed(list, host, port) {
235
+ // "host", "host:port", "*.domain" (the subdomains of domain), or "*" (all
236
+ // hosts, as "*:443"). The host resolves a name, so the VM cannot reach
237
+ // another address through it. With no BEAM_CONNECT, the VM can connect to
238
+ // all hosts. BEAM_FETCH has the same rules.
239
+ export function connectAllowed(list, host, port) {
230
240
  if (list === undefined) return true;
231
241
  const name = String(host).toLowerCase().replace(/\.$/, '');
232
242
  return list.split(',').map((r) => r.trim().toLowerCase()).filter(Boolean).some((rule) => {
233
243
  const i = rule.lastIndexOf(':');
234
244
  const [pattern, p] = i > 0 && !rule.includes(']') ? [rule.slice(0, i), rule.slice(i + 1)] : [rule, undefined];
235
245
  if (p !== undefined && Number(p) !== port) return false;
246
+ if (pattern === '*') return true;
236
247
  return pattern.startsWith('*.') ? name.endsWith(pattern.slice(1)) : name === pattern;
237
248
  });
238
249
  }
239
250
 
251
+ // The IP ranges of Cloudflare (https://www.cloudflare.com/ips-v4 and
252
+ // ips-v6, October 2026), and the ranges of its resolver 1.1.1.1. On
253
+ // Cloudflare, connect() of a Worker cannot reach them: the fallback of the
254
+ // fetch path (wasm_host_fetch.erl) sends the HTTP of such a host through
255
+ // fetch().
256
+ export const CLOUDFLARE_RANGES = [
257
+ '173.245.48.0/20', '103.21.244.0/22', '103.22.200.0/22', '103.31.4.0/22', '141.101.64.0/18',
258
+ '108.162.192.0/18', '190.93.240.0/20', '188.114.96.0/20', '197.234.240.0/22', '198.41.128.0/17',
259
+ '162.158.0.0/15', '104.16.0.0/13', '104.24.0.0/14', '172.64.0.0/13', '131.0.72.0/22',
260
+ '1.1.1.0/24', '1.0.0.0/24',
261
+ '2400:cb00::/32', '2606:4700::/32', '2803:f800::/32', '2405:b500::/32', '2405:8100::/32',
262
+ '2a06:98c0::/29', '2c0f:f248::/32',
263
+ ];
264
+
265
+ // An IP address as {bits, size} (a BigInt of 32 or 128 bits), or null.
266
+ export function ipBits(text) {
267
+ const ip = String(text).replace(/^\[|\]$/g, '');
268
+ const v4 = (t) => {
269
+ const parts = t.split('.');
270
+ if (parts.length !== 4 || !parts.every((x) => /^\d{1,3}$/.test(x) && Number(x) < 256)) return null;
271
+ return parts.reduce((n, x) => (n << 8n) | BigInt(x), 0n);
272
+ };
273
+ if (!ip.includes(':')) {
274
+ const n = v4(ip);
275
+ return n === null ? null : { bits: n, size: 32 };
276
+ }
277
+ let [head, tail] = ip.split('::');
278
+ if (ip.split('::').length > 2) return null;
279
+ const words = (t) => (t ? t.split(':') : []);
280
+ let h = words(head), t = tail === undefined ? [] : words(tail);
281
+ // An IPv4 address at the end (::ffff:1.2.3.4) is two words.
282
+ const last = (tail === undefined ? h : t);
283
+ if (last.length && last.at(-1).includes('.')) {
284
+ const n = v4(last.pop());
285
+ if (n === null) return null;
286
+ last.push((n >> 16n).toString(16), (n & 0xffffn).toString(16));
287
+ }
288
+ const fill = tail === undefined ? 0 : 8 - h.length - t.length;
289
+ if (fill < 0 || (tail === undefined && h.length !== 8)) return null;
290
+ const all = [...h, ...Array(fill).fill('0'), ...t];
291
+ if (!all.every((w) => /^[0-9a-f]{1,4}$/i.test(w))) return null;
292
+ return { bits: all.reduce((n, w) => (n << 16n) | BigInt(parseInt(w, 16)), 0n), size: 128 };
293
+ }
294
+
295
+ // The address is in one of the ranges ("address/prefix").
296
+ export function inRanges(address, ranges = CLOUDFLARE_RANGES) {
297
+ let a = ipBits(address);
298
+ if (!a) return false;
299
+ // An IPv4 address in IPv6 (::ffff:a.b.c.d) is that IPv4 address.
300
+ if (a.size === 128 && a.bits >> 32n === 0xffffn) a = { bits: a.bits & 0xffffffffn, size: 32 };
301
+ return ranges.some((r) => {
302
+ const [base, prefix] = r.split('/');
303
+ const b = ipBits(base);
304
+ if (!b || b.size !== a.size) return false;
305
+ const shift = BigInt(a.size - Number(prefix));
306
+ return (a.bits >> shift) === (b.bits >> shift);
307
+ });
308
+ }
309
+
310
+ // The URL of a request of the fetch path: the host and the port of the
311
+ // connect of the program (never a name of the request), and the path of
312
+ // the request. null for a path that would change the origin.
313
+ export function fetchUrl(host, port, tls, path) {
314
+ const scheme = tls ? 'https' : 'http';
315
+ const name = String(host).includes(':') ? `[${host}]` : String(host);
316
+ const origin = `${scheme}://${name}${port === (tls ? 443 : 80) ? '' : `:${port}`}`;
317
+ if (typeof path !== 'string' || !path.startsWith('/') || path.startsWith('//')) return null;
318
+ const url = new URL(path, origin);
319
+ return url.origin === new URL(origin).origin ? url.href : null;
320
+ }
321
+
240
322
  // The memory and the open files of the snapshot, in a new instance (before
241
323
  // main(), which does not run): then the threads start again.
242
324
  function restore(m, exports, snap) {
@@ -969,8 +1051,17 @@ export class Vm {
969
1051
  // secrets: a store of the secrets that the VM makes itself ({get(name),
970
1052
  // put(name, value)}: the storage of a Durable Object, the Deno KV of
971
1053
  // deno.js), and host: the host of the first request (autoVars).
1054
+ // scheme: false when the host gives x-forwarded-proto itself (the web
1055
+ // page gives https for http://localhost).
1056
+ // capture: a VM that only makes a snapshot of the build (the tool of
1057
+ // node.mjs): 'boot-point' gives the snapshot at the boot point to
1058
+ // captured, and 'full' lets the tool call snapshot() itself. Such a VM
1059
+ // makes no variables of autoVars, and uses no store of snapshots.
972
1060
  constructor(env, { plain = true, sql = null, id = null, release = null, snapshot = null, vars = {}, files = null,
973
- secrets = null, host = null } = {}) {
1061
+ secrets = null, host = null, scheme = true, capture = null } = {}) {
1062
+ this.scheme = scheme;
1063
+ this.capture = capture;
1064
+ this.captured = new Promise((resolve) => { this.gotCapture = resolve; });
974
1065
  this.vars = vars;
975
1066
  this.secrets = secrets;
976
1067
  this.host = host;
@@ -980,7 +1071,12 @@ export class Vm {
980
1071
  this.id = id;
981
1072
  this.sql = sql; // ctx.storage.sql of a Durable Object (Ecto SQLite)
982
1073
  this.tcps = new Map(); // id -> {send, close, h}: a TCP socket of wasm_tcp
983
- this.listeners = new Map(); // port -> the id of its listener (wasm_tcp)
1074
+ this.listeners = new Map(); // port -> the id of its listener (wasm_tcp); 'fetch' (and
1075
+ // 'fetch-tls' when the VM trusts its CA): wasm_host_fetch
1076
+ this.fetchConns = new Map(); // id -> {host, port, h}: a connection of the fetch path
1077
+ this.fetches = new Map(); // id -> the AbortController of a fetch() of the fetch path
1078
+ this.dns = new Map(); // host -> {at, addresses}: the names of the fetch path
1079
+ this.fetchPending = 0;
984
1080
  this.nextId = 1;
985
1081
  this.plain = plain;
986
1082
  this.handlers = []; // the open requests (plain)
@@ -1027,31 +1123,50 @@ export class Vm {
1027
1123
  // The vars of the host choose a snapshot at the boot point (below);
1028
1124
  // the vars of autoVars do not.
1029
1125
  const ownVars = Object.keys(this.vars).length;
1030
- await this.autoVars(env, releaseMeta(release));
1031
- // A snapshot of the build (snapshot.bin), else one that a Worker made
1032
- // (BEAM_SNAPSHOT = "off" turns them off).
1033
- let snapBytes = bundled, key = null;
1034
- if (!snapBytes && env.BEAM_SNAPSHOT !== 'off') {
1035
- // A Durable Object with Ecto SQLite (sql of .release.json): the
1036
- // snapshot is made at the boot point (wasm_host_server), before the
1037
- // program starts and runs its migrations. So all the objects (the
1038
- // tenants) share it, and each one runs the program on its own storage.
1039
- // Tenants (BEAM_TENANTS) share the snapshot too, so it is made at the
1040
- // boot point, before the program has the state of one tenant.
1041
- const meta = releaseMeta(release);
1042
- const atBoot = !this.plain && (this.sql || this.hostFiles)
1043
- && ((meta.sql ?? true) || !!env.BEAM_PERSIST || !!env.BEAM_TENANTS || ownVars > 0);
1126
+ // A snapshot of the build holds no secret that the VM made itself.
1127
+ if (!this.capture) await this.autoVars(env, releaseMeta(release));
1128
+ // A snapshot of the build, else one that a Worker made (BEAM_SNAPSHOT =
1129
+ // "off" turns them off). A snapshot that the code gives (this.given,
1130
+ // the global scope) is used as it is. Else, of the snapshot of the
1131
+ // build (serve(app, { snapshot }), or snapshot.bin):
1132
+ // - a full one is used in place of the others;
1133
+ // - one at the boot point only when the store has no snapshot, and then
1134
+ // the VM still makes its snapshot for the store.
1135
+ // A snapshot of the build of another release: a boot in its place.
1136
+ const meta = releaseMeta(release);
1137
+ let built = bundled && parseSnapshot(bundled);
1138
+ if (built?.release && built.release !== meta.snapshot_key) {
1139
+ console.log('beam: the snapshot of the build is for another release: a boot in its place');
1140
+ built = null;
1141
+ }
1142
+ // A Durable Object with Ecto SQLite (sql of .release.json): the
1143
+ // snapshot is made at the boot point (wasm_host_server), before the
1144
+ // program starts and runs its migrations. So all the objects (the
1145
+ // tenants) share it, and each one runs the program on its own storage.
1146
+ // Tenants (BEAM_TENANTS) share the snapshot too, so it is made at the
1147
+ // boot point, before the program has the state of one tenant.
1148
+ const atBoot = !this.plain && (this.sql || this.hostFiles)
1149
+ && ((meta.sql ?? true) || !!env.BEAM_PERSIST || !!env.BEAM_TENANTS || ownVars > 0);
1150
+ let snap = null, key = null, fallback = null;
1151
+ if (built && (this.given || (!built.boot_point && !atBoot))) snap = built;
1152
+ else if (built?.boot_point) fallback = built;
1153
+ if (this.capture === 'boot-point') this.bootKey = 'capture';
1154
+ if (!snap && !this.capture && env.BEAM_SNAPSHOT !== 'off') {
1044
1155
  key = await snapshotKey(env, meta, this.plain ? 'worker' : atBoot ? 'durable boot-point' : 'durable');
1045
- snapBytes = await snapshots.get(env, key);
1046
- if (!snapBytes && !snapshots.unavailable && atBoot) this.bootKey = key;
1156
+ const stored = await snapshots.get(env, key);
1157
+ snap = stored && parseSnapshot(stored);
1158
+ if (!snap && !snapshots.unavailable && atBoot && !fallback) this.bootKey = key;
1047
1159
  }
1048
- const snap = snapBytes && parseSnapshot(snapBytes);
1160
+ const fromFallback = !snap && !!fallback;
1161
+ if (fromFallback) snap = fallback;
1162
+ let snapBytes = snap ? snap.pagesData : null;
1049
1163
  const nifs = await nifModules();
1050
1164
  // restored: this VM comes from a snapshot (the page shows it).
1051
1165
  this.restored = !!snap;
1052
1166
  this.bootPointSnap = !!snap?.boot_point;
1053
- // No snapshot yet: this VM makes it, before its first request.
1054
- this.makeKey = !snap && !snapshots.unavailable && !this.bootKey && key;
1167
+ // No snapshot yet (or only the one of the build at the boot point):
1168
+ // this VM makes it, before its first request.
1169
+ this.makeKey = (!snap || (fromFallback && !atBoot)) && !snapshots.unavailable && !this.bootKey && key;
1055
1170
  this.release = release;
1056
1171
  const t1 = Date.now();
1057
1172
  return new Promise((resolve, reject) => {
@@ -1061,7 +1176,8 @@ export class Vm {
1061
1176
  noInitialRun: !!snap,
1062
1177
  onRuntimeInitialized: snap ? () => {
1063
1178
  try {
1064
- this.listeners = new Map(Object.entries(snap.listeners ?? {}).map(([p, id]) => [Number(p), id]));
1179
+ this.listeners = new Map(Object.entries(snap.listeners ?? {})
1180
+ .map(([p, id]) => [/^\d+$/.test(p) ? Number(p) : p, id]));
1065
1181
  restore(this.beam, this.exports, snap);
1066
1182
  // In the global scope (the bytes given), the first request
1067
1183
  // starts the threads. A snapshot of the boot point then goes on
@@ -1137,7 +1253,7 @@ export class Vm {
1137
1253
  // Livebook starts with 40 MB, not 70 MB, but :erlang.memory/0 is
1138
1254
  // not supported).
1139
1255
  const flags = (env.BEAM_ERL_FLAGS ?? '').split(/\s+/).filter(Boolean);
1140
- m.arguments.push('-S', '1', '-SDcpu', '1', '-A', '0', ...flags, ...(this.makeKey || this.bootKey ? ['-c', 'false'] : []), '--',
1256
+ m.arguments.push('-S', '1', '-SDcpu', '1', '-A', '0', ...flags, ...(this.makeKey || this.bootKey || this.capture ? ['-c', 'false'] : []), '--',
1141
1257
  '-root', '/app', '-bindir', '/app/bin', '-progname', 'erl', '--',
1142
1258
  '-home', '/', ...args, '-noshell');
1143
1259
  // Distributed Erlang over wasm_tcp, with no epmd (all nodes on
@@ -1201,6 +1317,9 @@ export class Vm {
1201
1317
  this.bootKey = null;
1202
1318
  if (key) {
1203
1319
  const bytes = await this.snapshot(true);
1320
+ // The snapshot of the build (or 'busy', or null): the tool stops this
1321
+ // VM, so the boot does not go on.
1322
+ if (this.capture) return this.gotCapture(bytes);
1204
1323
  if (bytes && bytes !== 'busy') this.store(key, bytes);
1205
1324
  else console.log(`beam: no snapshot at the boot point (${bytes})`);
1206
1325
  }
@@ -1212,6 +1331,7 @@ export class Vm {
1212
1331
  }
1213
1332
 
1214
1333
  store(key, bytes) {
1334
+ if (this.capture) return this.gotCapture(bytes);
1215
1335
  console.log(`beam: snapshot ${bytes.length >> 10} KB (${key.slice(0, 12)})`);
1216
1336
  const put = snapshots.put(this.env, key, bytes).catch((e) => console.log(`beam: snapshot not stored: ${e.message}`));
1217
1337
  this.waitUntil?.(put);
@@ -1223,7 +1343,7 @@ export class Vm {
1223
1343
  async snapshot(bootPoint) {
1224
1344
  const x = this.exports;
1225
1345
  const tick = () => new Promise((r) => setTimeout(r, 1));
1226
- const busy = () => this.sqlPending > 0 || this.tcps.size > 0;
1346
+ const busy = () => this.sqlPending > 0 || this.tcps.size > 0 || this.fetchPending > 0;
1227
1347
  // Data in a pipe (an event of the host that Erlang did not take yet,
1228
1348
  // or a wake-up of ERTS) would not be in the snapshot either.
1229
1349
  const unread = () => this.beam.FS.streams.some((st) => st?.node?.pipe?.buckets.some((b) => b.offset > b.roffset));
@@ -1509,6 +1629,11 @@ export class Vm {
1509
1629
  const id = `b${this.nextId++}`;
1510
1630
  const headers = new Headers(request.headers);
1511
1631
  headers.set('host', url.host);
1632
+ // The scheme of the client: the app gets a TCP connection with no TLS,
1633
+ // and Plug.SSL (force_ssl of Phoenix) reads x-forwarded-proto, as on
1634
+ // Deno and in a web page. The value of the Worker replaces the value
1635
+ // that a client sends. The web page gives its own value (scheme: false).
1636
+ if (this.scheme) headers.set('x-forwarded-proto', url.protocol.slice(0, -1));
1512
1637
  const origin = this.appOrigin(headers.get('origin'), url);
1513
1638
  if (origin) headers.set('origin', origin);
1514
1639
  headers.delete('transfer-encoding');
@@ -1704,11 +1829,17 @@ export class Vm {
1704
1829
 
1705
1830
  // A TCP socket of wasm_tcp: net.connect() of node:net. In a plain
1706
1831
  // Worker it belongs to the handler h, and closes with that request.
1707
- async tcpConnect({ id, host, port }, h) {
1832
+ // A connect of the VM (specs/FetchPath.tla). HTTP goes through fetch()
1833
+ // first (fetchFirst); the other protocols use connect(). direct: the
1834
+ // tunnel of wasm_host_fetch, which never goes back to the fetch path.
1835
+ async tcpConnect({ id, host, port, direct }, h) {
1708
1836
  let socket;
1709
1837
  if (h) h.sockets++;
1838
+ const allowed = connectAllowed(this.env.BEAM_CONNECT, host, port);
1839
+ const fetchPath = this.listeners.has('fetch') && !direct;
1840
+ if (allowed && fetchPath && this.fetchFirst(host, port)) return this.fetchPair(id, host, port, h);
1710
1841
  try {
1711
- if (!connectAllowed(this.env.BEAM_CONNECT, host, port)) throw new Error('not in BEAM_CONNECT');
1842
+ if (!allowed) throw new Error('not in BEAM_CONNECT');
1712
1843
  socket = net.connect({ host, port });
1713
1844
  // A send resolves when the bytes are written; a close sends the
1714
1845
  // bytes that wait, and then ends the socket.
@@ -1725,6 +1856,15 @@ export class Vm {
1725
1856
  console.log(`beam: connect ${host}:${port}: ${e.message}`);
1726
1857
  socket?.destroy();
1727
1858
  this.tcps.delete(id);
1859
+ // connect() of Cloudflare fails for a host behind Cloudflare, with the
1860
+ // error of any other failure: the addresses of the name tell them
1861
+ // apart. A host of Cloudflare goes through fetch() (specs/FetchPath.tla).
1862
+ // Only on Cloudflare: Deno and a web page have no such block.
1863
+ const cloudflare = (this.env.BEAM_HOST ?? 'cloudflare') === 'cloudflare';
1864
+ if (allowed && fetchPath && cloudflare && (port === 443 || port === 80) && await this.isCloudflare(host)) {
1865
+ console.log(`beam: connect ${host}:${port}: through fetch()`);
1866
+ return this.fetchPair(id, host, port, h);
1867
+ }
1728
1868
  this.event({ t: 'tcp_error', id, reason: 'econnrefused' });
1729
1869
  if (h) { h.sockets--; h.wake?.(); }
1730
1870
  return;
@@ -1739,6 +1879,101 @@ export class Vm {
1739
1879
  if (h) { h.sockets--; h.wake?.(); }
1740
1880
  }
1741
1881
 
1882
+ // BEAM_FETCH: the hosts and ports whose connect goes to fetch() with no
1883
+ // connect(), with the rules of BEAM_CONNECT. An empty BEAM_FETCH: none.
1884
+ // With no BEAM_FETCH: port 80, and port 443 when the trust store of the
1885
+ // VM holds the CA of wasm_host_fetch (a build with --cacerts).
1886
+ fetchFirst(host, port) {
1887
+ const list = this.env.BEAM_FETCH;
1888
+ if (list !== undefined) return connectAllowed(list, host, port);
1889
+ return port === 80 || (port === 443 && this.listeners.has('fetch-tls'));
1890
+ }
1891
+
1892
+ // The name is a host of Cloudflare: one of its addresses is in the ranges
1893
+ // of Cloudflare. The addresses come from DNS over HTTPS (a fetch(), which
1894
+ // reaches Cloudflare), and stay 5 minutes.
1895
+ async isCloudflare(host) {
1896
+ if (ipBits(host)) return inRanges(host);
1897
+ const name = String(host).toLowerCase().replace(/\.$/, '');
1898
+ const now = Date.now(), seen = this.dns.get(name);
1899
+ if (seen && now - seen.at < 300000) return seen.addresses.some((a) => inRanges(a));
1900
+ const addresses = [];
1901
+ try {
1902
+ for (const type of ['A', 'AAAA']) {
1903
+ const r = await fetch(`https://cloudflare-dns.com/dns-query?name=${encodeURIComponent(name)}&type=${type}`,
1904
+ { headers: { accept: 'application/dns-json' } });
1905
+ const answer = r.ok ? (await r.json()).Answer ?? [] : [];
1906
+ for (const a of answer) if (a.type === 1 || a.type === 28) addresses.push(a.data);
1907
+ }
1908
+ } catch (e) {
1909
+ console.log(`beam: resolve ${name}: ${e.message}`);
1910
+ }
1911
+ this.dns.set(name, { at: now, addresses });
1912
+ return addresses.some((a) => inRanges(a));
1913
+ }
1914
+
1915
+ // The fetch path: the socket id of the program and a connection of the
1916
+ // listener of wasm_host_fetch, as a pair in the host. The program sees
1917
+ // an open connection to host:port. The pair ends when one side closes.
1918
+ async fetchPair(id, host, port, h) {
1919
+ const conn = `x${this.nextId++}`;
1920
+ let done;
1921
+ const ended = new Promise((r) => { done = r; });
1922
+ const end = (other) => {
1923
+ if (!this.fetchConns.has(conn)) return;
1924
+ this.fetchConns.delete(conn);
1925
+ this.tcps.delete(id);
1926
+ this.tcps.delete(conn);
1927
+ this.event({ t: 'tcp_closed', id: other });
1928
+ done();
1929
+ };
1930
+ this.fetchConns.set(conn, { host, port, h });
1931
+ this.tcps.set(id, { send: (b) => this.event({ t: 'tcp_data', id: conn }, b), close: () => end(conn), h });
1932
+ this.tcps.set(conn, { send: (b) => this.event({ t: 'tcp_data', id }, b), close: () => end(id), h });
1933
+ this.event({ t: 'tcp_open', id });
1934
+ this.event({ t: 'tcp_accept', id: this.listeners.get('fetch'), conn, host, port });
1935
+ await ended;
1936
+ if (h) { h.sockets--; h.wake?.(); }
1937
+ }
1938
+
1939
+ // A request of wasm_host_fetch: fetch() of the URL of the host and the
1940
+ // port of the connect, and the response as fetch_head, fetch_data and
1941
+ // fetch_end (or fetch_error). fetch() gives the body decoded.
1942
+ async fetchRequest(msg, body, h) {
1943
+ const c = this.fetchConns.get(msg.conn);
1944
+ if (h) h.sockets++;
1945
+ this.fetchPending++;
1946
+ const ac = new AbortController();
1947
+ this.fetches.set(msg.id, ac);
1948
+ try {
1949
+ const url = c && fetchUrl(c.host, c.port, msg.tls, msg.path);
1950
+ if (!url) throw new Error(c ? 'the path is not a path of the host' : 'no connection');
1951
+ const headers = new Headers();
1952
+ for (const [k, v] of msg.headers ?? []) headers.append(k, v);
1953
+ const init = { method: msg.method, headers, redirect: 'manual', signal: ac.signal };
1954
+ if (!['GET', 'HEAD'].includes(msg.method)) init.body = body;
1955
+ const res = await fetch(url, init);
1956
+ const out = [...res.headers].filter(([k]) => k !== 'set-cookie');
1957
+ for (const v of res.headers.getSetCookie?.() ?? []) out.push(['set-cookie', v]);
1958
+ this.event({ t: 'fetch_head', id: msg.id, status: res.status, reason: res.statusText, headers: out });
1959
+ if (res.body) {
1960
+ const reader = res.body.getReader();
1961
+ for (;;) {
1962
+ const { done, value } = await reader.read();
1963
+ if (done) break;
1964
+ if (value?.byteLength) this.event({ t: 'fetch_data', id: msg.id }, value);
1965
+ }
1966
+ }
1967
+ this.event({ t: 'fetch_end', id: msg.id });
1968
+ } catch (e) {
1969
+ this.event({ t: 'fetch_error', id: msg.id, message: String(e?.message ?? e) });
1970
+ } finally {
1971
+ this.fetches.delete(msg.id);
1972
+ this.fetchPending--;
1973
+ if (h) { h.sockets--; h.wake?.(); }
1974
+ }
1975
+ }
1976
+
1742
1977
  // Data of a TCP socket: to Erlang, or to its peer after a splice.
1743
1978
  tcpData(id, bytes) {
1744
1979
  const t = this.tcps.get(id);
@@ -1786,6 +2021,14 @@ export class Vm {
1786
2021
  this.run(() => this.wasmRequest(msg, body, h), h);
1787
2022
  break;
1788
2023
  }
2024
+ case 'fetch': {
2025
+ const h = this.fetchConns.get(msg.conn)?.h ?? this.handlers.at(-1);
2026
+ this.run(() => this.fetchRequest(msg, body, h), h);
2027
+ break;
2028
+ }
2029
+ case 'fetch_cancel':
2030
+ this.fetches.get(msg.id)?.abort();
2031
+ break;
1789
2032
  case 'tcp_send': {
1790
2033
  const t = this.tcps.get(msg.id);
1791
2034
  if (t) this.run(() => t.send(body), t.h);
@@ -1804,8 +2047,14 @@ export class Vm {
1804
2047
  if (a && b) { a.peer = msg.b; b.peer = msg.a; }
1805
2048
  break;
1806
2049
  }
2050
+ // The listener of wasm_host_fetch: apart from the ports, so that no
2051
+ // WebSocket to /.tcp/PORT reaches it.
1807
2052
  case 'tcp_listen':
1808
- if (this.listeners.has(msg.port)) {
2053
+ if (msg.fetch) {
2054
+ this.listeners.set('fetch', msg.id);
2055
+ if (msg.tls) this.listeners.set('fetch-tls', msg.id);
2056
+ this.event({ t: 'tcp_listening', id: msg.id });
2057
+ } else if (this.listeners.has(msg.port)) {
1809
2058
  this.event({ t: 'tcp_error', id: msg.id, reason: 'eaddrinuse' });
1810
2059
  } else {
1811
2060
  this.listeners.set(msg.port, msg.id);
@@ -2,7 +2,11 @@
2
2
  // serve(app) of deno.js, in place of a path (the first argument of
3
3
  // deno.js, or BEAM_APP). release-bin.js reads them at the first request.
4
4
  export let app = null;
5
+ // false: the static files of a Phoenix app stay in the release of the VM
6
+ // (the option statics of serve(app)).
7
+ export let statics = true;
5
8
 
6
- export function use(bytes) {
9
+ export function use(bytes, split = true) {
7
10
  app = bytes;
11
+ statics = split;
8
12
  }
@@ -4,14 +4,14 @@
4
4
  // else the path in the first argument of deno.js, else BEAM_APP): the
5
5
  // files of the release of that file (its edge part, appFiles of
6
6
  // app-com.js), for this runtime only.
7
- import { app as given } from './app.js';
7
+ import { app as given, statics } from './app.js';
8
8
 
9
9
  async function release() {
10
10
  if (given) {
11
11
  const [{ appFiles, bytesReader }, { default: runtime }] =
12
12
  await Promise.all([import('../app-com.js'), import('../runtime-id.js')]);
13
13
  const { read, size } = bytesReader(given);
14
- return appFiles(read, size, { runtime });
14
+ return appFiles(read, size, { runtime, statics });
15
15
  }
16
16
  const app = Deno.args[0] ?? Deno.env.get('BEAM_APP');
17
17
  if (app) {