@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.
@@ -8,9 +8,16 @@ export declare const TWIN_PREFIX_HEADER = "x-forwarded-prefix";
8
8
  * callback) from this, never from the bare origin, so the URL works behind a served World. */
9
9
  export declare function twinPublicBase(request: Request): string;
10
10
  /** Where a World shows a hostname its twins answer (a Workers Custom Domain, an R2 public domain): the address a person
11
- * opens it at, `{host}` standing for the hostname (`http://{host}.<world>--<org>.localhost:<port>`). A World that routes
12
- * its sites to a browser sends it on every request it forwards to a twin. */
11
+ * opens it at, from the World's sites template. `{host}` stands for the hostname as it is, where the host's names nest
12
+ * (`http://{host}.<world>--<org>.localhost:<port>`); `{site}` for the hostname as one DNS label, its dots as hyphens,
13
+ * where one wildcard certificate covers the host's names (`https://{site}--<world>--<org>.volterdev.com`). A World that
14
+ * routes its sites to a browser sends the template on every request it forwards to a twin. */
13
15
  export declare const TWIN_SITES_HEADER = "x-volter-world-sites";
16
+ /** A hostname as one DNS label: its dots as hyphens (`www.volter.ai` is `www-volter-ai`). */
17
+ export declare const siteLabel: (hostname: string) => string;
18
+ /** The address a sites template gives `hostname`, or null where the template is not one or the name makes no address
19
+ * (a label over 63 characters). */
20
+ export declare function siteUrlOf(template: string, hostname: string): string | null;
14
21
  /** Where a page links a hostname the twin answers: the World's address for it when the World shows sites, else the
15
22
  * hostname itself (reached through the World's proxy, or the vendor). A pack's screen links a site from this, as it
16
23
  * mints its own URLs from twinPublicBase. */
@@ -79,3 +86,11 @@ export interface TwinFetchAdapterConfig {
79
86
  responseHeaders?: Record<string, string>;
80
87
  }
81
88
  export declare function createTwinFetchFromHandler(handler: (request: TwinFetchHandlerRequest) => TwinFetchHandlerResult | Promise<TwinFetchHandlerResult>, config: TwinFetchAdapterConfig): (request: Request) => Promise<Response>;
89
+ /** 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
90
+ * base path there (`Path=/` and no Path become `Path=<prefix>`, which RFC 6265's path-match gives the twin's root
91
+ * `<prefix>` and every path under it and no sibling; a path under the vendor's root, `Path=/x`, becomes `<prefix>/x`),
92
+ * so one twin's sign-in never reaches a sibling twin served from the same pages origin (architecture, "A page at a
93
+ * World's place"). A `__Host-` cookie, which a browser takes only at `Path=/`, is left as set. An answer off a place, at
94
+ * a prefix the World never sends (outside `/[A-Za-z0-9._~/-]*`, which twinPublicBase refuses too), or setting no cookie
95
+ * is the answer as it is. */
96
+ export declare function placedCookies(request: Request, response: Response): Response;
@@ -41,16 +41,35 @@ export function twinPublicBase(request) {
41
41
  return `${origin}${prefix.replace(/\/+$/, '')}`;
42
42
  }
43
43
  /** Where a World shows a hostname its twins answer (a Workers Custom Domain, an R2 public domain): the address a person
44
- * opens it at, `{host}` standing for the hostname (`http://{host}.<world>--<org>.localhost:<port>`). A World that routes
45
- * its sites to a browser sends it on every request it forwards to a twin. */
44
+ * opens it at, from the World's sites template. `{host}` stands for the hostname as it is, where the host's names nest
45
+ * (`http://{host}.<world>--<org>.localhost:<port>`); `{site}` for the hostname as one DNS label, its dots as hyphens,
46
+ * where one wildcard certificate covers the host's names (`https://{site}--<world>--<org>.volterdev.com`). A World that
47
+ * routes its sites to a browser sends the template on every request it forwards to a twin. */
46
48
  export const TWIN_SITES_HEADER = 'x-volter-world-sites';
