@volter/world-core 3.0.17 → 3.0.19

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/src/pack-fetch.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  // one of them is answered over the contract's context, which the kernel opens: a pack opens none, reads the tree and
7
7
  // writes it only through one.
8
8
  import { ORIGINAL_PATH_HEADER } from './sigv4.ts';
9
- import { TWIN_PREFIX_HEADER } from './twin-fetch.ts';
9
+ import { placedCookies, TWIN_PREFIX_HEADER } from './twin-fetch.ts';
10
10
  import { PREFIX_PARAMS_HEADER } from './derived.ts';
11
11
  import { withHeaders } from './with-headers.ts';
12
12
  import { authRefusal, bindSemantics, contextFor, LANE_HEADER, SCENARIO_DECISION_HEADER, coreFor, crossCutting, derivedRequestScopes, vendorError, type CoreScope, type DerivedManifest, type ErrorSpec, type Handler, type HandlerContext, type LanesDecl, type VendorManifest } from './derived-core.ts';
@@ -17,6 +17,13 @@ import { getActiveWorldStore } from './world-store.ts';
17
17
  import { runWithCorrelationId } from './actions.ts';
18
18
  import { deferPerform, performRequestGroup, RefusedWriteError, VendorWriteError } from './head.ts';
19
19
  import { withCors } from './cors.ts';
20
+ import type { ApplicationStandIn } from './events.ts';
21
+ import { contentAddressOf } from './content-route.ts';
22
+ import { getPack, type HostRule } from './packRegistry.ts';
23
+ import { hostRules } from '../vendor-hosts.cjs';
24
+
25
+ /** A descriptor's host rules, as data. */
26
+ type PackDescriptorHosts = ReadonlyArray<HostRule> | undefined;
20
27
  import { socketUpgrade, type SocketEngine } from './sockets.ts';
21
28
  import { MACHINE_POOL, POOL_KINDS } from './machines.ts';
22
29
  import { applyTwinWrite } from './serve.ts';
