@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.
Files changed (57) hide show
  1. package/README.md +46 -3
  2. package/bin/cellb.mjs +20 -0
  3. package/dist/browser.d.ts +2 -0
  4. package/dist/browser.js +1 -0
  5. package/dist/browser.js.map +1 -1
  6. package/dist/bucket/celld-cells.js +25 -3
  7. package/dist/bucket/celld-cells.js.map +1 -1
  8. package/dist/cli.d.ts +3 -0
  9. package/dist/cli.js +62 -0
  10. package/dist/cli.js.map +1 -0
  11. package/dist/isolate/browser.d.ts +2 -1
  12. package/dist/isolate/browser.js +2 -1
  13. package/dist/isolate/browser.js.map +1 -1
  14. package/dist/isolate/isolate.js +5 -3
  15. package/dist/isolate/isolate.js.map +1 -1
  16. package/dist/isolate/protocol.d.ts +4 -2
  17. package/dist/isolate/protocol.js +4 -4
  18. package/dist/isolate/protocol.js.map +1 -1
  19. package/dist/node.d.ts +3 -0
  20. package/dist/node.js +2 -0
  21. package/dist/node.js.map +1 -1
  22. package/dist/project/browser-host.d.ts +6 -12
  23. package/dist/project/browser-host.js +2 -25
  24. package/dist/project/browser-host.js.map +1 -1
  25. package/dist/project/browser-load.d.ts +22 -0
  26. package/dist/project/browser-load.js +63 -0
  27. package/dist/project/browser-load.js.map +1 -0
  28. package/dist/project/browser-shards.d.ts +8 -9
  29. package/dist/project/browser-shards.js +88 -52
  30. package/dist/project/browser-shards.js.map +1 -1
  31. package/dist/project/browser-tabs.d.ts +14 -4
  32. package/dist/project/browser-tabs.js +193 -208
  33. package/dist/project/browser-tabs.js.map +1 -1
  34. package/dist/project/build.d.ts +14 -0
  35. package/dist/project/build.js +53 -0
  36. package/dist/project/build.js.map +1 -0
  37. package/dist/project/built-project.d.ts +88 -0
  38. package/dist/project/built-project.js +118 -0
  39. package/dist/project/built-project.js.map +1 -0
  40. package/dist/project/node-load.d.ts +14 -0
  41. package/dist/project/node-load.js +187 -0
  42. package/dist/project/node-load.js.map +1 -0
  43. package/dist/project/wrangler.d.ts +9 -0
  44. package/dist/project/wrangler.js +24 -0
  45. package/dist/project/wrangler.js.map +1 -1
  46. package/dist/transport/cells-http.d.ts +6 -0
  47. package/dist/transport/cells-http.js +22 -1
  48. package/dist/transport/cells-http.js.map +1 -1
  49. package/dist/transport/cells-link.d.ts +2 -0
  50. package/dist/transport/cells-link.js +5 -0
  51. package/dist/transport/cells-link.js.map +1 -1
  52. package/dist/transport/port.js +4 -0
  53. package/dist/transport/port.js.map +1 -1
  54. package/dist/ws/server.d.ts +39 -23
  55. package/dist/ws/server.js +99 -140
  56. package/dist/ws/server.js.map +1 -1
  57. 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 { CellRouter } from "../router/cell-router.js";
20
- import { linkOverPort, serveHandlerOverPort } from "../transport/port.js";
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 { SHARD_KEEPALIVE, SHARD_WAKE, TAB_INIT, TAB_LINK, TAB_WATCH, } from "./browser-shards.js";
24
- const ATTACH = "cellb:attach";
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
- /** Links a shard's worker to the router (again, after a ServiceWorker restart). */
77
- const attach = async (shard, w, ownerLock) => {
78
- const down = new MessageChannel(); // router → shard
79
- const up = new MessageChannel(); // shard → router
80
- const ctl = new MessageChannel(); // WebSocket port hand-over
81
- const linked = await ask(w, { type: TAB_LINK }, [down.port2, up.port2, ctl.port2]);
82
- if (!linked.ok)
83
- throw new Error(linked.error);
84
- const sw = await opts.serviceWorker();
85
- const routerLock = await new Promise((resolve, reject) => {
86
- const { port1, port2 } = new MessageChannel();
87
- port1.onmessage = (e) => (port1.close(), e.data?.ok ? resolve(e.data.routerLock) : reject(new Error(e.data?.error)));
88
- sw.postMessage({
89
- type: ATTACH,
90
- name: opts.name,
91
- shards: opts.shards,
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 || !started.ownerLock)
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
- await attach(shard, w, started.ownerLock);
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 lock = owners.get(shard);
169
- if (lock && running.has(shard))
170
- await drain(shard, lock).catch(() => { });
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
- for (const shard of running) {
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
- /** The ServiceWorker side: one CellRouter per project, fed by the tabs' shard workers. */
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
- // Held while this ServiceWorker lives: the browser releases it when it stops the worker, which
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
- /** `<base><name>/<rest>` → the project's name and the Worker's path `/<rest>`. */
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
- throw new Error(`not a project URL: ${url}`);
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 name = decodeURIComponent(slash < 0 ? rest : rest.slice(0, slash));
362
- return { name, path: slash < 0 ? "/" : rest.slice(slash) };
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
- sw.addEventListener("fetch", (e) => {
365
- const url = new URL(e.request.url);
366
- const root = atRoot(url);
367
- if (!root) {
368
- if (url.origin !== sw.location.origin || !url.pathname.startsWith(base))
369
- return;
370
- if (e.request.mode === "navigate")
371
- return;
372
- }
373
- const { name, path } = split(url);
374
- if (!name)
375
- return;
376
- const req = e.request;
377
- const target = opts.stripBase && !root ? new URL(`${path}${url.search}`, url.origin) : url;
378
- const hostPage = root && req.mode === "navigate" ? opts.root?.hostPage : undefined;
379
- if (hostPage)
380
- e.respondWith(runningShards(name, opts.root?.hostWaitMs ?? 2_000).then((running) => running
381
- ? serve(name, req, target)
382
- : Response.redirect(`${new URL(hostPage, url.origin).href}?next=${encodeURIComponent(`${url.pathname}${url.search}`)}`, 302)));
383
- else
384
- e.respondWith(serve(name, req, target));
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
- const live = () => (routers.get(name)?.attachedShards.length ?? 0) > 0;
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, target) => routerNamed(name).then(async (router) => {
331
+ const serve = async ({ name, prefix }, req, root) => {
401
332
  try {
402
- return await applyWorkerCookies(sw, await router.ingress(new Request(target, {
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: await withPageCookies(sw, req),
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} ${target.pathname} failed:`, err);
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
- }, (err) => new Response(String(err?.message ?? err), { status: 503 }));
416
- return { routers };
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