49
+ /** A hostname as one DNS label: its dots as hyphens (`www.volter.ai` is `www-volter-ai`). */
50
+ export const siteLabel = (hostname) => hostname.toLowerCase().replace(/\./g, '-');
51
+ /** The address a sites template gives `hostname`, or null where the template is not one or the name makes no address
52
+ * (a label over 63 characters). */
53
+ export function siteUrlOf(template, hostname) {
54
+ const host = hostname.toLowerCase();
55
+ if (!/^[a-z0-9.-]+$/.test(host))
56
+ return null;
57
+ // a one-label site is never an IDN's (`xn--…` would be read as punycode, and is no address)
58
+ const filled = /^https?:\/\/\{host\}\.[A-Za-z0-9.-]+(:\d+)?$/.test(template) ? template.replace('{host}', host)
59
+ : /^https?:\/\/\{site\}--[A-Za-z0-9.-]+(:\d+)?$/.test(template) && !host.startsWith('xn--') ? template.replace('{site}', siteLabel(host)) : null;
60
+ try {
61
+ return filled && new URL(filled).hostname.split('.').every((l) => l.length > 0 && l.length <= 63) ? filled : null;
62
+ }
63
+ catch {
64
+ return null;
65
+ }
66
+ }
47
67
  /** Where a page links a hostname the twin answers: the World's address for it when the World shows sites, else the
48
68
  * hostname itself (reached through the World's proxy, or the vendor). A pack's screen links a site from this, as it
49
69
  * mints its own URLs from twinPublicBase. */