@@ -68,12 +75,23 @@ export type PackParts = {
68
75
  export type PackScenario<Req = any> = { adapter: PackScenarioAdapter<Req>; operations: ReadonlyArray<string>; request: (ctx: HandlerContext) => Req | undefined };
69
76
 
70
77
  /** A lane as its vendor's fetch is given it: its own pack fetch, and its manifest (the doors it declares). */
71
- export type LanePart = { fetch: ((request: Request) => Promise<Response>) & { serves?: (method: string, path: string) => boolean; boards?: boolean; draws?: (url: string) => boolean }; manifest: { doors?: ReadonlyArray<{ method: string; path: string }> } };
78
+ export type LanePart = {
79
+ fetch: ((request: Request) => Promise<Response>) & {
80
+ serves?: (method: string, path: string) => boolean; boards?: boolean; draws?: (url: string) => boolean;
81
+ /** the hosts the lane's built content screens name: its vendor's router sends `<base>/@<host>/` there */
82
+ contentHosts?: ReadonlyArray<string>;
83
+ };
84
+ manifest: { doors?: ReadonlyArray<{ method: string; path: string }> };
85
+ };
72
86
 
73
87
  /** Where a pack serves: its World's root, whether it was started read-only, the World instant when a caller pins it, the
74
88
  * World's managed Postgres the runtime binds a pack declaring `managedDatabase` to (a handler's `ctx.engine`), and the
75
89
  * World's scenario file for a model vendor's turns (`scenarioPath`, the runtime's `--scenario`). */
76
- export type PackFetchOptions = { root?: string; readOnly?: boolean; clock?: () => string; database?: string; scenarioPath?: string };
90
+ export type PackFetchOptions = {
91
+ root?: string; readOnly?: boolean; clock?: () => string; database?: string; scenarioPath?: string;
92
+ /** the application a walk stands in for (a life's `application` answers): this fetch's `ctx.ask` is answered by it */
93
+ application?: ApplicationStandIn;
94
+ };
77
95
 
78
96
  type Match = { params: Record<string, string> };
79
97
  /** A declared path (`/{account}/r2/api-tokens`, `/<room>`, a door's `/_twin/users/{email}`) as a matcher of a request's
@@ -160,6 +178,7 @@ export function createPackFetch(parts: PackParts, options: PackFetchOptions = {}
160
178
  ...(options.clock ? { clock: options.clock } : {}),
161
179
  ...(options.database !== undefined ? { database: options.database, readOnly: options.readOnly ?? false } : {}),
162
180
  ...(parts.tenant ? { tenant: parts.tenant } : {}),
181
+ ...(options.application ? { application: options.application } : {}),
163
182
  };
164
183
  // a command table's surface (Redis's, a vendor that carries Redis over HTTP) names no HTTP operation: its wire is the
165
184
  // pack's front (its `around`), and the table is the denominator the grade counts
@@ -295,6 +314,68 @@ export function createPackFetch(parts: PackParts, options: PackFetchOptions = {}
295
314
  };
296
315
  const rootServes = (method: string, path: string): boolean => Boolean(matchOperation(routes, method, path));
297
316
  const lane = m.lanes ? laneRouter(m.vendor, m.lanes, parts.lanes ?? {}, rootServes) : undefined;
317
+ // at a World's place, a page's asset or upload on another of the pack's content hosts is `<base>/@<host>/<path>`
318
+ // (architecture, "A page at a World's place"): answered by the built content screen that names that host exactly,
319
+ // routed from it, and by nothing else; a request naming no such host is general routing's
320
+ const contentScreens = screens.filter((s) => s.decl.kind === 'content');
321
+ for (const s of contentScreens) if (s.decl.yieldsToApi) throw new Error(`${m.vendor}: the content screen ${s.decl.id} declares yieldsToApi; a content screen answers its host's requests alone (architecture, "Screens")`);
322
+ const contentHosts = [...new Set(contentScreens.flatMap((s) => [s.decl.host, ...(s.decl.hosts ?? [])]).filter((h) => !/[{<]/.test(h)).map((h) => h.toLowerCase()))];
323
+ const contentNotFound = (host: string, path: string): Response => {
324
+ const nf = m.notFound;
325
+ return vendorError(m, { status: nf.status, message: nf.message.replace(/\{object\}/g, host).replace(/\{id\}/g, path), ...(nf.code !== undefined ? { code: nf.code } : {}), ...(nf.kind !== undefined ? { kind: nf.kind } : {}) });
326
+ };
327
+ /** A request at a World's place for `/@<host>/<path>`, `<host>` one a built content screen of this unit names exactly:
328
+ * that screen's answer, or the pack's not-found for what the host or the screen does not take. The path is held to
329
+ * the descriptor's host rules (`rules`: this unit's, or its vendor's for a lane), read as the injector reads them.
330
+ * Undefined for any other request. */
331
+ // the descriptor's host rules: this unit's, or, for a lane (which declares none), its vendor's as registered
332
+ const descriptorHosts = (): PackDescriptorHosts => m.descriptor?.hosts ?? getPack(m.vendor)?.hosts;
333
+ const atContentHost = async (request: Request, rules: PackDescriptorHosts = descriptorHosts()): Promise<Response | undefined> => {
334
+ if (!request.headers.has(TWIN_PREFIX_HEADER)) return undefined;
335
+ const url = new URL(request.url);
336
+ const address = contentAddressOf(url.pathname);
337
+ if (!address || !contentHosts.includes(address.host)) return undefined;
338
+ const { host, path: inner } = address;
339
+ if (address.refused) return contentNotFound(host, inner);
340
+ const declared = hostRules(rules);
341
+ if (declared.names(host) && !declared.takes(host, inner)) return contentNotFound(host, inner);
342
+ const onHost = contentScreens.filter((s) => [s.decl.host, ...(s.decl.hosts ?? [])].some((h) => h.toLowerCase() === host));
343
+ const method = request.method.toUpperCase();
344
+ const allowed = (s: (typeof screens)[number]): boolean => method === 'GET' || method === 'HEAD' || (method === 'POST' && (s.decl.takes ?? []).includes('POST'));
345
+ // the request as it reaches the content host, through the pack's own routing of a host (its path prefix and labels)
346
+ url.pathname = inner;
347
+ const headers = new Headers(request.headers);
348
+ headers.set('x-volter-twin-original-host', host);
349
+ const sent = method === 'POST' && request.body ? { body: request.body, duplex: 'half' } : {};
350
+ const there = routed(new Request(url, { method, headers, signal: request.signal, ...sent } as RequestInit));
351
+ const path = new URL(there.url).pathname.replace(/\/+$/, '') || '/';
352
+ const hit = onHost.map((s) => ({ s, hit: s.path(path) })).find((x) => x.hit && allowed(x.s));
353
+ if (!hit) return contentNotFound(host, inner);
354
+ const asked = options.readOnly && !isReadOnlyRequest(there) ? withHeaders(there, new Headers([...there.headers, [READ_ONLY_REQUEST_HEADER, '1']])) : there;
355
+ if (method === 'POST' && ((options.readOnly ?? false) || isReadOnlyRequest(asked))) return refusedReadOnly();
356
+ if (parts.clock) await runAsVendorMove(async () => parts.clock!(await open(new Request(asked.url, m.tenant ? { headers: asked.headers } : {}), 'clock', 'POST', '/_twin/clock', {})));
357
+ return hit.s.answer(await open(asked, hit.s.decl.id, method, hit.s.decl.path, { ...(hit.s.host(host)?.params ?? {}), ...hit.hit!.params }));
358
+ };
359
+ /** A request as the kernel reads it: the headers only the kernel names taken off, its body decoded (undefined when it
360
+ * does not decode as its Content-Encoding says). */
361
+ const prepared = async (arrived: Request): Promise<Request | undefined> => {
362
+ if (arrived.headers.has(SCENARIO_DECISION_HEADER) || arrived.headers.has(PREFIX_PARAMS_HEADER)) {
363
+ const headers = new Headers(arrived.headers);
364
+ headers.delete(SCENARIO_DECISION_HEADER);
365
+ headers.delete(PREFIX_PARAMS_HEADER);
366
+ arrived = new Request(arrived, { headers });
367
+ }
368
+ return decoded(m, arrived);
369
+ };
370
+ const undecodable = (arrived: Request): Response => vendorError(m, m.encodings?.undecodable ?? { status: 400, message: `the body does not decode as its Content-Encoding (${arrived.headers.get('content-encoding')}) says` });
371
+ /** The content route at the request's arrival: the answer, or undefined when this unit's content screens name no such
372
+ * host. */
373
+ const answerContent = async (arrived: Request): Promise<Response | undefined> => {
374
+ const address = arrived.headers.has(TWIN_PREFIX_HEADER) ? contentAddressOf(new URL(arrived.url).pathname) : undefined;
375
+ if (!address || !contentHosts.includes(address.host)) return undefined;
376
+ const readable = await prepared(arrived);
377
+ return readable ? atContentHost(readable) : undecodable(arrived);
378
+ };
298
379
  const fetch = async (arrived: Request): Promise<Response> => {
299
380
  // the discovery door is the pack's own, whichever lane would take its path
300
381
  const asked = new URL(arrived.url).pathname.replace(/\/+$/, '') || '/';
@@ -307,17 +388,15 @@ export function createPackFetch(parts: PackParts, options: PackFetchOptions = {}
307
388
  const frames = await parts.board(await open(request, 'board', 'GET', '/twin/board', {}));
308
389
  return Response.json({ frames: frames.map((f) => ({ ...f, drawn: drawsUrl(f.url) })) });
309
390
  }
391
+ // `<base>/@<host>/<path>` at a World's place, for a host a built content screen of this pack names, is that screen's
392
+ // alone (only the kernel names a scenario's decision or a path prefix's parameters: `prepared` takes them off); for
393
+ // one a lane's names, the lane router sends it to that lane's own fetch, wrapped as any request to it is
394
+ const own = await answerContent(arrived);
395
+ if (own) return own;
310
396
  const toLane = discovery ? undefined : lane?.(arrived);
311
397
  if (toLane) return toLane(arrived);
312
- // only the kernel names a scenario's decision, or a path prefix's parameters
313
- if (arrived.headers.has(SCENARIO_DECISION_HEADER) || arrived.headers.has(PREFIX_PARAMS_HEADER)) {
314
- const headers = new Headers(arrived.headers);
315
- headers.delete(SCENARIO_DECISION_HEADER);
316
- headers.delete(PREFIX_PARAMS_HEADER);
317
- arrived = new Request(arrived, { headers });
318
- }
319
- const readable = await decoded(m, arrived);
320
- if (!readable) return vendorError(m, m.encodings?.undecodable ?? { status: 400, message: `the body does not decode as its Content-Encoding (${arrived.headers.get('content-encoding')}) says` });
398
+ const readable = await prepared(arrived);
399
+ if (!readable) return undecodable(arrived);
321
400
  const incoming = routed(readable);
322
401
  // a twin started read-only marks every request so, so every context it opens knows (isReadOnlyRequest)
323
402
  const request = options.readOnly && !isReadOnlyRequest(incoming) ? withHeaders(incoming, new Headers([...incoming.headers, [READ_ONLY_REQUEST_HEADER, '1']])) : incoming;
@@ -368,8 +447,10 @@ export function createPackFetch(parts: PackParts, options: PackFetchOptions = {}
368
447
  // whichever host the twin is reached (its links carry the twin's own base), never a root screen off its own host
369
448
  const host = hostOf(request);
370
449
  const exact = (s: (typeof screens)[number]): boolean => [s.decl.host, ...(s.decl.hosts ?? [])].some((h) => !/[{<]/.test(h) && h.toLowerCase() === host);
371
- const hits = screens.map((s) => ({ s, hit: s.path(path), onHost: s.host(host) })).filter((x) => x.hit);
372
450
  const apiPath = Boolean(matchOperation(routes, request.method, url.pathname, url.searchParams, request.headers));
451
+ // a screen that yields to the API draws only what no operation names, wherever it would answer (architecture,
452
+ // "Screens": Hacker News's /login page, whose form posts to POST /login)
453
+ const hits = screens.map((s) => ({ s, hit: s.path(path), onHost: s.host(host) })).filter((x) => x.hit && !(apiPath && x.s.decl.yieldsToApi));
373
454
  // reached at a World's place for the twin (`x-forwarded-prefix`, served-world's wire), the host is the World's: a
374
455
  // workspace is shown there by its path alone, its root too (the console frames it so)
375
456
  const placed = request.headers.has(TWIN_PREFIX_HEADER);
@@ -424,7 +505,9 @@ export function createPackFetch(parts: PackParts, options: PackFetchOptions = {}
424
505
  headers.set('content-type', m.jsonContentType!);
425
506
  return new Response(res.body, { status: res.status, statusText: res.statusText, headers });
426
507
  };
427
- const served = m.cors ? withCors(m.cors, labelled) : labelled;
508
+ const cors = m.cors ? withCors(m.cors, labelled) : labelled;
509
+ // at a World's place every cookie the twin sets is kept to the twin's base path (twin-fetch.ts, placedCookies)
510
+ const served = async (request: Request): Promise<Response> => placedCookies(request, await cors(request));
428
511
  // which paths this pack's API serves, for a vendor's lane router that asks (unlessOnlyRoot)
429
512
  const serves = (method: string, path: string): boolean => Boolean(matchOperation(routes, method, path));
430
513
  // the vendor's sockets: a request to a declared socket's path opens a session of its engine, over a context of the
@@ -436,7 +519,7 @@ export function createPackFetch(parts: PackParts, options: PackFetchOptions = {}
436
519
  async (asked) => engineOf()?.serve(asked)) : undefined;
437
520
  // what serves each operation: the pack's own, and each lane's
438
521
  const owners = (): Record<string, DerivedOwner> => ({ ...api.owners(), ...lanesOwners(parts.lanes ?? {}) });
439
- return derivedRequestScopes(m, Object.assign(served, { owners, serves, workspaces, boards: Boolean(parts.board), draws: drawsUrl, ...(upgrade ? { upgrade } : {}) }));
522
+ return derivedRequestScopes(m, Object.assign(served, { owners, serves, workspaces, boards: Boolean(parts.board), draws: drawsUrl, contentHosts, ...(upgrade ? { upgrade } : {}) }));
440
523
  }
441
524
 
442
525
  /** The vendor's gateway over its lanes: the lane a request goes to (a door to the lane that declares it, then the first
@@ -473,6 +556,12 @@ function laneRouter(vendor: string, decl: LanesDecl, lanes: Record<string, LaneP
473
556
  });
474
557
  const fallback = decl.default !== undefined ? served(decl.default) : undefined;
475
558
  return (request) => {
559
+ // `<base>/@<host>/<path>` at a World's place, for a host a lane's built content screen names, goes to that lane, before
560
+ // any lane is picked by the request's host or path: its own fetch answers it by its content route, under the vendor's
561
+ // host rules (architecture, "A page at a World's place")
562
+ const address = request.headers.has(TWIN_PREFIX_HEADER) ? contentAddressOf(new URL(request.url).pathname) : undefined;
563
+ const content = address && Object.keys(lanes).find((l) => lanes[l]!.fetch.contentHosts?.includes(address.host));
564
+ if (content) return served(content);
476
565
  const path = new URL(request.url).pathname.replace(/\/{2,}/g, '/');
477
566
  if (path.startsWith('/_twin/')) {
478
567
  const door = doors.find((d) => d.method === request.method && d.matches(path));
package/src/runtime.ts CHANGED
@@ -13,7 +13,7 @@ export { parseScenarioDocument, scenarioFaultResult, ScenarioEngine, ScenarioErr
13
13
  export { WORLD_CLOCK_ENV, worldNow } from './world-clock.ts';
14
14
  export { WORLD_ENV_NAMES_ENV, worldEnvValue } from './world-env.ts';
15
15
  export { ORIGINAL_PATH_HEADER } from './sigv4.ts';
16
- export { createTwinFetchFromHandler, TWIN_PREFIX_HEADER, TWIN_REQUEST_SCOPES, TWIN_SITES_HEADER, twinPublicBase, twinSiteUrl, withRequestScopes } from './twin-fetch.ts';
16
+ export { createTwinFetchFromHandler, TWIN_PREFIX_HEADER, TWIN_REQUEST_SCOPES, TWIN_SITES_HEADER, siteLabel, siteUrlOf, twinPublicBase, twinSiteUrl, withRequestScopes } from './twin-fetch.ts';
17
17
  export { isReadOnlyRequest, READ_ONLY_REQUEST_HEADER, ReadOnlyRequestError, runAsReadOnlyRequest, runAsVendorMove, writesRefused } from './request-scope.ts';
18
18
  export { compileSurface, createDerivedFetch, matchOperation } from './derived.ts';
19
19
  export { currentTraceparent, deliveryTraceHeaders, newTraceparent, parseTraceparent, runWithRequestTrace, runWithTraceparent, TRACEPARENT_HEADER, traceparentForDelivery, validTraceparent } from './trace-context.ts';
package/src/twin-fetch.ts CHANGED
@@ -44,17 +44,32 @@ export function twinPublicBase(request: Request): string {
44
44
  }
45
45
 
46
46
  /** Where a World shows a hostname its twins answer (a Workers Custom Domain, an R2 public domain): the address a person
47
- * opens it at, `{host}` standing for the hostname (`http://{host}.<world>--<org>.localhost:<port>`). A World that routes
48
- * its sites to a browser sends it on every request it forwards to a twin. */
47
+ * opens it at, from the World's sites template. `{host}` stands for the hostname as it is, where the host's names nest
48
+ * (`http://{host}.<world>--<org>.localhost:<port>`); `{site}` for the hostname as one DNS label, its dots as hyphens,
49
+ * where one wildcard certificate covers the host's names (`https://{site}--<world>--<org>.volterdev.com`). A World that
50
+ * routes its sites to a browser sends the template on every request it forwards to a twin. */
49
51
  export const TWIN_SITES_HEADER = 'x-volter-world-sites';
50
52
 
53
+ /** A hostname as one DNS label: its dots as hyphens (`www.volter.ai` is `www-volter-ai`). */
54
+ export const siteLabel = (hostname: string): string => hostname.toLowerCase().replace(/\./g, '-');
55
+
56
+ /** The address a sites template gives `hostname`, or null where the template is not one or the name makes no address
57
+ * (a label over 63 characters). */
58
+ export function siteUrlOf(template: string, hostname: string): string | null {
59
+ const host = hostname.toLowerCase();
60
+ if (!/^[a-z0-9.-]+$/.test(host)) return null;
61
+ // a one-label site is never an IDN's (`xn--…` would be read as punycode, and is no address)
62
+ const filled = /^https?:\/\/\{host\}\.[A-Za-z0-9.-]+(:\d+)?$/.test(template) ? template.replace('{host}', host)
63
+ : /^https?:\/\/\{site\}--[A-Za-z0-9.-]+(:\d+)?$/.test(template) && !host.startsWith('xn--') ? template.replace('{site}', siteLabel(host)) : null;
64
+ try { return filled && new URL(filled).hostname.split('.').every((l) => l.length > 0 && l.length <= 63) ? filled : null; } catch { return null; }
65
+ }
66
+
51
67
  /** Where a page links a hostname the twin answers: the World's address for it when the World shows sites, else the
52
68
  * hostname itself (reached through the World's proxy, or the vendor). A pack's screen links a site from this, as it
53
69
  * mints its own URLs from twinPublicBase. */
54
70
  export function twinSiteUrl(request: Request, hostname: string): string {
55
71
  const at = request.headers.get(TWIN_SITES_HEADER);
56
- const host = hostname.toLowerCase();
57
- return at && /^https?:\/\/\{host\}\.[A-Za-z0-9.-]+(:\d+)?$/.test(at) && /^[a-z0-9.-]+$/.test(host) ? at.replace('{host}', host) : `https://${host}`;
72
+ return (at ? siteUrlOf(at, hostname) : null) ?? `https://${hostname.toLowerCase()}`;
58
73
  }
59
74
 
60
75
  /** A twin's BYTE-STREAM door: a pack whose clients speak a TCP protocol (SMTP, the MySQL wire) serves
@@ -215,3 +230,29 @@ async function readScoped(run: (asReadOnly?: boolean) => TwinFetchHandlerResult
215
230
  if (!out.refused) return out.value;
216
231
  return { status: 405, body: { error: 'read_only', message: out.refused.message } };
217
232
  }
233
+
234
+ /** A twin's answer at a World's place with its cookies kept to the twin: every cookie it sets is scoped to the twin's
235
+ * base path there (`Path=/` and no Path become `Path=<prefix>`, which RFC 6265's path-match gives the twin's root
236
+ * `<prefix>` and every path under it and no sibling; a path under the vendor's root, `Path=/x`, becomes `<prefix>/x`),
237
+ * so one twin's sign-in never reaches a sibling twin served from the same pages origin (architecture, "A page at a
238
+ * World's place"). A `__Host-` cookie, which a browser takes only at `Path=/`, is left as set. An answer off a place, at
239
+ * a prefix the World never sends (outside `/[A-Za-z0-9._~/-]*`, which twinPublicBase refuses too), or setting no cookie
240
+ * is the answer as it is. */
241
+ export function placedCookies(request: Request, response: Response): Response {
242
+ const prefix = request.headers.get(TWIN_PREFIX_HEADER)?.replace(/\/+$/, '');
243
+ if (!prefix || !/^\/[A-Za-z0-9._~\/-]*$/.test(prefix)) return response;
244
+ const cookies = response.headers.getSetCookie?.() ?? [];
245
+ if (!cookies.length || response.status < 200) return response;
246
+ const scoped = cookies.map((cookie) => {
247
+ if (/^\s*__Host-/i.test(cookie)) return cookie;
248
+ const path = /;\s*path=([^;]*)/i.exec(cookie)?.[1]?.trim();
249
+ // already scoped (a lane's answer its vendor passes on): as it is
250
+ if (path !== undefined && (path === prefix || path.startsWith(`${prefix}/`))) return cookie;
251
+ const at = !path || path === '/' ? prefix : `${prefix}${path.startsWith('/') ? path : `/${path}`}`.replace(/\/+$/, '');
252
+ return path === undefined ? `${cookie}; Path=${at}` : cookie.replace(/(;\s*)path=[^;]*/i, `$1Path=${at}`);
253
+ });
254
+ const headers = new Headers(response.headers);
255
+ headers.delete('set-cookie');
256
+ for (const cookie of scoped) headers.append('set-cookie', cookie);
257
+ return new Response(response.body, { status: response.status, statusText: response.statusText, headers });
258
+ }
package/vendor-hosts.cjs CHANGED
@@ -55,6 +55,39 @@ const COMPILED = new Map();
55
55
  /** The hand table's keys, before any pack's rules: a pack installed later replaces its hand entry in place (keeping its
56
56
  * position, which decides who answers a shared host first), where the built-in facts may not declare one twice. */
57
57
  const HAND = new Set(Object.keys(VENDOR_HOSTS));
58
+ /** One host rule as a predicate over (hostname, pathname?): its one selector (host | suffix | hostPattern) and its
59
+ * pathPattern, a path left out matching any. */
60
+ function compileHostRule(rule, where) {
61
+ const selectors = ['host', 'suffix', 'hostPattern'].filter((k) => rule[k] !== undefined).length;
62
+ if (selectors !== 1) throw new Error(`inject: ${where} rule ${JSON.stringify(rule)} — exactly one of host | suffix | hostPattern (pack-facts.json is hand-edited or stale)`);
63
+ const hostRe = rule.hostPattern === undefined ? null : new RegExp(rule.hostPattern);
64
+ const pathRe = rule.pathPattern === undefined ? null : new RegExp(rule.pathPattern);
65
+ return (h, p) =>
66
+ (rule.host !== undefined ? h === rule.host : rule.suffix !== undefined ? h.endsWith(rule.suffix) : hostRe.test(h))
67
+ && (pathRe === null || p === undefined || pathRe.test(p));
68
+ }
69
+
70
+ /** A pack's host rules (its descriptor's `hosts`) read as the injector reads them: `names(host)`, whether any include
71
+ * rule's selector names the host, whatever the path; `takes(host, path)`, whether the request is the pack's (under
72
+ * one of its keys, an include matches and no exclude does). The content route at a World's place holds a
73
+ * `<base>/@<host>/<path>` to it (pack-fetch.ts). */
74
+ function hostRules(rules) {
75
+ const byKey = new Map();
76
+ for (const rule of rules || []) {
77
+ const key = rule.key === undefined ? '' : rule.key;
78
+ if (!byKey.has(key)) byKey.set(key, []);
79
+ byKey.get(key).push(rule);
80
+ }
81
+ const keys = [...byKey.values()].map((keyRules) => ({
82
+ includes: keyRules.filter((r) => r.exclude !== true).map((r) => compileHostRule(r, 'a pack')),
83
+ excludes: keyRules.filter((r) => r.exclude === true).map((r) => compileHostRule(r, 'a pack')),
84
+ }));
85
+ return {
86
+ names: (h) => keys.some((k) => k.includes.some((m) => m(h))),
87
+ takes: (h, p) => keys.some((k) => k.includes.some((m) => m(h, p)) && !k.excludes.some((m) => m(h, p))),
88
+ };
89
+ }
90
+
58
91
  function compilePackHosts(packs, overlay) {
59
92
  for (const vendor of Object.keys(packs)) {
60
93
  const rules = packs[vendor].hosts;
@@ -77,15 +110,7 @@ function compilePackHosts(packs, overlay) {
77
110
  if (VENDOR_HOSTS[key] && !(overlay && HAND.has(key))) {
78
111
  throw new Error(`inject: key "${key}" (pack ${vendor}) is declared twice — on this descriptor and in the hand VENDOR_HOSTS table or another pack's descriptor; a key has one home.`);
79
112
  }
80
- const compile = (rule) => {
81
- const selectors = ['host', 'suffix', 'hostPattern'].filter((k) => rule[k] !== undefined).length;
82
- if (selectors !== 1) throw new Error(`inject: key "${key}" (pack ${vendor}) rule ${JSON.stringify(rule)} — exactly one of host | suffix | hostPattern (pack-facts.json is hand-edited or stale)`);
83
- const hostRe = rule.hostPattern === undefined ? null : new RegExp(rule.hostPattern);
84
- const pathRe = rule.pathPattern === undefined ? null : new RegExp(rule.pathPattern);
85
- return (h, p) =>
86
- (rule.host !== undefined ? h === rule.host : rule.suffix !== undefined ? h.endsWith(rule.suffix) : hostRe.test(h))
87
- && (pathRe === null || p === undefined || pathRe.test(p));
88
- };
113
+ const compile = (rule) => compileHostRule(rule, `key "${key}" (pack ${vendor})`);
89
114
  const includes = keyRules.filter((r) => r.exclude !== true).map(compile);
90
115
  const excludes = keyRules.filter((r) => r.exclude === true).map(compile);
91
116
  VENDOR_HOSTS[key] = (h, p) => includes.some((m) => m(h, p)) && !excludes.some((m) => m(h, p));
@@ -141,4 +166,4 @@ function twinOrigins(env) {
141
166
  return map;
142
167
  }
143
168
 
144
- module.exports = { VENDOR_HOSTS, addPackHosts, isVendorHost, vendorsOfHost, twinEnvStem, twinOrigins };
169
+ module.exports = { VENDOR_HOSTS, hostRules, addPackHosts, isVendorHost, vendorsOfHost, twinEnvStem, twinOrigins };
@@ -6,3 +6,6 @@ export function vendorsOfHost(hostname: string): string[];
6
6
  export function twinEnvStem(vendor: string): string;
7
7
  /** The vendors whose twin `env` points at, each with its origin. */
8
8
  export function twinOrigins(env: Readonly<Record<string, string | undefined>>): Record<string, string>;
9
+ /** A pack's host rules (its descriptor's `hosts`, as data) read as the injector reads them: whether any include names a
10
+ * host whatever the path, and whether a request to a host and path is the pack's. */
11
+ export function hostRules(rules: ReadonlyArray<{ host?: string; suffix?: string; hostPattern?: string; pathPattern?: string; key?: string; exclude?: true }> | undefined): { names(hostname: string): boolean; takes(hostname: string, pathname: string): boolean };