beam.com 0.1.0-rc.1
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/LICENSE +202 -0
- package/NOTICE +97 -0
- package/README.md +369 -0
- package/js/beam.com.mjs +33 -0
- package/js/download.mjs +78 -0
- package/js/node.mjs +75 -0
- package/package.json +36 -0
- package/runtime/app-com.js +203 -0
- package/runtime/beam.mjs +2 -0
- package/runtime/beam.wasm +0 -0
- package/runtime/browser/beam-wasm.js +2 -0
- package/runtime/browser/net.js +7 -0
- package/runtime/browser/none.js +4 -0
- package/runtime/browser.js +161 -0
- package/runtime/cloudflare/durable.js +361 -0
- package/runtime/cloudflare/index.js +76 -0
- package/runtime/cloudflare/release.js +42 -0
- package/runtime/cloudflare/snapshot.js +3 -0
- package/runtime/cloudflare/worker.js +1891 -0
- package/runtime/deno/app.js +8 -0
- package/runtime/deno/beam-wasm.js +3 -0
- package/runtime/deno/none.js +3 -0
- package/runtime/deno/release-bin.js +46 -0
- package/runtime/deno/snapshot-bin.js +11 -0
- package/runtime/deno/worker.js +1891 -0
- package/runtime/deno.js +346 -0
- package/runtime/deno.json +5 -0
- package/runtime/durable-global.js +40 -0
- package/runtime/durable.js +361 -0
- package/runtime/global.js +26 -0
- package/runtime/licenses/LICENSE +202 -0
- package/runtime/licenses/NOTICE +97 -0
- package/runtime/licenses/bcrypt_elixir.txt +87 -0
- package/runtime/licenses/cosmopolitan.txt +16 -0
- package/runtime/licenses/elixir/Apache-2.0.txt +73 -0
- package/runtime/licenses/elixir/LicenseRef-elixir-trademark-policy.txt +98 -0
- package/runtime/licenses/elixir/LicenseRef-scancode-unicode.txt +58 -0
- package/runtime/licenses/emscripten.txt +102 -0
- package/runtime/licenses/exqlite.txt +21 -0
- package/runtime/licenses/llvm.txt +311 -0
- package/runtime/licenses/musl.txt +193 -0
- package/runtime/licenses/otp/BSD-2-Clause.txt +22 -0
- package/runtime/licenses/otp/BSD-3-Clause.txt +25 -0
- package/runtime/licenses/otp/LicenseRef-IETF-MIB.txt +9 -0
- package/runtime/licenses/otp/LicenseRef-RSA-PKCS3.txt +23 -0
- package/runtime/licenses/otp/LicenseRef-RSA-PKCS5v2-0.txt +17 -0
- package/runtime/licenses/otp/LicenseRef-scancode-ietf-trust.txt +296 -0
- package/runtime/licenses/otp/LicenseRef-scancode-ietf.txt +23 -0
- package/runtime/licenses/otp/LicenseRef-scancode-w3c-docs-19990405.txt +26 -0
- package/runtime/licenses/otp/LicenseRef-scancode-wxwindows-free-doc-3.txt +57 -0
- package/runtime/licenses/otp/MIT.txt +17 -0
- package/runtime/licenses/otp/SGI-B-2.0.txt +25 -0
- package/runtime/licenses/otp/W3C-19980720.txt +55 -0
- package/runtime/licenses/picosat.txt +19 -0
- package/runtime/licenses/wamr.txt +219 -0
- package/runtime/nifs.js +2 -0
- package/runtime/package.json +1 -0
- package/runtime/page/404.html +49 -0
- package/runtime/page/app-com.js +203 -0
- package/runtime/page/beam.mjs +2 -0
- package/runtime/page/browser/beam-wasm.js +2 -0
- package/runtime/page/browser/net.js +7 -0
- package/runtime/page/browser/none.js +4 -0
- package/runtime/page/browser.js +161 -0
- package/runtime/page/index.html +50 -0
- package/runtime/page/licenses/LICENSE +202 -0
- package/runtime/page/licenses/NOTICE +97 -0
- package/runtime/page/licenses/bcrypt_elixir.txt +87 -0
- package/runtime/page/licenses/cosmopolitan.txt +16 -0
- package/runtime/page/licenses/elixir/Apache-2.0.txt +73 -0
- package/runtime/page/licenses/elixir/LicenseRef-elixir-trademark-policy.txt +98 -0
- package/runtime/page/licenses/elixir/LicenseRef-scancode-unicode.txt +58 -0
- package/runtime/page/licenses/emscripten.txt +102 -0
- package/runtime/page/licenses/exqlite.txt +21 -0
- package/runtime/page/licenses/llvm.txt +311 -0
- package/runtime/page/licenses/musl.txt +193 -0
- package/runtime/page/licenses/otp/BSD-2-Clause.txt +22 -0
- package/runtime/page/licenses/otp/BSD-3-Clause.txt +25 -0
- package/runtime/page/licenses/otp/LicenseRef-IETF-MIB.txt +9 -0
- package/runtime/page/licenses/otp/LicenseRef-RSA-PKCS3.txt +23 -0
- package/runtime/page/licenses/otp/LicenseRef-RSA-PKCS5v2-0.txt +17 -0
- package/runtime/page/licenses/otp/LicenseRef-scancode-ietf-trust.txt +296 -0
- package/runtime/page/licenses/otp/LicenseRef-scancode-ietf.txt +23 -0
- package/runtime/page/licenses/otp/LicenseRef-scancode-w3c-docs-19990405.txt +26 -0
- package/runtime/page/licenses/otp/LicenseRef-scancode-wxwindows-free-doc-3.txt +57 -0
- package/runtime/page/licenses/otp/MIT.txt +17 -0
- package/runtime/page/licenses/otp/SGI-B-2.0.txt +25 -0
- package/runtime/page/licenses/otp/W3C-19980720.txt +55 -0
- package/runtime/page/licenses/picosat.txt +19 -0
- package/runtime/page/licenses/wamr.txt +219 -0
- package/runtime/page/main.js +176 -0
- package/runtime/page/runtime-id.js +3 -0
- package/runtime/page/scope.js +17 -0
- package/runtime/page/sw.js +111 -0
- package/runtime/page/vm.js +148 -0
- package/runtime/page/worker.js +1891 -0
- package/runtime/page/ws-shim.js +143 -0
- package/runtime/release/app.js +17 -0
- package/runtime/release.json +1 -0
- package/runtime/runtime-id.js +3 -0
- package/runtime/tcp-proxy.mjs +30 -0
- package/runtime/worker.js +1891 -0
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
// The VM in one Durable Object, in place of one VM for each isolate: one
|
|
2
|
+
// instance for all the requests (a server whose state must be in one
|
|
3
|
+
// place), which runs all the time while it is in memory, and whose SQLite
|
|
4
|
+
// storage is the database of Ecto SQLite. All the requests go to the object
|
|
5
|
+
// "main" (BEAM_OBJECT names another one).
|
|
6
|
+
//
|
|
7
|
+
// Tenants: with the var BEAM_TENANTS, each tenant has its own object (its
|
|
8
|
+
// own VM, state and SQLite storage), and the request header x-beam-tenant
|
|
9
|
+
// gives its name to the app:
|
|
10
|
+
// - "cookie": GET /.tenant/NAME sets the cookie beam_tenant, and the cookie
|
|
11
|
+
// names the object (for a workers.dev URL);
|
|
12
|
+
// - "host": the first label of the host names the object (NAME.example.com);
|
|
13
|
+
// - "path": /t/NAME/... names the object. The front Worker removes the
|
|
14
|
+
// prefix, and the VM gets BEAM_TENANT=NAME and BEAM_TENANT_PATH=/t/NAME,
|
|
15
|
+
// the base path of the URLs that the app writes. A request from an
|
|
16
|
+
// iframe of another site has no cookie, but it has its path.
|
|
17
|
+
// A name has 1 to 32 characters: a-z, 0-9 and "-".
|
|
18
|
+
//
|
|
19
|
+
// Instances (path tenants, with the var BEAM_INSTANCES): each visitor can
|
|
20
|
+
// start an instance (the button of the page of /), with a random name and
|
|
21
|
+
// a time limit. At the limit, the object deletes its storage and stops.
|
|
22
|
+
// The object ".registry" admits the visitors:
|
|
23
|
+
// - BEAM_INSTANCES: the instances at one time (then a queue);
|
|
24
|
+
// - BEAM_INSTANCE_TTL: the life of an instance in seconds (1800);
|
|
25
|
+
// - BEAM_INSTANCE_HOURS: the hours of instances in each UTC day (24), a
|
|
26
|
+
// bound for the Durable Objects of the Free plan;
|
|
27
|
+
// - BEAM_INSTANCES_PER_IP: the instances at one time for one address (2).
|
|
28
|
+
// The VM gets BEAM_INSTANCE_EXPIRES (Unix seconds). A cron trigger (each
|
|
29
|
+
// 30 minutes) calls sweep(): the registry deletes the storage of each
|
|
30
|
+
// instance past its limit (if its alarm did not), and once the storage of
|
|
31
|
+
// each object that BEAM_RETIRE names ("a,b,c": objects of an earlier mode,
|
|
32
|
+
// which the registry did not make).
|
|
33
|
+
//
|
|
34
|
+
// wrangler deploy -c wrangler.durable.jsonc
|
|
35
|
+
import { DurableObject } from 'cloudflare:workers';
|
|
36
|
+
import { Vm } from './worker.js';
|
|
37
|
+
|
|
38
|
+
const valid = (name) => /^[a-z0-9-]{1,32}$/.test(name ?? '');
|
|
39
|
+
const REGISTRY = '.registry';
|
|
40
|
+
const EXPIRES = 'beam:expires';
|
|
41
|
+
|
|
42
|
+
export class Beam extends DurableObject {
|
|
43
|
+
constructor(ctx, env) {
|
|
44
|
+
super(ctx, env);
|
|
45
|
+
this.vm = null;
|
|
46
|
+
this.expires = undefined;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// The VM starts at the first request: a path tenant gives its name there.
|
|
50
|
+
// The storage of the object keeps the secrets that the VM makes, and the
|
|
51
|
+
// host of the request is the default PHX_HOST (Vm.autoVars).
|
|
52
|
+
makeVm(vars, host) {
|
|
53
|
+
return new Vm(this.env, { plain: false, sql: this.ctx.storage.sql, id: this.ctx.id.toString(), vars,
|
|
54
|
+
secrets: this.ctx.storage, host });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async fetch(request) {
|
|
58
|
+
if (this.env.BEAM_INSTANCES && this.env.BEAM_TENANTS === 'path') {
|
|
59
|
+
this.expires ??= (await this.ctx.storage.get(EXPIRES)) ?? null;
|
|
60
|
+
if (!this.expires || this.expires <= Date.now()) {
|
|
61
|
+
if (this.expires) await this.expire();
|
|
62
|
+
return page(410, 'This instance is not available', `<p>An instance stops at its time limit, and its files are deleted.</p>
|
|
63
|
+
<form method="post" action="/.instance"><button>Start a new instance</button></form>`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (!this.vm) {
|
|
67
|
+
const vars = {};
|
|
68
|
+
const tenant = request.headers.get('x-beam-tenant');
|
|
69
|
+
if (this.env.BEAM_TENANTS === 'path' && valid(tenant)) {
|
|
70
|
+
vars.BEAM_TENANT = tenant;
|
|
71
|
+
vars.BEAM_TENANT_PATH = `/t/${tenant}`;
|
|
72
|
+
}
|
|
73
|
+
if (this.expires) vars.BEAM_INSTANCE_EXPIRES = String(Math.floor(this.expires / 1000));
|
|
74
|
+
this.vm = this.makeVm(vars, new URL(request.url).hostname);
|
|
75
|
+
}
|
|
76
|
+
return this.vm.fetch(request);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// An instance: its time limit (from the registry).
|
|
80
|
+
async start(expires) {
|
|
81
|
+
await this.ctx.storage.put(EXPIRES, expires);
|
|
82
|
+
await this.ctx.storage.setAlarm(expires);
|
|
83
|
+
this.expires = expires;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async alarm() {
|
|
87
|
+
await this.expire();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// The end of an instance: its storage (the files of BEAM_PERSIST, the
|
|
91
|
+
// database) is deleted, and the VM stops.
|
|
92
|
+
async expire() {
|
|
93
|
+
this.expires = null;
|
|
94
|
+
await this.ctx.storage.deleteAll();
|
|
95
|
+
if (this.vm) setTimeout(() => this.ctx.abort('the instance expired'), 0);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// The registry (the object ".registry") admits a visitor: a ticket and a
|
|
99
|
+
// hash of its address. It gives {name, expires} of a new or running
|
|
100
|
+
// instance, the place in the queue, or a limit.
|
|
101
|
+
async admit(ticket, address) {
|
|
102
|
+
const env = this.env, sql = this.ctx.storage.sql, now = Date.now();
|
|
103
|
+
// A limit that is not a number is the default (NaN would be no limit).
|
|
104
|
+
const max = limit(env.BEAM_INSTANCES, 1);
|
|
105
|
+
const ttl = limit(env.BEAM_INSTANCE_TTL, 1800);
|
|
106
|
+
const hours = limit(env.BEAM_INSTANCE_HOURS, 24);
|
|
107
|
+
const perAddress = limit(env.BEAM_INSTANCES_PER_IP, 2);
|
|
108
|
+
this.tables();
|
|
109
|
+
// A visitor that did not refresh its page for 60 s left the queue.
|
|
110
|
+
sql.exec('DELETE FROM beam_waiting WHERE seen < ?', now - 60000);
|
|
111
|
+
// The rows of the instances past their limit stay until sweep().
|
|
112
|
+
const one = (q, ...a) => [...sql.exec(q, ...a)][0];
|
|
113
|
+
const mine = one('SELECT name, expires FROM beam_instances WHERE ticket = ? AND expires > ?', ticket, now);
|
|
114
|
+
if (mine) return { name: mine.name, expires: mine.expires, running: true };
|
|
115
|
+
const count = one('SELECT count(*) AS n FROM beam_instances WHERE expires > ?', now).n;
|
|
116
|
+
const next = one('SELECT min(expires) AS t FROM beam_instances WHERE expires > ?', now).t;
|
|
117
|
+
if (one('SELECT count(*) AS n FROM beam_instances WHERE address = ? AND expires > ?', address, now).n >= perAddress) {
|
|
118
|
+
return { limit: 'address', next };
|
|
119
|
+
}
|
|
120
|
+
const day = new Date(now).toISOString().slice(0, 10);
|
|
121
|
+
const used = one('SELECT seconds FROM beam_budget WHERE day = ?', day)?.seconds ?? 0;
|
|
122
|
+
if (used + ttl > hours * 3600) return { limit: 'day' };
|
|
123
|
+
// An address has at most perAddress places in the queue, so that one
|
|
124
|
+
// client with many tickets cannot fill it.
|
|
125
|
+
const queued = one('SELECT count(*) AS n FROM beam_waiting WHERE address = ? AND ticket <> ?', address, ticket).n;
|
|
126
|
+
if (queued >= perAddress) return { limit: 'address', next };
|
|
127
|
+
sql.exec('INSERT INTO beam_waiting VALUES (?, ?, ?, ?) ON CONFLICT(ticket) DO UPDATE SET seen = excluded.seen',
|
|
128
|
+
ticket, address, now, now);
|
|
129
|
+
const since = one('SELECT since FROM beam_waiting WHERE ticket = ?', ticket).since;
|
|
130
|
+
const position = one('SELECT count(*) AS n FROM beam_waiting WHERE since < ? OR (since = ? AND ticket < ?)',
|
|
131
|
+
since, since, ticket).n + 1;
|
|
132
|
+
if (position > max - count) {
|
|
133
|
+
return { position, waiting: one('SELECT count(*) AS n FROM beam_waiting').n, next };
|
|
134
|
+
}
|
|
135
|
+
const name = randomName();
|
|
136
|
+
const expires = now + ttl * 1000;
|
|
137
|
+
sql.exec('INSERT INTO beam_instances VALUES (?, ?, ?, ?)', name, ticket, address, expires);
|
|
138
|
+
sql.exec('DELETE FROM beam_waiting WHERE ticket = ?', ticket);
|
|
139
|
+
sql.exec('DELETE FROM beam_budget WHERE day < ?', day);
|
|
140
|
+
sql.exec('INSERT INTO beam_budget VALUES (?, ?) ON CONFLICT(day) DO UPDATE SET seconds = seconds + excluded.seconds',
|
|
141
|
+
day, ttl);
|
|
142
|
+
return { name, expires };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// The registry: deletes the storage of the instances past their limit,
|
|
146
|
+
// and of the objects that BEAM_RETIRE names (once). Gives the counts.
|
|
147
|
+
async sweep() {
|
|
148
|
+
const env = this.env, sql = this.ctx.storage.sql, now = Date.now();
|
|
149
|
+
this.tables();
|
|
150
|
+
const stub = (name) => env.BEAM.get(env.BEAM.idFromName(name));
|
|
151
|
+
let expired = 0, retired = 0;
|
|
152
|
+
// An error of one object does not stop the sweep of the others.
|
|
153
|
+
for (const { name } of [...sql.exec('SELECT name FROM beam_instances WHERE expires <= ?', now)]) {
|
|
154
|
+
try {
|
|
155
|
+
await stub(name).expire();
|
|
156
|
+
sql.exec('DELETE FROM beam_instances WHERE name = ?', name);
|
|
157
|
+
expired++;
|
|
158
|
+
} catch (e) {
|
|
159
|
+
console.log(`beam: sweep: ${name}: ${e}`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
const done = new Set([...sql.exec('SELECT name FROM beam_retired')].map((r) => r.name));
|
|
163
|
+
for (const name of (env.BEAM_RETIRE ?? '').split(',').map((n) => n.trim()).filter(valid)) {
|
|
164
|
+
if (done.has(name)) continue;
|
|
165
|
+
try {
|
|
166
|
+
await stub(name).expire();
|
|
167
|
+
sql.exec('INSERT INTO beam_retired VALUES (?, ?)', name, now);
|
|
168
|
+
retired++;
|
|
169
|
+
} catch (e) {
|
|
170
|
+
console.log(`beam: sweep: ${name}: ${e}`);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return { expired, retired };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// The registry: the instances in use, for the page of /.
|
|
177
|
+
async usage() {
|
|
178
|
+
this.tables();
|
|
179
|
+
const n = [...this.ctx.storage.sql.exec('SELECT count(*) AS n FROM beam_instances WHERE expires > ?', Date.now())][0].n;
|
|
180
|
+
return { used: n, max: Number(this.env.BEAM_INSTANCES), ttl: Number(this.env.BEAM_INSTANCE_TTL ?? 1800) };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
tables() {
|
|
184
|
+
const sql = this.ctx.storage.sql;
|
|
185
|
+
sql.exec('CREATE TABLE IF NOT EXISTS beam_instances (name TEXT PRIMARY KEY, ticket TEXT, address TEXT, expires INTEGER)');
|
|
186
|
+
sql.exec('CREATE TABLE IF NOT EXISTS beam_waiting (ticket TEXT PRIMARY KEY, address TEXT, since INTEGER, seen INTEGER)');
|
|
187
|
+
sql.exec('CREATE TABLE IF NOT EXISTS beam_budget (day TEXT PRIMARY KEY, seconds INTEGER)');
|
|
188
|
+
sql.exec('CREATE TABLE IF NOT EXISTS beam_retired (name TEXT PRIMARY KEY, time INTEGER)');
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// 20 characters of a-z and 2-7: 100 random bits.
|
|
193
|
+
function randomName() {
|
|
194
|
+
const abc = 'abcdefghijklmnopqrstuvwxyz234567';
|
|
195
|
+
return [...crypto.getRandomValues(new Uint8Array(20))].map((b) => abc[b & 31]).join('');
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// A limit of the vars: a number of 0 or more, else the default.
|
|
199
|
+
function limit(value, fallback) {
|
|
200
|
+
const n = Number(value);
|
|
201
|
+
return value !== undefined && value !== '' && Number.isFinite(n) && n >= 0 ? n : fallback;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// The address of a client for its limits: an IPv6 client has a whole /64,
|
|
205
|
+
// so the limits count the /64.
|
|
206
|
+
function clientKey(ip) {
|
|
207
|
+
if (!ip.includes(':')) return ip;
|
|
208
|
+
const [head, tail = ''] = ip.split('::');
|
|
209
|
+
const h = head ? head.split(':') : [], t = tail ? tail.split(':') : [];
|
|
210
|
+
const all = ip.includes('::') ? [...h, ...Array(8 - h.length - t.length).fill('0'), ...t] : h;
|
|
211
|
+
return all.slice(0, 4).map((x) => x.toLowerCase().replace(/^0+(?=.)/, '')).join(':') + '::/64';
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const hex = (bytes) => [...bytes].map((b) => b.toString(16).padStart(2, '0')).join('');
|
|
215
|
+
|
|
216
|
+
function cookie(request, name) {
|
|
217
|
+
return new RegExp(`(?:^|;\\s*)${name}=([^;]*)`).exec(request.headers.get('cookie') ?? '')?.[1];
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function tenant(request, env) {
|
|
221
|
+
if (env.BEAM_TENANTS === 'host') return new URL(request.url).hostname.split('.')[0];
|
|
222
|
+
if (env.BEAM_TENANTS === 'cookie') return cookie(request, 'beam_tenant');
|
|
223
|
+
return null;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c]);
|
|
227
|
+
|
|
228
|
+
function page(status, title, body, headers = {}) {
|
|
229
|
+
return new Response(`<!doctype html><html lang="en"><head><meta charset="utf-8">
|
|
230
|
+
<meta name="viewport" content="width=device-width, initial-scale=1"><title>${esc(title)}</title>
|
|
231
|
+
<style>body{font:16px/1.5 system-ui,sans-serif;max-width:40rem;margin:4rem auto;padding:0 1rem;color:#1f2937}
|
|
232
|
+
button{font:inherit;padding:.5rem 1rem;border-radius:.5rem;border:0;background:#4f46e5;color:#fff;cursor:pointer}</style>
|
|
233
|
+
</head><body><h1>${esc(title)}</h1>${body}</body></html>`,
|
|
234
|
+
{ status, headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', ...headers } });
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const minutes = (ms) => Math.max(1, Math.ceil(ms / 60000));
|
|
238
|
+
|
|
239
|
+
// A static file of the app, in the front Worker (no request to the
|
|
240
|
+
// object): BEAM_STATICS (serve(app) of the npm package gives it, for the
|
|
241
|
+
// static files in app.com), else, with assets, the static assets of the
|
|
242
|
+
// binding ASSETS (wasm/erts/host/static.mjs). Cloudflare serves the
|
|
243
|
+
// assets of the root before the Worker runs, so only a path tenant asks
|
|
244
|
+
// ASSETS (after its prefix).
|
|
245
|
+
async function staticFile(env, request, assets = false) {
|
|
246
|
+
if (request.method !== 'GET' && request.method !== 'HEAD') return null;
|
|
247
|
+
if (env.BEAM_STATICS) return env.BEAM_STATICS(request);
|
|
248
|
+
if (!assets || !env.ASSETS) return null;
|
|
249
|
+
const asset = await env.ASSETS.fetch(request);
|
|
250
|
+
return asset.status !== 404 ? asset : null;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// The instances: the page of /, and POST (the button) or GET (the refresh
|
|
254
|
+
// of the queue) of /.instance. Only a POST makes a ticket, so a crawler
|
|
255
|
+
// that reads / starts no instance.
|
|
256
|
+
async function instances(request, env, url) {
|
|
257
|
+
const registry = env.BEAM.get(env.BEAM.idFromName(REGISTRY));
|
|
258
|
+
if (url.pathname === '/' && request.method === 'GET') {
|
|
259
|
+
const { used, max, ttl } = await registry.usage();
|
|
260
|
+
return page(200, env.BEAM_INSTANCE_TITLE ?? 'Start an instance', `<p>Each visitor gets an instance of its own, for ${minutes(ttl * 1000)} minutes.
|
|
261
|
+
At the end, the instance stops, and its files are deleted.</p>
|
|
262
|
+
<p>${used} of ${max} instances are in use.</p>
|
|
263
|
+
<form method="post" action="/.instance"><button>Start an instance</button></form>`);
|
|
264
|
+
}
|
|
265
|
+
if (url.pathname !== '/.instance' || (request.method !== 'POST' && request.method !== 'GET')) {
|
|
266
|
+
return new Response('not found\n', { status: 404 });
|
|
267
|
+
}
|
|
268
|
+
// Only the navigation of a page (the button, the refresh) gets the name
|
|
269
|
+
// of an instance: a script of a page of another instance, on this same
|
|
270
|
+
// origin, cannot read it with fetch().
|
|
271
|
+
const mode = request.headers.get('sec-fetch-mode');
|
|
272
|
+
if (mode && mode !== 'navigate') return new Response('forbidden\n', { status: 403 });
|
|
273
|
+
let ticket = cookie(request, 'beam_ticket');
|
|
274
|
+
const headers = {};
|
|
275
|
+
if (!/^[0-9a-f]{32}$/.test(ticket ?? '')) {
|
|
276
|
+
if (request.method === 'GET') return Response.redirect(new URL('/', url), 303);
|
|
277
|
+
ticket = hex(crypto.getRandomValues(new Uint8Array(16)));
|
|
278
|
+
headers['set-cookie'] = `beam_ticket=${ticket}; Path=/; Secure; HttpOnly; SameSite=Lax`;
|
|
279
|
+
}
|
|
280
|
+
// A hash of the address and the day, not the address.
|
|
281
|
+
const day = new Date().toISOString().slice(0, 10);
|
|
282
|
+
const digest = await crypto.subtle.digest('SHA-256',
|
|
283
|
+
new TextEncoder().encode(`${clientKey(request.headers.get('cf-connecting-ip') ?? '')} ${day}`));
|
|
284
|
+
const r = await registry.admit(ticket, hex(new Uint8Array(digest).slice(0, 16)));
|
|
285
|
+
if (r.name) {
|
|
286
|
+
if (!r.running) await env.BEAM.get(env.BEAM.idFromName(r.name)).start(r.expires);
|
|
287
|
+
return new Response(null, { status: 303, headers: { ...headers, location: `/t/${r.name}/` } });
|
|
288
|
+
}
|
|
289
|
+
if (r.limit === 'day') {
|
|
290
|
+
return page(503, 'No more instances today', '<p>The instances of today are used. They start again at 00:00 UTC.</p>', headers);
|
|
291
|
+
}
|
|
292
|
+
const wait = r.next ? `The next instance stops in about ${minutes(r.next - Date.now())} minutes.` : '';
|
|
293
|
+
if (r.limit === 'address') {
|
|
294
|
+
return page(429, 'Too many instances', `<p>Your address has the most instances that it can have at one time. ${wait}</p>`, headers);
|
|
295
|
+
}
|
|
296
|
+
return page(200, 'You are in the queue', `<p>Your place: ${r.position} of ${r.waiting}. ${wait}</p>
|
|
297
|
+
<p>This page refreshes each 10 seconds. Keep it open to keep your place.</p>`,
|
|
298
|
+
{ ...headers, refresh: '10; url=/.instance' });
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
export default {
|
|
302
|
+
// The cron trigger of the instances (see sweep()).
|
|
303
|
+
async scheduled(controller, env, ctx) {
|
|
304
|
+
if (!env.BEAM_INSTANCES) return;
|
|
305
|
+
const registry = env.BEAM.get(env.BEAM.idFromName(REGISTRY));
|
|
306
|
+
ctx.waitUntil(registry.sweep().then((r) => console.log(`beam: sweep: ${r.expired} expired, ${r.retired} retired`)));
|
|
307
|
+
},
|
|
308
|
+
|
|
309
|
+
async fetch(request, env) {
|
|
310
|
+
const url = new URL(request.url);
|
|
311
|
+
if (env.BEAM_TENANTS === 'path') {
|
|
312
|
+
const m = /^\/t\/([^/]+)(\/.*)?$/.exec(url.pathname);
|
|
313
|
+
if (!m) {
|
|
314
|
+
// A path of the app with no prefix (Livebook writes some so).
|
|
315
|
+
const asset = await staticFile(env, request);
|
|
316
|
+
if (asset) return asset;
|
|
317
|
+
if (env.BEAM_INSTANCES) return instances(request, env, url);
|
|
318
|
+
return new Response('not found\n', { status: 404 });
|
|
319
|
+
}
|
|
320
|
+
if (!valid(m[1])) return new Response('bad tenant name\n', { status: 400 });
|
|
321
|
+
if (m[2] === undefined) return Response.redirect(new URL(`/t/${m[1]}/`, url), 301);
|
|
322
|
+
// The path of the app, with no prefix, on the same origin (a path
|
|
323
|
+
// "//host/..." must not change the host).
|
|
324
|
+
const inner = new URL(url);
|
|
325
|
+
inner.pathname = m[2];
|
|
326
|
+
const asset = await staticFile(env, new Request(inner, request), true);
|
|
327
|
+
if (asset) return asset;
|
|
328
|
+
// All the tenants share this origin. A service worker of one tenant
|
|
329
|
+
// could take the requests of the others, so none can register.
|
|
330
|
+
if (request.headers.get('service-worker') === 'script') {
|
|
331
|
+
return new Response('no service workers for tenants\n', { status: 403 });
|
|
332
|
+
}
|
|
333
|
+
// The app can trust the header: a client cannot give it.
|
|
334
|
+
request = new Request(inner, request);
|
|
335
|
+
request.headers.set('x-beam-tenant', m[1]);
|
|
336
|
+
const response = await env.BEAM.get(env.BEAM.idFromName(m[1])).fetch(request);
|
|
337
|
+
if (!response.headers.has('service-worker-allowed')) return response;
|
|
338
|
+
const out = new Response(response.body, response);
|
|
339
|
+
out.headers.delete('service-worker-allowed');
|
|
340
|
+
return out;
|
|
341
|
+
}
|
|
342
|
+
const set = env.BEAM_TENANTS === 'cookie' && /^\/\.tenant\/([^/]+)$/.exec(url.pathname);
|
|
343
|
+
if (set) {
|
|
344
|
+
if (!valid(set[1])) return new Response('bad tenant name\n', { status: 400 });
|
|
345
|
+
return new Response(null, {
|
|
346
|
+
status: 303,
|
|
347
|
+
headers: { location: '/', 'set-cookie': `beam_tenant=${set[1]}; Path=/; Secure; HttpOnly; SameSite=Lax` },
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
const asset = await staticFile(env, request);
|
|
351
|
+
if (asset) return asset;
|
|
352
|
+
let name = env.BEAM_OBJECT ?? 'main';
|
|
353
|
+
const t = tenant(request, env);
|
|
354
|
+
if (t !== undefined && t !== null && !valid(t)) return new Response('bad tenant name\n', { status: 400 });
|
|
355
|
+
name = t ?? name;
|
|
356
|
+
request = new Request(request);
|
|
357
|
+
if (env.BEAM_TENANTS) request.headers.set('x-beam-tenant', name);
|
|
358
|
+
else request.headers.delete('x-beam-tenant');
|
|
359
|
+
return env.BEAM.get(env.BEAM.idFromName(name)).fetch(request);
|
|
360
|
+
},
|
|
361
|
+
};
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// The module "beam.com" of the npm package in a Worker (the condition
|
|
2
|
+
// "workerd" of its exports, which Wrangler uses): the engine that runs the
|
|
3
|
+
// app.com of the project, with the runtime of this package. The entry of
|
|
4
|
+
// the Worker:
|
|
5
|
+
//
|
|
6
|
+
// import app from './app.com' with { type: 'bytes' };
|
|
7
|
+
// import { serve } from 'beam.com';
|
|
8
|
+
// export { Beam } from 'beam.com'; // stateful only
|
|
9
|
+
//
|
|
10
|
+
// const beam = serve(app);
|
|
11
|
+
//
|
|
12
|
+
// export default {
|
|
13
|
+
// fetch(request, env, ctx) {
|
|
14
|
+
// return beam.fetch(request, env, ctx);
|
|
15
|
+
// },
|
|
16
|
+
// };
|
|
17
|
+
//
|
|
18
|
+
// The app is stateful or stateless, as the Worker gives it:
|
|
19
|
+
// - stateful: with the export Beam and its binding (BEAM) in wrangler.jsonc,
|
|
20
|
+
// each request goes to one Durable Object (durable.js): one VM for all
|
|
21
|
+
// the requests, whose timers run between requests, and its SQLite
|
|
22
|
+
// storage for Ecto SQLite;
|
|
23
|
+
// - stateless: with no binding, each isolate runs its own VM (worker.js).
|
|
24
|
+
//
|
|
25
|
+
// wrangler.jsonc gives app.com to the Worker as a Data module:
|
|
26
|
+
// "rules": [{ "type": "Data", "globs": ["**/*.com"], "fallthrough": true }]
|
|
27
|
+
import { release, use } from './release.js';
|
|
28
|
+
import plain, { staticResponse } from './worker.js';
|
|
29
|
+
import front from './durable.js';
|
|
30
|
+
|
|
31
|
+
export { Beam } from './durable.js';
|
|
32
|
+
|
|
33
|
+
// options.binding: the binding of the Durable Object (BEAM). options.name:
|
|
34
|
+
// the name of the object, or a function of the request that gives it (a
|
|
35
|
+
// tenant, for example); the var BEAM_OBJECT, else "main", by default.
|
|
36
|
+
// options.nifs: the NIF libraries in WebAssembly of app.com, for an app
|
|
37
|
+
// that has them. A Worker cannot compile WebAssembly at run time, so
|
|
38
|
+
// "beam.com --nif-modules app.com ." writes nifs.js and nifs/, and the
|
|
39
|
+
// entry gives them:
|
|
40
|
+
//
|
|
41
|
+
// import nifs from './nifs.js';
|
|
42
|
+
// const beam = serve(app, { nifs });
|
|
43
|
+
//
|
|
44
|
+
// With the binding, the front of durable.js routes the requests: the
|
|
45
|
+
// tenants and the instances of its vars (BEAM_TENANTS, BEAM_INSTANCES),
|
|
46
|
+
// and the static assets of the binding ASSETS for path tenants. The
|
|
47
|
+
// instances also need scheduled() for their cron trigger (the sweep):
|
|
48
|
+
//
|
|
49
|
+
// scheduled(controller, env, ctx) {
|
|
50
|
+
// return beam.scheduled(controller, env, ctx);
|
|
51
|
+
// },
|
|
52
|
+
export function serve(app, { binding = 'BEAM', name, nifs = null } = {}) {
|
|
53
|
+
use(app, nifs);
|
|
54
|
+
// The env of the front: the binding as BEAM, the name of the object, and
|
|
55
|
+
// the static files of app.com (the front serves them, with no request
|
|
56
|
+
// to the object).
|
|
57
|
+
const statics = async (request) => {
|
|
58
|
+
const files = (await release()).statics;
|
|
59
|
+
return files ? staticResponse(files, request) : null;
|
|
60
|
+
};
|
|
61
|
+
const frontEnv = (env) => ({
|
|
62
|
+
...env, BEAM: env[binding], BEAM_OBJECT: name ?? env.BEAM_OBJECT, BEAM_STATICS: statics,
|
|
63
|
+
});
|
|
64
|
+
return {
|
|
65
|
+
fetch(request, env, ctx) {
|
|
66
|
+
const objects = env[binding];
|
|
67
|
+
if (!objects) return plain.fetch(request, env, ctx);
|
|
68
|
+
if (typeof name === 'function') return objects.getByName(name(request)).fetch(request);
|
|
69
|
+
return front.fetch(request, frontEnv(env), ctx);
|
|
70
|
+
},
|
|
71
|
+
scheduled(controller, env, ctx) {
|
|
72
|
+
if (!env[binding]) return;
|
|
73
|
+
return front.scheduled(controller, frontEnv(env), ctx);
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// The release of a Worker that Wrangler bundles (cloudflare/worker.js and
|
|
2
|
+
// cloudflare/durable.js): the edge part of the app.com of the project, as
|
|
3
|
+
// appFiles gives it (views of the file, no copy). serve(app) of index.js
|
|
4
|
+
// gives the file (use). It
|
|
5
|
+
// runs at the top level of the entry of the Worker, so each isolate has
|
|
6
|
+
// the file, also an isolate that runs only the Durable Object.
|
|
7
|
+
//
|
|
8
|
+
// The file must come from the beam.com of the version of this package:
|
|
9
|
+
// app-com.js refuses a file for another runtime. worker.js reads the
|
|
10
|
+
// release at the first request of an isolate, not in the global scope.
|
|
11
|
+
import { appFiles, bytesReader } from '../app-com.js';
|
|
12
|
+
import runtime from '../runtime-id.js';
|
|
13
|
+
|
|
14
|
+
let app = null;
|
|
15
|
+
let modules = null;
|
|
16
|
+
|
|
17
|
+
// nifModules: the NIF libraries in WebAssembly of app.com, compiled by
|
|
18
|
+
// Wrangler (nifs.js of "beam.com --nif-modules"), or null.
|
|
19
|
+
export function use(bytes, nifModules = null) {
|
|
20
|
+
app = bytes;
|
|
21
|
+
modules = nifModules;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// For worker.js: the path of each NIF library in /app, and its module.
|
|
25
|
+
export function nifs() {
|
|
26
|
+
return modules;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// One read of the file for each isolate: the front Worker and its VMs
|
|
30
|
+
// share the files (they are views of app.com).
|
|
31
|
+
let files = null;
|
|
32
|
+
|
|
33
|
+
export function release() {
|
|
34
|
+
if (!app) {
|
|
35
|
+
throw new Error("beam.com: no app.com: call serve(app) in the entry of the Worker");
|
|
36
|
+
}
|
|
37
|
+
if (!files) {
|
|
38
|
+
const { read, size } = bytesReader(app);
|
|
39
|
+
files = appFiles(read, size, { runtime });
|
|
40
|
+
}
|
|
41
|
+
return files;
|
|
42
|
+
}
|