50
70
  export function twinSiteUrl(request, hostname) {
51
71
  const at = request.headers.get(TWIN_SITES_HEADER);
52
- const host = hostname.toLowerCase();
53
- 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()}`;
54
73
  }
55
74
  /** The request scopes a pack wrapped in `withRequestScopes` enforces, advertised on its `GET /twin` as
56
75
  * `requestScopes`: `read` — a request carrying `x-volter-read-only: 1` (request-scope.ts) has every
@@ -159,3 +178,33 @@ async function readScoped(run) {
159
178
  return out.value;
160
179
  return { status: 405, body: { error: 'read_only', message: out.refused.message } };
161
180
  }
181
+ /** 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
182
+ * base path there (`Path=/` and no Path become `Path=<prefix>`, which RFC 6265's path-match gives the twin's root
183
+ * `<prefix>` and every path under it and no sibling; a path under the vendor's root, `Path=/x`, becomes `<prefix>/x`),
184
+ * so one twin's sign-in never reaches a sibling twin served from the same pages origin (architecture, "A page at a
185
+ * World's place"). A `__Host-` cookie, which a browser takes only at `Path=/`, is left as set. An answer off a place, at
186
+ * a prefix the World never sends (outside `/[A-Za-z0-9._~/-]*`, which twinPublicBase refuses too), or setting no cookie
187
+ * is the answer as it is. */
188
+ export function placedCookies(request, response) {
189
+ const prefix = request.headers.get(TWIN_PREFIX_HEADER)?.replace(/\/+$/, '');
190
+ if (!prefix || !/^\/[A-Za-z0-9._~\/-]*$/.test(prefix))
191
+ return response;
192
+ const cookies = response.headers.getSetCookie?.() ?? [];
193
+ if (!cookies.length || response.status < 200)
194
+ return response;
195
+ const scoped = cookies.map((cookie) => {
196
+ if (/^\s*__Host-/i.test(cookie))
197
+ return cookie;
198
+ const path = /;\s*path=([^;]*)/i.exec(cookie)?.[1]?.trim();
199
+ // already scoped (a lane's answer its vendor passes on): as it is
200
+ if (path !== undefined && (path === prefix || path.startsWith(`${prefix}/`)))
201
+ return cookie;
202
+ const at = !path || path === '/' ? prefix : `${prefix}${path.startsWith('/') ? path : `/${path}`}`.replace(/\/+$/, '');
203
+ return path === undefined ? `${cookie}; Path=${at}` : cookie.replace(/(;\s*)path=[^;]*/i, `$1Path=${at}`);
204
+ });
205
+ const headers = new Headers(response.headers);
206
+ headers.delete('set-cookie');
207
+ for (const cookie of scoped)
208
+ headers.append('set-cookie', cookie);
209
+ return new Response(response.body, { status: response.status, statusText: response.statusText, headers });
210
+ }
@@ -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 };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/world-core",
3
- "version": "3.0.17",
3
+ "version": "3.0.19",
4
4
  "description": "The kernel of Volter World: one log per twin, branches as pointers, checkpoints, the fold that keeps a twin current, the head that performs a write against the vendor, references, and the git plane. A twin package builds on it; the runtime serves it.",
5
5
  "keywords": [
6
6
  "twin",
@@ -0,0 +1,24 @@
1
+ // THE CONTENT ROUTE'S ADDRESS (architecture, "Screens", "A page at a World's place"): at a World's place, what a page
2
+ // reaches on another of the pack's hosts that a content screen serves is `<base>/@<host>/<path>`. This reads that
3
+ // address as a browser would have written it: a doubled slash collapsed, `%40` for `@`, the host's case, a trailing dot
4
+ // and a default port (80, 443) normalised away. What it reads is a candidate only: the route is taken when the host is
5
+ // one a built content screen names (pack-fetch.ts), and a request naming no such host is general routing's, as before.
6
+
7
+ /** A `/@<host>/<path>` address: the host normalised, the path under it (slashes collapsed), and why the spelling cannot
8
+ * be the host's content (`refused`: a port other than the default), when it cannot. */
9
+ export type ContentAddress = { host: string; path: string; refused?: string };
10
+
11
+ export function contentAddressOf(pathname: string): ContentAddress | undefined {
12
+ const collapsed = pathname.replace(/\/{2,}/g, '/');
13
+ const named = /^\/(?:@|%40)([^/]+)(\/.*)?$/i.exec(collapsed);
14
+ if (!named) return undefined;
15
+ let token: string;
16
+ try { token = decodeURIComponent(named[1]!).toLowerCase(); } catch { return undefined; }
17
+ const at = /^([^:]*)(?::(\d*))?$/.exec(token);
18
+ if (!at) return undefined;
19
+ const host = at[1]!.replace(/\.$/, '');
20
+ if (!/^[a-z0-9_-]+(?:\.[a-z0-9_-]+)+$/.test(host)) return undefined;
21
+ const port = at[2];
22
+ const refused = port && port !== '80' && port !== '443' ? `port ${port} is not the host's` : undefined;
23
+ return { host, path: named[2] ?? '/', ...(refused ? { refused } : {}) };
24
+ }
@@ -16,7 +16,7 @@ import { worldNow } from './world-clock.ts';
16
16
  import { worldEnvValue } from './world-env.ts';
17
17
  import { openSessions, socketWrite, type SocketDecl, type SocketSession } from './sockets.ts';
18
18
  import { MACHINE_POOL, poolFor, type MachinePool } from './machines.ts';
19
- import { askApplication, deliverEvents, deliverToApplication, type ApplicationAnswer, type DeliveryAnswer, type EventRender, type EventScheme, type EventsDecl, type EventValues } from './events.ts';
19
+ import { askApplication, deliverEvents, deliverToApplication, type ApplicationAnswer, type ApplicationStandIn, type DeliveryAnswer, type EventRender, type EventScheme, type EventsDecl, type EventValues } from './events.ts';
20
20
  import type { RateBudgetDeclaration } from './rateBudget.ts';
21
21
  import { handlerCrypto, hmac, lettersFrom, sha256, uuidFrom, type HandlerCrypto } from './signing.ts';
22
22
  import { hashFieldValue } from './hash.ts';
