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
|
@@ -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",
|
|
227
|
-
// resolves a name, so the VM cannot reach
|
|
228
|
-
// With no BEAM_CONNECT, the VM can connect to
|
|
229
|
-
|
|
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
|
-
|
|
1031
|
-
|
|
1032
|
-
//
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
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
|
-
|
|
1046
|
-
|
|
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
|
|
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
|
|
1054
|
-
this
|
|
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 ?? {})
|
|
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
|
-
|
|
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 (!
|
|
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 (
|
|
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);
|
package/runtime/deno/app.js
CHANGED
|
@@ -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) {
|