@statewalker/cellb 0.3.0 → 0.5.0
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 +46 -3
- package/bin/cellb.mjs +20 -0
- package/dist/browser.d.ts +2 -0
- package/dist/browser.js +1 -0
- package/dist/browser.js.map +1 -1
- package/dist/bucket/celld-cells.js +25 -3
- package/dist/bucket/celld-cells.js.map +1 -1
- package/dist/cli.d.ts +3 -0
- package/dist/cli.js +62 -0
- package/dist/cli.js.map +1 -0
- package/dist/isolate/browser.d.ts +2 -1
- package/dist/isolate/browser.js +2 -1
- package/dist/isolate/browser.js.map +1 -1
- package/dist/isolate/isolate.js +5 -3
- package/dist/isolate/isolate.js.map +1 -1
- package/dist/isolate/protocol.d.ts +4 -2
- package/dist/isolate/protocol.js +4 -4
- package/dist/isolate/protocol.js.map +1 -1
- package/dist/node.d.ts +3 -0
- package/dist/node.js +2 -0
- package/dist/node.js.map +1 -1
- package/dist/project/browser-host.d.ts +6 -12
- package/dist/project/browser-host.js +2 -25
- package/dist/project/browser-host.js.map +1 -1
- package/dist/project/browser-load.d.ts +22 -0
- package/dist/project/browser-load.js +63 -0
- package/dist/project/browser-load.js.map +1 -0
- package/dist/project/browser-shards.d.ts +8 -9
- package/dist/project/browser-shards.js +88 -52
- package/dist/project/browser-shards.js.map +1 -1
- package/dist/project/browser-tabs.d.ts +14 -4
- package/dist/project/browser-tabs.js +193 -208
- package/dist/project/browser-tabs.js.map +1 -1
- package/dist/project/build.d.ts +14 -0
- package/dist/project/build.js +53 -0
- package/dist/project/build.js.map +1 -0
- package/dist/project/built-project.d.ts +88 -0
- package/dist/project/built-project.js +118 -0
- package/dist/project/built-project.js.map +1 -0
- package/dist/project/node-load.d.ts +14 -0
- package/dist/project/node-load.js +187 -0
- package/dist/project/node-load.js.map +1 -0
- package/dist/project/wrangler.d.ts +9 -0
- package/dist/project/wrangler.js +24 -0
- package/dist/project/wrangler.js.map +1 -1
- package/dist/transport/cells-http.d.ts +6 -0
- package/dist/transport/cells-http.js +22 -1
- package/dist/transport/cells-http.js.map +1 -1
- package/dist/transport/cells-link.d.ts +2 -0
- package/dist/transport/cells-link.js +5 -0
- package/dist/transport/cells-link.js.map +1 -1
- package/dist/transport/port.js +4 -0
- package/dist/transport/port.js.map +1 -1
- package/dist/ws/server.d.ts +39 -23
- package/dist/ws/server.js +99 -140
- package/dist/ws/server.js.map +1 -1
- package/package.json +9 -4
|
@@ -16,20 +16,17 @@
|
|
|
16
16
|
* finish, new ones wait for the next owner); a tab that closes runs no code, so the requests in
|
|
17
17
|
* flight in its shards fail (ShardOwnerLostError) and the other tabs take the shards over.
|
|
18
18
|
*/
|
|
19
|
-
import {
|
|
20
|
-
import {
|
|
19
|
+
import { LinkLostError, SwHttpAdapter, SwHttpDispatcher, } from "@statewalker/webrun-http-browser/sw";
|
|
20
|
+
import { ShardOwnerLostError } from "../router/cell-router.js";
|
|
21
|
+
import { errorResponse } from "../transport/cells-http.js";
|
|
22
|
+
import { H } from "../transport/cells-link.js";
|
|
21
23
|
import { SW_SOCKET } from "../ws/sw-connector.js";
|
|
22
24
|
import { applyWorkerCookies, withPageCookies } from "./browser-cookies.js";
|
|
23
|
-
import {
|
|
24
|
-
|
|
25
|
+
import { INTERNAL_ORIGIN, TAB_INIT } from "./browser-shards.js";
|
|
26
|
+
/** The ServiceWorker asks a project's tabs to attach their shards again (a host page's wait). */
|
|
25
27
|
const NEED_LINKS = "cellb:need-links";
|
|
26
28
|
/** A page opens a socket (`serviceWorkerConnector`). */
|
|
27
29
|
const SOCKET = SW_SOCKET;
|
|
28
|
-
const DRAIN = "cellb:drain";
|
|
29
|
-
/** A shard's call waits for the ServiceWorker: this message starts it, and it asks for links. */
|
|
30
|
-
const WAKE = "cellb:wake";
|
|
31
|
-
/** Calls between shards are in flight: the message itself resets the ServiceWorker's idle timer. */
|
|
32
|
-
const KEEPALIVE = "cellb:keepalive";
|
|
33
30
|
/**
|
|
34
31
|
* The tab that should run `shard`: the tabs sorted by id (ids start with the time the tab joined),
|
|
35
32
|
* shard i to tab i mod n — every tab runs ⌊shards/n⌋ or ⌈shards/n⌉ shards. A tab joining or
|
|
@@ -41,6 +38,8 @@ export function shardOwner(shard, tabs) {
|
|
|
41
38
|
const sorted = [...tabs].sort();
|
|
42
39
|
return sorted[shard % sorted.length];
|
|
43
40
|
}
|
|
41
|
+
/** A shard's handler id under the project's key: the router's `route` picks shards by it. */
|
|
42
|
+
const shardId = (shard) => `shard:${shard}`;
|
|
44
43
|
/** The page side: this tab takes part in running the project's shards. */
|
|
45
44
|
export async function joinProjectShards(opts) {
|
|
46
45
|
const prefix = `cellb:${opts.name}`;
|
|
@@ -73,52 +72,22 @@ export async function joinProjectShards(opts) {
|
|
|
73
72
|
};
|
|
74
73
|
w.postMessage(data, [port2, ...ports]);
|
|
75
74
|
});
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
scriptName: opts.config.name ?? "worker",
|
|
93
|
-
shard,
|
|
94
|
-
ownerLock,
|
|
95
|
-
down: down.port1,
|
|
96
|
-
up: up.port1,
|
|
97
|
-
ctl: ctl.port1,
|
|
98
|
-
}, [port2, down.port1, up.port1, ctl.port1]);
|
|
99
|
-
});
|
|
100
|
-
// The shard drops these links once the ServiceWorker is gone, and wakes it when it needs it.
|
|
101
|
-
const watching = await ask(w, { type: TAB_WATCH, routerLock });
|
|
102
|
-
if (!watching.ok)
|
|
103
|
-
throw new Error(watching.error);
|
|
104
|
-
};
|
|
105
|
-
/** Starts the ServiceWorker again (WAKE: it asks the tabs for their shards' links), or keeps it up. */
|
|
106
|
-
const tell = async (type) => {
|
|
107
|
-
const sw = await opts.serviceWorker();
|
|
108
|
-
sw.postMessage({ type, name: opts.name });
|
|
109
|
-
};
|
|
110
|
-
const owners = new Map(); // shard → its worker's owner lock
|
|
111
|
-
/** Asks the router to stop sending the shard new requests and let the current ones finish. */
|
|
112
|
-
const drain = async (shard, ownerLock) => {
|
|
113
|
-
const sw = await opts.serviceWorker();
|
|
114
|
-
const ms = opts.drainMs ?? 5_000;
|
|
115
|
-
await new Promise((resolve) => {
|
|
116
|
-
const { port1, port2 } = new MessageChannel();
|
|
117
|
-
const t = setTimeout(resolve, ms + 1_000); // a ServiceWorker that does not answer
|
|
118
|
-
port1.onmessage = () => (clearTimeout(t), port1.close(), resolve());
|
|
119
|
-
sw.postMessage({ type: DRAIN, name: opts.name, shard, ownerLock, ms }, [port2]);
|
|
120
|
-
});
|
|
121
|
-
};
|
|
75
|
+
// The tab's side of the ServiceWorker's router (webrun-http-browser): it attaches this tab's
|
|
76
|
+
// shard workers under the project's key, and attaches them again whenever the ServiceWorker
|
|
77
|
+
// asks (after a restart). The page itself attaches no handler the router would pick.
|
|
78
|
+
const worker = await opts.serviceWorker();
|
|
79
|
+
// The given worker's registration, not `navigator.serviceWorker.ready` (that is this page's
|
|
80
|
+
// scope's registration, and never settles for a page outside every scope).
|
|
81
|
+
const registration = (await navigator.serviceWorker.getRegistrations()).find((r) => [r.active, r.waiting, r.installing].includes(worker));
|
|
82
|
+
if (!registration)
|
|
83
|
+
throw new Error(`cellb: no registration holds the ServiceWorker ${worker.scriptURL}`);
|
|
84
|
+
const adapter = new SwHttpAdapter({
|
|
85
|
+
key: opts.name,
|
|
86
|
+
registration,
|
|
87
|
+
id: `tab:${tabId}`,
|
|
88
|
+
});
|
|
89
|
+
await adapter.start();
|
|
90
|
+
const links = new Map();
|
|
122
91
|
const changed = () => opts.onChange?.([...running].sort((a, b) => a - b));
|
|
123
92
|
/** Waits for the shard's lock, then runs its worker until `stop` is aborted. */
|
|
124
93
|
const take = (shard) => {
|
|
@@ -128,12 +97,6 @@ export async function joinProjectShards(opts) {
|
|
|
128
97
|
.request(`${prefix}:shard:${shard}`, { signal: h.stop.signal }, async () => {
|
|
129
98
|
const w = opts.spawn(shard);
|
|
130
99
|
h.worker = w;
|
|
131
|
-
w.addEventListener("message", (e) => {
|
|
132
|
-
if (e.data?.type === SHARD_WAKE)
|
|
133
|
-
void tell(WAKE).catch(() => { });
|
|
134
|
-
if (e.data?.type === SHARD_KEEPALIVE)
|
|
135
|
-
void tell(KEEPALIVE).catch(() => { });
|
|
136
|
-
});
|
|
137
100
|
try {
|
|
138
101
|
const started = await ask(w, {
|
|
139
102
|
type: TAB_INIT,
|
|
@@ -148,15 +111,18 @@ export async function joinProjectShards(opts) {
|
|
|
148
111
|
pool: `${opts.name}-shard-${shard}`,
|
|
149
112
|
filesDir: `${opts.name}-files-${shard}`,
|
|
150
113
|
});
|
|
151
|
-
if (!started.ok
|
|
114
|
+
if (!started.ok)
|
|
152
115
|
throw new Error(started.error);
|
|
153
116
|
// What the configuration asks for and this runtime does not provide (a cron trigger
|
|
154
117
|
// without a scheduled() handler, say): said once, not silently ignored.
|
|
155
118
|
if (started.unsupported?.length)
|
|
156
119
|
console.warn(`cellb: project ${opts.name} shard ${shard}: unsupported: ${started.unsupported.join("; ")}`);
|
|
157
|
-
owners.set(shard, started.ownerLock);
|
|
158
120
|
if (!h.stop.signal.aborted) {
|
|
159
|
-
|
|
121
|
+
links.set(shard, await adapter.attachWorker(w, {
|
|
122
|
+
key: opts.name,
|
|
123
|
+
id: shardId(shard),
|
|
124
|
+
meta: { shard },
|
|
125
|
+
}));
|
|
160
126
|
running.add(shard);
|
|
161
127
|
changed();
|
|
162
128
|
await new Promise((r) => h.stop.signal.aborted ? r() : h.stop.signal.addEventListener("abort", () => r()));
|
|
@@ -165,11 +131,11 @@ export async function joinProjectShards(opts) {
|
|
|
165
131
|
finally {
|
|
166
132
|
// A planned hand-over drains the shard's requests in flight first; then the runtime
|
|
167
133
|
// closes (and the pool with it) before the lock goes to the next tab.
|
|
168
|
-
const
|
|
169
|
-
|
|
170
|
-
|
|
134
|
+
const link = links.get(shard);
|
|
135
|
+
links.delete(shard);
|
|
136
|
+
if (link && running.has(shard))
|
|
137
|
+
await link.drain({ ms: opts.drainMs ?? 5_000 }).catch(() => { });
|
|
171
138
|
running.delete(shard);
|
|
172
|
-
owners.delete(shard);
|
|
173
139
|
await ask(w, { type: "cellb:shard-close" }).catch(() => { });
|
|
174
140
|
w.terminate();
|
|
175
141
|
changed();
|
|
@@ -199,6 +165,9 @@ export async function joinProjectShards(opts) {
|
|
|
199
165
|
if (closed)
|
|
200
166
|
return;
|
|
201
167
|
const present = await tabs();
|
|
168
|
+
// close() may have run while `tabs()` was answering: take nothing after it.
|
|
169
|
+
if (closed)
|
|
170
|
+
return;
|
|
202
171
|
for (let shard = 0; shard < opts.shards; shard++) {
|
|
203
172
|
const mine = shardOwner(shard, present) === tabId;
|
|
204
173
|
const h = held.get(shard);
|
|
@@ -216,12 +185,7 @@ export async function joinProjectShards(opts) {
|
|
|
216
185
|
const onSw = (e) => {
|
|
217
186
|
if (e.data?.type !== NEED_LINKS || (e.data.name && e.data.name !== opts.name))
|
|
218
187
|
return;
|
|
219
|
-
|
|
220
|
-
const w = held.get(shard)?.worker;
|
|
221
|
-
const lock = owners.get(shard);
|
|
222
|
-
if (w && lock)
|
|
223
|
-
void attach(shard, w, lock).catch(() => { });
|
|
224
|
-
}
|
|
188
|
+
void adapter.relink().catch(() => { });
|
|
225
189
|
};
|
|
226
190
|
navigator.serviceWorker.addEventListener("message", onSw);
|
|
227
191
|
channel.postMessage({ joined: tabId });
|
|
@@ -237,155 +201,111 @@ export async function joinProjectShards(opts) {
|
|
|
237
201
|
for (const h of stopping)
|
|
238
202
|
h.stop.abort();
|
|
239
203
|
await Promise.all(stopping.map((h) => h.running));
|
|
204
|
+
await adapter.stop().catch(() => { });
|
|
240
205
|
leave();
|
|
241
206
|
channel.postMessage({ left: tabId });
|
|
242
207
|
channel.close();
|
|
243
208
|
},
|
|
244
209
|
};
|
|
245
210
|
}
|
|
246
|
-
/**
|
|
211
|
+
/**
|
|
212
|
+
* The ServiceWorker side: webrun-http-browser's dispatcher, with the project as its key and the
|
|
213
|
+
* tabs' shard workers as its handlers. What cellb adds: its urls (`<basePath><name>/`, a root
|
|
214
|
+
* project, `http://cellb.internal/<name>/` for calls between shards), which shard a call goes to,
|
|
215
|
+
* the page's cookies, no control headers from pages, and the host page.
|
|
216
|
+
*/
|
|
247
217
|
export function installProjectRouter(scope, opts) {
|
|
248
218
|
const sw = scope;
|
|
249
219
|
const base = opts.basePath.endsWith("/") ? opts.basePath : `${opts.basePath}/`;
|
|
250
|
-
|
|
251
|
-
// tells the shards that their links to it are dead (MessagePorts report nothing).
|
|
252
|
-
const routerLock = `cellb:router:${crypto.randomUUID()}`;
|
|
253
|
-
const lockHeld = new Promise((held) => {
|
|
254
|
-
void navigator.locks.request(routerLock, () => {
|
|
255
|
-
held();
|
|
256
|
-
return new Promise(() => { });
|
|
257
|
-
});
|
|
258
|
-
});
|
|
259
|
-
const routers = new Map();
|
|
260
|
-
const created = new Map();
|
|
261
|
-
const ready = new Map();
|
|
262
|
-
let asking;
|
|
263
|
-
const askForLinks = (name) => {
|
|
264
|
-
asking ??= sw.clients
|
|
265
|
-
.matchAll({ type: "window", includeUncontrolled: true })
|
|
266
|
-
.then((cs) => {
|
|
267
|
-
for (const c of cs)
|
|
268
|
-
c.postMessage({ type: NEED_LINKS, name });
|
|
269
|
-
})
|
|
270
|
-
.finally(() => setTimeout(() => (asking = undefined), 500));
|
|
271
|
-
return asking;
|
|
272
|
-
};
|
|
273
|
-
const routerFor = (name, shards, scriptName) => {
|
|
274
|
-
let router = routers.get(name);
|
|
275
|
-
if (!router) {
|
|
276
|
-
router = new CellRouter({
|
|
277
|
-
shards,
|
|
278
|
-
bindings: {}, // shards name the owner of every internal request (x-cellb-shard)
|
|
279
|
-
scriptName,
|
|
280
|
-
attachTimeoutMs: opts.attachTimeoutMs ?? 15_000,
|
|
281
|
-
onMissingShard: () => void askForLinks(name),
|
|
282
|
-
// The owner worker's own lock: released when it closes, its tab closes or it dies.
|
|
283
|
-
watchOwner: (_shard, ownerLock) => navigator.locks.request(ownerLock, async () => { }),
|
|
284
|
-
isAlive: async (_shard, ownerLock) => !!(await navigator.locks.query()).held?.some((l) => l.name === ownerLock),
|
|
285
|
-
livenessIntervalMs: 5_000,
|
|
286
|
-
});
|
|
287
|
-
routers.set(name, router);
|
|
288
|
-
created.get(name)?.();
|
|
289
|
-
}
|
|
290
|
-
return router;
|
|
291
|
-
};
|
|
292
|
-
/** A project's router; after a ServiceWorker restart, once a tab has attached a shard again. */
|
|
293
|
-
const routerNamed = (name) => {
|
|
294
|
-
const r = routers.get(name);
|
|
295
|
-
if (r)
|
|
296
|
-
return Promise.resolve(r);
|
|
297
|
-
let wait = ready.get(name);
|
|
298
|
-
if (!wait) {
|
|
299
|
-
wait = new Promise((resolve, reject) => {
|
|
300
|
-
const t = setTimeout(() => reject(new Error(`project ${name} has no shards in any tab`)), opts.attachTimeoutMs ?? 15_000);
|
|
301
|
-
created.set(name, () => (clearTimeout(t), resolve()));
|
|
302
|
-
}).finally(() => (ready.delete(name), created.delete(name)));
|
|
303
|
-
ready.set(name, wait);
|
|
304
|
-
void askForLinks(name);
|
|
305
|
-
}
|
|
306
|
-
return wait.then(() => routers.get(name));
|
|
307
|
-
};
|
|
308
|
-
sw.addEventListener("message", (e) => {
|
|
309
|
-
const m = e.data;
|
|
310
|
-
const reply = e.ports[0];
|
|
311
|
-
if (m?.type === ATTACH)
|
|
312
|
-
e.waitUntil((async () => {
|
|
313
|
-
try {
|
|
314
|
-
const router = routerFor(m.name, m.shards, m.scriptName);
|
|
315
|
-
await serveHandlerOverPort(m.up, router.internal);
|
|
316
|
-
const link = await linkOverPort(m.down);
|
|
317
|
-
router.attach(m.shard, link.send, m.ownerLock, m.ctl);
|
|
318
|
-
await lockHeld;
|
|
319
|
-
reply?.postMessage({ ok: true, routerLock });
|
|
320
|
-
}
|
|
321
|
-
catch (err) {
|
|
322
|
-
reply?.postMessage({ ok: false, error: String(err?.message ?? err) });
|
|
323
|
-
}
|
|
324
|
-
})());
|
|
325
|
-
else if (m?.type === DRAIN)
|
|
326
|
-
e.waitUntil((async () => {
|
|
327
|
-
await routers.get(m.name)?.drain(m.shard, m.ownerLock, m.ms);
|
|
328
|
-
reply?.postMessage({ ok: true });
|
|
329
|
-
})());
|
|
330
|
-
else if (m?.type === WAKE)
|
|
331
|
-
// Started by this message: stay up until a tab has linked the project's shards again.
|
|
332
|
-
e.waitUntil(routerNamed(m.name).catch(() => { }));
|
|
333
|
-
else if (m?.type === SOCKET)
|
|
334
|
-
e.waitUntil((async () => {
|
|
335
|
-
try {
|
|
336
|
-
const { name, path } = split(new URL(m.url));
|
|
337
|
-
const url = new URL(m.url);
|
|
338
|
-
if (opts.stripBase && !atRoot(url))
|
|
339
|
-
url.pathname = path;
|
|
340
|
-
const { port, protocol } = await (await routerNamed(name)).connectWebSocket(url.toString(), m.protocols ?? []);
|
|
341
|
-
reply.postMessage({ ok: true, port, protocol }, [port]);
|
|
342
|
-
}
|
|
343
|
-
catch (err) {
|
|
344
|
-
reply.postMessage({ ok: false, error: String(err?.message ?? err) });
|
|
345
|
-
}
|
|
346
|
-
})());
|
|
347
|
-
});
|
|
220
|
+
const internal = new URL(INTERNAL_ORIGIN);
|
|
348
221
|
/** Whether `url` goes to the root project (`opts.root`). */
|
|
349
222
|
const atRoot = (url) => !!opts.root &&
|
|
350
223
|
url.origin === sw.location.origin &&
|
|
351
224
|
!url.pathname.startsWith(base) &&
|
|
352
225
|
!(opts.root.passthrough ?? []).some((p) => url.pathname.startsWith(p));
|
|
353
|
-
/**
|
|
226
|
+
/**
|
|
227
|
+
* `<base><name>/<rest>` → the project's name, the Worker's path `/<rest>`, and `<base><name>` as
|
|
228
|
+
* the URL spells it (`prefix`: what `stripBase` cuts, so a name the URL leaves unescaped, `a@b`,
|
|
229
|
+
* or escapes where it need not, `%61pp`, is cut as written).
|
|
230
|
+
*/
|
|
354
231
|
const split = (url) => {
|
|
355
232
|
if (opts.root && atRoot(url))
|
|
356
|
-
return { name: opts.root.name, path: url.pathname };
|
|
357
|
-
if (!url.pathname.startsWith(base))
|
|
358
|
-
|
|
233
|
+
return { name: opts.root.name, path: url.pathname, prefix: "" };
|
|
234
|
+
if (url.origin !== sw.location.origin || !url.pathname.startsWith(base))
|
|
235
|
+
return undefined;
|
|
359
236
|
const rest = url.pathname.slice(base.length);
|
|
360
237
|
const slash = rest.indexOf("/");
|
|
361
|
-
const
|
|
362
|
-
|
|
238
|
+
const raw = slash < 0 ? rest : rest.slice(0, slash);
|
|
239
|
+
const name = decodeURIComponent(raw);
|
|
240
|
+
return name
|
|
241
|
+
? { name, path: slash < 0 ? "/" : rest.slice(slash), prefix: `${base}${raw}` }
|
|
242
|
+
: undefined;
|
|
363
243
|
};
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
244
|
+
const isShard = (h) => typeof h.meta?.shard === "number";
|
|
245
|
+
let rr = 0;
|
|
246
|
+
const dispatcher = new SwHttpDispatcher({
|
|
247
|
+
self: sw,
|
|
248
|
+
handleFetch: false,
|
|
249
|
+
attachTimeoutMs: opts.attachTimeoutMs ?? 15_000,
|
|
250
|
+
key: (url) => {
|
|
251
|
+
// Calls between shards: http://cellb.internal/<name>/.
|
|
252
|
+
if (url.host === internal.host) {
|
|
253
|
+
const name = url.pathname.split("/")[1];
|
|
254
|
+
return name ? decodeURIComponent(name) : undefined;
|
|
255
|
+
}
|
|
256
|
+
return split(url)?.name;
|
|
257
|
+
},
|
|
258
|
+
// A call between shards names its shard (it waits for that one); the rest go to the shard
|
|
259
|
+
// with the fewest requests in flight, round robin among equals. The tabs' pages are handlers
|
|
260
|
+
// of the key too (their adapters'), with nothing to serve: never picked.
|
|
261
|
+
route: (request, _key, handlers) => {
|
|
262
|
+
const shards = handlers.filter(isShard);
|
|
263
|
+
const named = request.headers.get(H.shard);
|
|
264
|
+
if (named !== null)
|
|
265
|
+
return shards.find((h) => h.meta.shard === Number(named));
|
|
266
|
+
if (!shards.length)
|
|
267
|
+
return undefined;
|
|
268
|
+
const start = rr++ % shards.length;
|
|
269
|
+
let best = shards[start];
|
|
270
|
+
for (let i = 1; i < shards.length; i++) {
|
|
271
|
+
const h = shards[(start + i) % shards.length];
|
|
272
|
+
if (h.inflight < best.inflight)
|
|
273
|
+
best = h;
|
|
274
|
+
}
|
|
275
|
+
return best;
|
|
276
|
+
},
|
|
277
|
+
// A call between shards fails as an error its caller rethrows (postVia); a page's request
|
|
278
|
+
// gets a response it can read.
|
|
279
|
+
failure: (request, failure) => failed(request, failure),
|
|
280
|
+
// Pages may not address objects or shards directly: no control headers on their sockets.
|
|
281
|
+
socketHeaders: (headers, from) => from === "page"
|
|
282
|
+
? headers?.filter(([name]) => !name.toLowerCase().startsWith("x-cellb"))
|
|
283
|
+
: headers,
|
|
385
284
|
});
|
|
285
|
+
dispatcher.start();
|
|
286
|
+
const failed = (request, failure) => {
|
|
287
|
+
const named = request.headers.get(H.shard);
|
|
288
|
+
const what = named !== null ? `cell shard ${named}` : "the project";
|
|
289
|
+
const error = failure.kind === "lost" && failure.error instanceof LinkLostError && named !== null
|
|
290
|
+
? new ShardOwnerLostError(Number(named))
|
|
291
|
+
: failure.kind === "timeout"
|
|
292
|
+
? new Error(`${what} was not attached in time`)
|
|
293
|
+
: failure.kind === "gone"
|
|
294
|
+
? new Error(`${what} has no tab running it`)
|
|
295
|
+
: failure.error;
|
|
296
|
+
if (request.headers.get(H.kind) !== null)
|
|
297
|
+
return errorResponse(error, 503);
|
|
298
|
+
return new Response(`cellb: ${error?.message ?? error}`, { status: 503 });
|
|
299
|
+
};
|
|
300
|
+
const askForLinks = async (name) => {
|
|
301
|
+
const clients = await sw.clients.matchAll({ type: "window", includeUncontrolled: true });
|
|
302
|
+
for (const c of clients)
|
|
303
|
+
c.postMessage({ type: NEED_LINKS, name });
|
|
304
|
+
};
|
|
386
305
|
/** Whether a tab runs project `name`'s shards, waiting up to `ms` for one to link. */
|
|
387
306
|
const runningShards = async (name, ms) => {
|
|
388
|
-
|
|
307
|
+
await dispatcher.ready;
|
|
308
|
+
const live = () => dispatcher.table.live(name).some(isShard);
|
|
389
309
|
if (live())
|
|
390
310
|
return true;
|
|
391
311
|
void askForLinks(name);
|
|
@@ -396,23 +316,88 @@ export function installProjectRouter(scope, opts) {
|
|
|
396
316
|
}
|
|
397
317
|
return false;
|
|
398
318
|
};
|
|
319
|
+
/** Headers for a request the router sends on: no control headers from the page, its base. */
|
|
320
|
+
const routed = (req, prefix, root) => {
|
|
321
|
+
// Headers only: a Request built from `req` would take its body.
|
|
322
|
+
const headers = new Headers(req.headers);
|
|
323
|
+
for (const h of Object.values(H))
|
|
324
|
+
if (h !== H.cookie)
|
|
325
|
+
headers.delete(h);
|
|
326
|
+
if (opts.stripBase && !root)
|
|
327
|
+
headers.set(H.base, prefix);
|
|
328
|
+
return headers;
|
|
329
|
+
};
|
|
399
330
|
/** A request to project `name`, with the page's cookies in and the Worker's out. */
|
|
400
|
-
const serve = (name, req,
|
|
331
|
+
const serve = async ({ name, prefix }, req, root) => {
|
|
401
332
|
try {
|
|
402
|
-
|
|
333
|
+
// A project no tab has ever served is not the router's: the page's own request goes to the
|
|
334
|
+
// network, as if there were no router (not the copy below, with the page's cookies in a
|
|
335
|
+
// header and a streamed body).
|
|
336
|
+
await dispatcher.ready;
|
|
337
|
+
if (!dispatcher.serves(name))
|
|
338
|
+
return await fetch(req);
|
|
339
|
+
const headers = await withPageCookies(sw, new Request(req.url, { headers: routed(req, prefix, root) }));
|
|
340
|
+
const res = await dispatcher.dispatch(new Request(req.url, {
|
|
403
341
|
method: req.method,
|
|
404
|
-
headers
|
|
342
|
+
headers,
|
|
405
343
|
body: req.body,
|
|
406
344
|
signal: req.signal,
|
|
407
345
|
duplex: "half",
|
|
408
|
-
}))
|
|
346
|
+
}));
|
|
347
|
+
return await applyWorkerCookies(sw, res);
|
|
409
348
|
}
|
|
410
349
|
catch (err) {
|
|
411
350
|
// A response the page can read, instead of a network error with no cause.
|
|
412
|
-
console.error(`cellb: ${req.method} ${
|
|
351
|
+
console.error(`cellb: ${req.method} ${new URL(req.url).pathname} failed:`, err);
|
|
413
352
|
return new Response(`cellb: ${err?.stack ?? err}`, { status: 502 });
|
|
414
353
|
}
|
|
415
|
-
}
|
|
416
|
-
|
|
354
|
+
};
|
|
355
|
+
sw.addEventListener("fetch", (e) => {
|
|
356
|
+
const req = e.request;
|
|
357
|
+
const url = new URL(req.url);
|
|
358
|
+
const root = atRoot(url);
|
|
359
|
+
if (!root && req.mode === "navigate")
|
|
360
|
+
return; // the pages under basePath are the host's
|
|
361
|
+
const project = split(url);
|
|
362
|
+
if (!project)
|
|
363
|
+
return;
|
|
364
|
+
const hostPage = root && req.mode === "navigate" ? opts.root?.hostPage : undefined;
|
|
365
|
+
if (hostPage)
|
|
366
|
+
e.respondWith(runningShards(project.name, opts.root?.hostWaitMs ?? 2_000).then((running) => running
|
|
367
|
+
? serve(project, req, root)
|
|
368
|
+
: Response.redirect(`${new URL(hostPage, url.origin).href}?next=${encodeURIComponent(`${url.pathname}${url.search}`)}`, 302)));
|
|
369
|
+
else
|
|
370
|
+
e.respondWith(serve(project, req, root));
|
|
371
|
+
});
|
|
372
|
+
// A page's socket (`serviceWorkerConnector`): routed as a fetch would be, without the page's
|
|
373
|
+
// headers.
|
|
374
|
+
sw.addEventListener("message", (e) => {
|
|
375
|
+
const m = e.data;
|
|
376
|
+
if (m?.type !== SOCKET)
|
|
377
|
+
return;
|
|
378
|
+
const reply = e.ports[0];
|
|
379
|
+
e.waitUntil((async () => {
|
|
380
|
+
try {
|
|
381
|
+
const url = new URL(m.url);
|
|
382
|
+
const httpUrl = new URL(url);
|
|
383
|
+
if (httpUrl.protocol === "ws:")
|
|
384
|
+
httpUrl.protocol = "http:";
|
|
385
|
+
else if (httpUrl.protocol === "wss:")
|
|
386
|
+
httpUrl.protocol = "https:";
|
|
387
|
+
const project = split(httpUrl);
|
|
388
|
+
if (!project)
|
|
389
|
+
throw new Error(`not a project URL: ${m.url}`);
|
|
390
|
+
const headers = [];
|
|
391
|
+
if (opts.stripBase && !atRoot(httpUrl))
|
|
392
|
+
headers.push([H.base, project.prefix]);
|
|
393
|
+
const { port, protocol } = await dispatcher.connectWebSocket(`${httpUrl}`, m.protocols ?? [], headers);
|
|
394
|
+
reply.postMessage({ ok: true, port, protocol }, [port]);
|
|
395
|
+
}
|
|
396
|
+
catch (err) {
|
|
397
|
+
reply.postMessage({ ok: false, error: String(err?.message ?? err) });
|
|
398
|
+
}
|
|
399
|
+
})());
|
|
400
|
+
});
|
|
401
|
+
return { dispatcher };
|
|
417
402
|
}
|
|
418
403
|
//# sourceMappingURL=browser-tabs.js.map
|