@@ -214,7 +214,9 @@ export type LaneRoute = {
214
214
  export type LanesDecl = { cors?: CorsDecl; routes: ReadonlyArray<LaneRoute>; default?: string };
215
215
  /** The manifest of a vendor whose every API is a lane (Cloudflare's API v4 and R2, AWS's services): no API of its own,
216
216
  * only what the vendor's front says — its discovery facts, its lanes and its descriptor. */
217
- export type VendorManifest = { vendor: string; discovery?: DerivedManifest['discovery']; lanes: LanesDecl; descriptor: Omit<PackDescriptor, 'vendor'>; ingest?: IngestDecl; rateBudget?: RateBudgetDeclaration };
217
+ export type VendorManifest = { vendor: string; discovery?: DerivedManifest['discovery']; lanes: LanesDecl; descriptor: Omit<PackDescriptor, 'vendor'>; ingest?: IngestDecl; rateBudget?: RateBudgetDeclaration;
218
+ /** the vendor has no vendor-backed half (architecture, "The descriptor"): packOf copies it, and derives no state system */
219
+ vendorBacked?: DerivedManifest['vendorBacked'] };
218
220
 
219
221
  /** `code` as the vendor writes it: a string, or a number (Discord's JSON error codes are integers) */
220
222
  export type ErrorSpec = { status: number; message: string; code?: string | number; param?: string; kind?: string };
@@ -235,6 +237,15 @@ export type ScreenDecl = {
235
237
  demand: string;
236
238
  status: 'done' | 'todo';
237
239
  controls?: string[];
240
+ /** wherever this screen would answer (its host, a World's place, its path alone), a request the pack's API names for
241
+ * its method is the operation's, not this screen's: a site whose pages draw while its forms and links act on
242
+ * operations the spec writes at the same paths (Hacker News's /login page posts to POST /login; its front page at `/`
243
+ * would otherwise take GET /vote) (architecture, "Screens") */
244
+ yieldsToApi?: true;
245
+ /** a content screen that takes a form at a World's place (`<base>/@<host>/…`), beside GET and HEAD: the methods it
246
+ * takes, with its demand (an upload bucket a page posts a lease's form to: Reddit's S3 hosts) (architecture, "A page
247
+ * at a World's place") */
248
+ takes?: ReadonlyArray<'POST'>;
238
249
  /** the vendor's documentation of the round trip or the screen */
239
250
  source: string;
240
251
  };
@@ -524,6 +535,14 @@ export function flagOf(m: Pick<DerivedManifest, 'booleans'>, value: unknown): bo
524
535
  * text: a field the operation's spec types as a number or a boolean (`scalars`, by bracket path, written by
525
536
  * world-tooling's formScalarsOf) is read as one when its text is that literal; every other field stays the text sent
526
537
  * (`metadata[order]=007`, `name=2024`). */
538
+ /** A field set as the object's own, whatever its name (`__proto__`, `constructor` and `prototype` are data a caller
539
+ * may name: Stripe's metadata keys are the caller's), never through what the object inherits. */
540
+ export function ownField(node: Record<string, unknown> | unknown[], key: string, value: unknown): void {
541
+ Object.defineProperty(node, key, { value, writable: true, enumerable: true, configurable: true });
542
+ }
543
+ /** A field the object holds as its own, never one it inherits (`constructor` is every object's). */
544
+ const ownValue = (node: Record<string, unknown> | unknown[], key: string): unknown => (Object.hasOwn(node, key) ? (node as Record<string, unknown>)[key] : undefined);
545
+
527
546
  export function parseBracketForm(text: string, scalars?: Readonly<Record<string, string | undefined>>): Record<string, unknown> {
528
547
  const out: Record<string, unknown> = {};
529
548
  for (const [rawKey, raw] of new URLSearchParams(text)) {
@@ -533,18 +552,26 @@ export function parseBracketForm(text: string, scalars?: Readonly<Record<string,
533
552
  : kind === 'integer' ? (/^-?\d+$/.test(raw) ? Number(raw) : raw)
534
553
  : kind === 'number' ? (/^-?\d+(\.\d+)?$/.test(raw) ? Number(raw) : raw)
535
554
  : raw;
536
- let node: any = out;
537
- parts.forEach((key, i) => {
555
+ // every field is the node's own, read and written as such: a key naming an object's machinery (`__proto__[x]`,
556
+ // `constructor[prototype][x]`) is a field of that name, never a walk into what every object inherits
557
+ let node: Record<string, unknown> | unknown[] = out;
558
+ for (const [i, key] of parts.entries()) {
559
+ // an array holds its items by index (`a[]`, `a[0]`): a named key under one (`a[]=1&a[length]=-1`) has nowhere to
560
+ // go, as a key under text has none (`a=1&a[b]=2`): the first shape stands
561
+ if (Array.isArray(node) && key !== '' && !/^\d+$/.test(key)) break;
538
562
  if (i === parts.length - 1) {
539
563
  if (key === '') {
540
564
  if (Array.isArray(node)) node.push(value);
541
- } else node[key] = value;
542
- return;
565
+ } else ownField(node, key, value);
566
+ break;
543
567
  }
544
568
  const next = parts[i + 1]!;
545
- if (node[key] === undefined) node[key] = next === '' || /^\d+$/.test(next) ? [] : {};
546
- node = node[key];
547
- });
569
+ if (key === '') break;
570
+ let child = ownValue(node, key);
571
+ if (child === undefined) { child = next === '' || /^\d+$/.test(next) ? [] : {}; ownField(node, key, child); }
572
+ if (child === null || typeof child !== 'object') break;
573
+ node = child as Record<string, unknown> | unknown[];
574
+ }
548
575
  }
549
576
  return out;
550
577
  }
@@ -580,26 +607,38 @@ export async function readParams(manifest: DerivedManifest, request: Request, op
580
607
  const parts = boundary === null ? [] : multipartParts(new Uint8Array(await request.arrayBuffer()), boundary);
581
608
  const out: Record<string, unknown> = { ...query };
582
609
  for (const part of parts) {
610
+ // each part a field of its own name, whatever the name (`__proto__` included: ownField)
583
611
  if (part.filename === null) {
584
- out[part.name] = new TextDecoder().decode(part.body);
612
+ ownField(out, part.name, new TextDecoder().decode(part.body));
585
613
  continue;
586
614
  }
587
615
  const bytes = part.body;
588
616
  const parsed = { name: part.filename, type: part.type ?? '', size: bytes.byteLength, content: new TextDecoder().decode(bytes) };
589
- out[part.name] = Object.defineProperty(parsed, 'bytes', { value: bytes, enumerable: false });
617
+ ownField(out, part.name, Object.defineProperty(parsed, 'bytes', { value: bytes, enumerable: false }));
590
618
  }
591
619
  return out;
592
620
  }
593
- const text = await bodyTextOf(request);
594
- if (!text) return query;
595
621
  // NDJSON (`application/x-ndjson`) is lines of JSON, not a JSON document: the handler reads its text
596
- const isJson = type.includes('json') && !type.includes('ndjson');
622
+ const media = (type.split(';')[0] ?? '').trim().toLowerCase();
623
+ const isJson = media.includes('json') && !media.includes('ndjson');
624
+ // a form is a body labelled one (application/x-www-form-urlencoded); a body of any other type (an image, a video, an
625
+ // octet-stream PUT to an upload URL, a text/plain SQL statement, an NDJSON batch) is no form: its parameters are the
626
+ // query's, and the handler reads its text or bytes itself
627
+ const isForm = media === 'application/x-www-form-urlencoded';
597
628
  const asJson = isJson || manifest.body.json === 'always';
629
+ if (!isForm && !asJson) return query;
630
+ const text = await bodyTextOf(request);
631
+ if (!text) return query;
598
632
  // `json: 'always'` reads a body labelled otherwise as JSON (GitHub reads curl -d's); a body that is not JSON is read
599
- // as the form it is labelled, never a crash (a sign-in page's own form, posted to the vendor's page)
633
+ // as a form only when it is labelled one (a sign-in page's own form, posted to the vendor's page), never a crash
600
634
  let body: unknown;
601
- if (asJson) { try { body = JSON.parse(text); } catch { if (isJson) throw new SyntaxError('malformed JSON body'); body = parseBracketForm(text, scalars); } }
602
- else body = parseBracketForm(text, scalars);
635
+ if (asJson) {
636
+ try { body = JSON.parse(text); } catch {
637
+ if (isJson) throw new SyntaxError('malformed JSON body');
638
+ if (!isForm) return query;
639
+ body = parseBracketForm(text, scalars);
640
+ }
641
+ } else body = parseBracketForm(text, scalars);
603
642
  // a body that is not an object (GitHub's set-labels takes a bare array) is the body, not fields
604
643
  return body && typeof body === 'object' && !Array.isArray(body) ? { ...query, ...(body as Record<string, unknown>) } : { ...query };
605
644
  }
@@ -743,6 +782,8 @@ export type CoreScope = {
743
782
  root?: string; clock?: () => string; database?: string; readOnly?: boolean;
744
783
  /** `semantics/tenant.ts`: the tenant the caller acts in (the manifest's `tenant`), or undefined for none */
745
784
  tenant?: (ctx: HandlerContext) => string | undefined | Promise<string | undefined>;
785
+ /** the application a walk stands in for: what the vendor asks it (`ctx.ask`) is answered by it (events.ts) */
786
+ application?: ApplicationStandIn;
746
787
  };
747
788
 
748
789
  function now(m: DerivedManifest, at: string): unknown {
@@ -979,6 +1020,9 @@ function recordedInput(m: DerivedManifest, call: DerivedCall, merged: Record<str
979
1020
  return { operationId: id, ...asSent };
980
1021
  }
981
1022
 
1023
+ /** The scope each call is served in (serveCore, contextFor), for the contexts a write opens for its hooks. */
1024
+ const scopeOfCall = new WeakMap<DerivedCall, CoreScope>();
1025
+
982
1026
  async function writeDetailed(m: DerivedManifest, call: DerivedCall, resource: string, id: string, fields: Record<string, unknown>, operation: string, params: Record<string, unknown>, root: string | undefined, occurredAt: string): Promise<{ body: Record<string, unknown>; id: string; vendorData?: unknown }> {
983
1027
  const withFiles = async (input: Record<string, unknown>): Promise<Record<string, unknown>> => { const files = input.operationId ? await keptFiles(m, call, root) : undefined; return files ? { ...input, files } : input; };
984
1028
  const { resource: row, result } = await applyTwinWrite(
@@ -993,7 +1037,9 @@ async function writeDetailed(m: DerivedManifest, call: DerivedCall, resource: st
993
1037
  );
994
1038
  await keepCounts(m, call, resource, row as unknown as Record<string, unknown>, params, root, occurredAt);
995
1039
  const body = view(m, resource, row);
996
- const context = (): Promise<SemanticsContext> => contextFor(m, { ...call, request: call.request.clone() }, { ...(root !== undefined ? { root } : {}), clock: () => occurredAt });
1040
+ // the write's own context for its hooks, in the scope the call was served in (a walk's application answers, a tenant,
1041
+ // the managed database), at the write's moment
1042
+ const context = (): Promise<SemanticsContext> => contextFor(m, { ...call, request: call.request.clone() }, { ...(scopeOfCall.get(call) ?? {}), ...(root !== undefined ? { root } : {}), clock: () => occurredAt });
997
1043
  if (m.onWrite) await m.onWrite({ operation, storedType: storedType(m, resource), body, ...(root !== undefined ? { root } : {}), occurredAt, request: call.request, context });
998
1044
  if (m.events) {
999
1045
  const events = m.events;
@@ -1314,6 +1360,7 @@ export async function serveCore(m: DerivedManifest, call: DerivedCall, scope: Co
1314
1360
  const decl = resource ? m.resources[resource] : undefined;
1315
1361
  if (!resource || !decl) return { unmodeled: `no manifest entry for resource ${resource ?? '(none)'}` };
1316
1362
  call = adoptedPath(m, call, root);
1363
+ scopeOfCall.set(call, scope);
1317
1364
  // the body is read from a copy, so the call's own request is still unread for the write hook's context
1318
1365
  const params = await boundaryParams(m, { ...call, request: call.request.clone() }, root);
1319
1366
  const idParam = subjectOf(m, resource, call);
@@ -1626,6 +1673,7 @@ export async function contextFor(m: DerivedManifest, call: DerivedCall, scope: C
1626
1673
  const root = scope.root;
1627
1674
  const asAsked = call;
1628
1675
  call = adoptedPath(m, call, root);
1676
+ scopeOfCall.set(call, scope);
1629
1677
  const at = (scope.clock ?? worldNow)();
1630
1678
  // the guard's parse when it made one (taken once), else this context's own
1631
1679
  const parsed = takeParsedBody(call.request);
@@ -1658,7 +1706,7 @@ export async function contextFor(m: DerivedManifest, call: DerivedCall, scope: C
1658
1706
  publicBase: twinPublicBase(call.request),
1659
1707
  scenario: scenarioDecisionOf(call.request),
1660
1708
  deliver: (url, init) => deliverToApplication(url, init),
1661
- ask: (url, init, within) => askApplication(url, init, within),
1709
+ ask: (url, init, within) => askApplication(url, init, within, scope.application),
1662
1710
  worldEnv: (name) => worldEnvValue(name),
1663
1711
  asVendor: (fn) => runAsVendorMove(fn),
1664
1712
  git: (name) => {
package/src/derived.ts CHANGED
@@ -202,6 +202,9 @@ export type DerivedFetch = ((request: Request) => Promise<Response>) & {
202
202
  boards?: boolean;
203
203
  /** Whether a built screen of it takes a URL: a board frame it draws. */
204
204
  draws?(url: string): boolean;
205
+ /** The hosts its built content screens name: a vendor's lane router sends `<base>/@<host>/<path>` for one of them to
206
+ * this fetch (pack-fetch.ts). */
207
+ contentHosts?: ReadonlyArray<string>;
205
208
  };
206
209
 
207
210
  /** A workspace screen a pack draws, as `GET /twin` lists it: its id and path (Viewing a World). */
package/src/events.ts CHANGED
@@ -323,9 +323,26 @@ export async function deliverToApplication(url: string, init: RequestInit): Prom
323
323
  * World let no request out to it or it could not be reached (`unreachable`), or it did not answer in time (`timeout`). */
324
324
  export type ApplicationAnswer = { status: number; body: string; missed?: 'timeout' | 'unreachable' };
325
325
 
326
+ /** Who answers what the vendor asks the application (`ctx.ask`) before the World's application route does, given to a
327
+ * pack's fetch (`PackFetchOptions.application`) and belonging to that fetch alone: an answer it gives is the
328
+ * application's; `'unreachable'` is a question nobody answers (a walk's life, a sealed World, takes no other); undefined
329
+ * is a question it does not take, which goes on as any other does: the application route, the World's egress rule, the
330
+ * network. A walk's stand-in answers every question (its life's answers, else unreachable); a hosted World's router
331
+ * answers only what it can answer truthfully (WorldDoors.askInWorld) and leaves the rest. */
332
+ export type ApplicationStandIn = (url: string, init: RequestInit) => Promise<{ status: number; body: string } | 'unreachable' | undefined>;
333
+
326
334
  /** A message the vendor sends the application's server and decides by its answer (Stripe's real-time authorization
327
- * request, answered within its window): over the same transport, waited on for at most `within` milliseconds. */
328
- export async function askApplication(url: string, init: RequestInit, within: number): Promise<ApplicationAnswer> {
335
+ * request, answered within its window): over the same transport, waited on for at most `within` milliseconds, the
336
+ * fetch's stand-in first when it was given one (its window the same). */
337
+ export async function askApplication(url: string, init: RequestInit, within: number, standIn?: ApplicationStandIn): Promise<ApplicationAnswer> {
338
+ if (standIn) {
339
+ let timer: ReturnType<typeof setTimeout> | undefined;
340
+ const late = new Promise<'late'>((done) => { timer = setTimeout(() => done('late'), within); });
341
+ const stood = await Promise.race([standIn(url, init), late]).finally(() => clearTimeout(timer));
342
+ if (stood === 'late') return { status: 0, body: '', missed: 'timeout' };
343
+ if (stood === 'unreachable') return { status: 0, body: '', missed: 'unreachable' };
344
+ if (stood !== undefined) return stood;
345
+ }
329
346
  const [{ appDestination, appFetch }, { worldEgressRefusal }] = await Promise.all([
330
347
  import('../app-route.cjs') as Promise<{ appDestination: (value: string) => unknown; appFetch: (url: string, init: RequestInit) => Promise<{ status: number; text(): Promise<string> }> }>,
331
348
  import('../network-policy.cjs') as Promise<{ worldEgressRefusal: (value: string) => unknown }>,
@@ -22,9 +22,10 @@ export function fileResponse(path: string, init?: ResponseInit): Response {
22
22
  }
23
23
 
24
24
  /**
25
- * Stored bytes as a response, honouring a single `Range: bytes=` as RFC 9110 §14 has it: a range that does not parse, a
26
- * first-byte-pos past its last-byte-pos (`bytes=5-2`) included, is ignored and the whole body is a 200; a well-formed
27
- * range that starts past the end is a 416. `nosniff` stops a browser second-guessing the content type the vendor gave.
25
+ * Stored bytes as a response, honouring a single `Range: bytes=` as RFC 9110 §14 has it: every satisfiable range is a
26
+ * 206 with its Content-Range, `bytes=0-` (the whole body) included; a range that does not parse, a first-byte-pos past
27
+ * its last-byte-pos (`bytes=5-2`) included, is ignored and the whole body is a 200; a well-formed range that starts past
28
+ * the end (any range of an empty body) is a 416. `nosniff` stops a browser second-guessing the content type the vendor gave.
28
29
  */
29
30
  export function bytesResponse(request: Request, bytes: Uint8Array, contentType: string, cacheControl = 'public, max-age=604800, immutable'): Response {
30
31
  const total = bytes.length;
@@ -40,7 +41,9 @@ export function bytesResponse(request: Request, bytes: Uint8Array, contentType:
40
41
  }
41
42
  if (start >= total || (range[1] === '' && Number(range[2]) === 0)) return new Response(null, { status: 416, headers: { ...base, 'content-range': `bytes */${total}` } });
42
43
  }
43
- const partial = start !== 0 || end !== total - 1;
44
+ // every satisfiable range is answered as one, the whole body's `bytes=0-` included: a media player (Safari's) reads a
45
+ // 206 with its Content-Range as the server taking ranges, and a 200 as one it cannot seek in
46
+ const partial = valid;
44
47
  const body = bytes.slice(start, end + 1);
45
48
  return new Response(request.method === 'HEAD' ? null : new Blob([body]), {
46
49
  status: partial ? 206 : 200,
package/src/index.ts CHANGED
@@ -52,7 +52,7 @@ export { isReadOnlyRequest, READ_ONLY_REQUEST_HEADER } from './request-scope.ts'
52
52
  export { serveHttp, serveStream, type HttpHandler, type HttpServer, type ServeHttpOptions } from './serve-http.ts';
53
53
  export type { TwinStream, TwinStreamConnection, TwinStreamSink } from './twin-fetch.ts';
54
54
  // where a page links a hostname its twin answers, inside the World that shows it
55
- export { twinSiteUrl } from './twin-fetch.ts';
55
+ export { siteLabel, siteUrlOf, twinSiteUrl } from './twin-fetch.ts';
56
56
  export { statefulTwinManifest, twinManifest } from './scenario.ts';
57
57
  export { assetContentType, packAsset } from './pack-assets.ts';
58
58
  export { bytesResponse, contentTypeOf, fileResponse } from './file-response.ts';