@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.
@@ -0,0 +1,8 @@
1
+ /** A `/@<host>/<path>` address: the host normalised, the path under it (slashes collapsed), and why the spelling cannot
2
+ * be the host's content (`refused`: a port other than the default), when it cannot. */
3
+ export type ContentAddress = {
4
+ host: string;
5
+ path: string;
6
+ refused?: string;
7
+ };
8
+ export declare function contentAddressOf(pathname: string): ContentAddress | undefined;
@@ -0,0 +1,27 @@
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
+ export function contentAddressOf(pathname) {
7
+ const collapsed = pathname.replace(/\/{2,}/g, '/');
8
+ const named = /^\/(?:@|%40)([^/]+)(\/.*)?$/i.exec(collapsed);
9
+ if (!named)
10
+ return undefined;
11
+ let token;
12
+ try {
13
+ token = decodeURIComponent(named[1]).toLowerCase();
14
+ }
15
+ catch {
16
+ return undefined;
17
+ }
18
+ const at = /^([^:]*)(?::(\d*))?$/.exec(token);
19
+ if (!at)
20
+ return undefined;
21
+ const host = at[1].replace(/\.$/, '');
22
+ if (!/^[a-z0-9_-]+(?:\.[a-z0-9_-]+)+$/.test(host))
23
+ return undefined;
24
+ const port = at[2];
25
+ const refused = port && port !== '80' && port !== '443' ? `port ${port} is not the host's` : undefined;
26
+ return { host, path: named[2] ?? '/', ...(refused ? { refused } : {}) };
27
+ }
@@ -3,7 +3,7 @@ import { GitObjectStore } from './git/objects.js';
3
3
  import { GitRefs } from './git/refs.js';
4
4
  import { type SocketDecl, type SocketSession } from './sockets.js';
5
5
  import { type MachinePool } from './machines.js';
6
- import { type ApplicationAnswer, type EventRender, type EventScheme, type EventsDecl, type EventValues } from './events.js';
6
+ import { type ApplicationAnswer, type ApplicationStandIn, type EventRender, type EventScheme, type EventsDecl, type EventValues } from './events.js';
7
7
  import type { RateBudgetDeclaration } from './rateBudget.js';
8
8
  import { type HandlerCrypto } from './signing.js';
9
9
  import { type ManagedDatabase } from './managed-database.js';
@@ -268,6 +268,8 @@ export type VendorManifest = {
268
268
  descriptor: Omit<PackDescriptor, 'vendor'>;
269
269
  ingest?: IngestDecl;
270
270
  rateBudget?: RateBudgetDeclaration;
271
+ /** the vendor has no vendor-backed half (architecture, "The descriptor"): packOf copies it, and derives no state system */
272
+ vendorBacked?: DerivedManifest['vendorBacked'];
271
273
  };
272
274
  /** `code` as the vendor writes it: a string, or a number (Discord's JSON error codes are integers) */
273
275
  export type ErrorSpec = {
@@ -293,6 +295,15 @@ export type ScreenDecl = {
293
295
  demand: string;
294
296
  status: 'done' | 'todo';
295
297
  controls?: string[];
298
+ /** wherever this screen would answer (its host, a World's place, its path alone), a request the pack's API names for
299
+ * its method is the operation's, not this screen's: a site whose pages draw while its forms and links act on
300
+ * operations the spec writes at the same paths (Hacker News's /login page posts to POST /login; its front page at `/`
301
+ * would otherwise take GET /vote) (architecture, "Screens") */
302
+ yieldsToApi?: true;
303
+ /** a content screen that takes a form at a World's place (`<base>/@<host>/…`), beside GET and HEAD: the methods it
304
+ * takes, with its demand (an upload bucket a page posts a lease's form to: Reddit's S3 hosts) (architecture, "A page
305
+ * at a World's place") */
306
+ takes?: ReadonlyArray<'POST'>;
296
307
  /** the vendor's documentation of the round trip or the screen */
297
308
  source: string;
298
309
  };
@@ -651,6 +662,9 @@ export declare function flagOf(m: Pick<DerivedManifest, 'booleans'>, value: unkn
651
662
  * text: a field the operation's spec types as a number or a boolean (`scalars`, by bracket path, written by
652
663
  * world-tooling's formScalarsOf) is read as one when its text is that literal; every other field stays the text sent
653
664
  * (`metadata[order]=007`, `name=2024`). */
665
+ /** A field set as the object's own, whatever its name (`__proto__`, `constructor` and `prototype` are data a caller
666
+ * may name: Stripe's metadata keys are the caller's), never through what the object inherits. */
667
+ export declare function ownField(node: Record<string, unknown> | unknown[], key: string, value: unknown): void;
654
668
  export declare function parseBracketForm(text: string, scalars?: Readonly<Record<string, string | undefined>>): Record<string, unknown>;
655
669
  /** A request's parameters, its query's and its body's. With the manifest's `body.form.coerce`, a form's fields the
656
670
  * operation's spec types as numbers or booleans are read as them (parseBracketForm); without an operation (a twin door)
@@ -686,6 +700,8 @@ export type CoreScope = {
686
700
  readOnly?: boolean;
687
701
  /** `semantics/tenant.ts`: the tenant the caller acts in (the manifest's `tenant`), or undefined for none */
688
702
  tenant?: (ctx: HandlerContext) => string | undefined | Promise<string | undefined>;
703
+ /** the application a walk stands in for: what the vendor asks it (`ctx.ask`) is answered by it (events.ts) */
704
+ application?: ApplicationStandIn;
689
705
  };
690
706
  /** The lane a vendor's router sent a request to (pack-fetch's laneRouter sets it). */
691
707
  export declare const LANE_HEADER = "x-volter-lane";
@@ -124,6 +124,13 @@ export function flagOf(m, value) {
124
124
  * text: a field the operation's spec types as a number or a boolean (`scalars`, by bracket path, written by
125
125
  * world-tooling's formScalarsOf) is read as one when its text is that literal; every other field stays the text sent
126
126
  * (`metadata[order]=007`, `name=2024`). */
127
+ /** A field set as the object's own, whatever its name (`__proto__`, `constructor` and `prototype` are data a caller
128
+ * may name: Stripe's metadata keys are the caller's), never through what the object inherits. */
129
+ export function ownField(node, key, value) {
130
+ Object.defineProperty(node, key, { value, writable: true, enumerable: true, configurable: true });
131
+ }
132
+ /** A field the object holds as its own, never one it inherits (`constructor` is every object's). */
133
+ const ownValue = (node, key) => (Object.hasOwn(node, key) ? node[key] : undefined);
127
134
  export function parseBracketForm(text, scalars) {
128
135
  const out = {};
129
136
  for (const [rawKey, raw] of new URLSearchParams(text)) {
@@ -133,22 +140,35 @@ export function parseBracketForm(text, scalars) {
133
140
  : kind === 'integer' ? (/^-?\d+$/.test(raw) ? Number(raw) : raw)
134
141
  : kind === 'number' ? (/^-?\d+(\.\d+)?$/.test(raw) ? Number(raw) : raw)
135
142
  : raw;
143
+ // every field is the node's own, read and written as such: a key naming an object's machinery (`__proto__[x]`,
144
+ // `constructor[prototype][x]`) is a field of that name, never a walk into what every object inherits
136
145
  let node = out;
137
- parts.forEach((key, i) => {
146
+ for (const [i, key] of parts.entries()) {
147
+ // an array holds its items by index (`a[]`, `a[0]`): a named key under one (`a[]=1&a[length]=-1`) has nowhere to
148
+ // go, as a key under text has none (`a=1&a[b]=2`): the first shape stands
149
+ if (Array.isArray(node) && key !== '' && !/^\d+$/.test(key))
150
+ break;
138
151
  if (i === parts.length - 1) {
139
152
  if (key === '') {
140
153
  if (Array.isArray(node))
141
154
  node.push(value);
142
155
  }
143
156
  else
144
- node[key] = value;
145
- return;
157
+ ownField(node, key, value);
158
+ break;
146
159
  }
147
160
  const next = parts[i + 1];
148
- if (node[key] === undefined)
149
- node[key] = next === '' || /^\d+$/.test(next) ? [] : {};
150
- node = node[key];
151
- });
161
+ if (key === '')
162
+ break;
163
+ let child = ownValue(node, key);
164
+ if (child === undefined) {
165
+ child = next === '' || /^\d+$/.test(next) ? [] : {};
166
+ ownField(node, key, child);
167
+ }
168
+ if (child === null || typeof child !== 'object')
169
+ break;
170
+ node = child;
171
+ }
152
172
  }
153
173
  return out;
154
174
  }
@@ -186,24 +206,32 @@ export async function readParams(manifest, request, operation) {
186
206
  const parts = boundary === null ? [] : multipartParts(new Uint8Array(await request.arrayBuffer()), boundary);
187
207
  const out = { ...query };
188
208
  for (const part of parts) {
209
+ // each part a field of its own name, whatever the name (`__proto__` included: ownField)
189
210
  if (part.filename === null) {
190
- out[part.name] = new TextDecoder().decode(part.body);
211
+ ownField(out, part.name, new TextDecoder().decode(part.body));
191
212
  continue;
192
213
  }
193
214
  const bytes = part.body;
194
215
  const parsed = { name: part.filename, type: part.type ?? '', size: bytes.byteLength, content: new TextDecoder().decode(bytes) };
195
- out[part.name] = Object.defineProperty(parsed, 'bytes', { value: bytes, enumerable: false });
216
+ ownField(out, part.name, Object.defineProperty(parsed, 'bytes', { value: bytes, enumerable: false }));
196
217
  }
197
218
  return out;
198
219
  }
220
+ // NDJSON (`application/x-ndjson`) is lines of JSON, not a JSON document: the handler reads its text
221
+ const media = (type.split(';')[0] ?? '').trim().toLowerCase();
222
+ const isJson = media.includes('json') && !media.includes('ndjson');
223
+ // a form is a body labelled one (application/x-www-form-urlencoded); a body of any other type (an image, a video, an
224
+ // octet-stream PUT to an upload URL, a text/plain SQL statement, an NDJSON batch) is no form: its parameters are the
225
+ // query's, and the handler reads its text or bytes itself
226
+ const isForm = media === 'application/x-www-form-urlencoded';
227
+ const asJson = isJson || manifest.body.json === 'always';
228
+ if (!isForm && !asJson)
229
+ return query;
199
230
  const text = await bodyTextOf(request);
200
231
  if (!text)
201
232
  return query;
202
- // NDJSON (`application/x-ndjson`) is lines of JSON, not a JSON document: the handler reads its text
203
- const isJson = type.includes('json') && !type.includes('ndjson');
204
- const asJson = isJson || manifest.body.json === 'always';
205
233
  // `json: 'always'` reads a body labelled otherwise as JSON (GitHub reads curl -d's); a body that is not JSON is read
206
- // as the form it is labelled, never a crash (a sign-in page's own form, posted to the vendor's page)
234
+ // 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
207
235
  let body;
208
236
  if (asJson) {
209
237
  try {
@@ -212,6 +240,8 @@ export async function readParams(manifest, request, operation) {
212
240
  catch {
213
241
  if (isJson)
214
242
  throw new SyntaxError('malformed JSON body');
243
+ if (!isForm)
244
+ return query;
215
245
  body = parseBracketForm(text, scalars);
216
246
  }
217
247
  }
@@ -598,6 +628,8 @@ function recordedInput(m, call, merged) {
598
628
  return { door: id, ...asSent };
599
629
  return { operationId: id, ...asSent };
600
630
  }
631
+ /** The scope each call is served in (serveCore, contextFor), for the contexts a write opens for its hooks. */
632
+ const scopeOfCall = new WeakMap();
601
633
  async function writeDetailed(m, call, resource, id, fields, operation, params, root, occurredAt) {
602
634
  const withFiles = async (input) => { const files = input.operationId ? await keptFiles(m, call, root) : undefined; return files ? { ...input, files } : input; };
603
635
  const { resource: row, result } = await applyTwinWrite(m.service,
@@ -609,7 +641,9 @@ async function writeDetailed(m, call, resource, id, fields, operation, params, r
609
641
  : { operation, subjectType: storedType(m, resource), subjectId: id, fields, input: await withFiles(recordedInput(m, call, params)), occurredAt, actor: { kind: 'agent', ...(m.identity ? { id: m.identity(call.request.headers.get('authorization'), root) } : {}) } }, root);
610
642
  await keepCounts(m, call, resource, row, params, root, occurredAt);
611
643
  const body = view(m, resource, row);
612
- const context = () => contextFor(m, { ...call, request: call.request.clone() }, { ...(root !== undefined ? { root } : {}), clock: () => occurredAt });
644
+ // the write's own context for its hooks, in the scope the call was served in (a walk's application answers, a tenant,
645
+ // the managed database), at the write's moment
646
+ const context = () => contextFor(m, { ...call, request: call.request.clone() }, { ...(scopeOfCall.get(call) ?? {}), ...(root !== undefined ? { root } : {}), clock: () => occurredAt });
613
647
  if (m.onWrite)
614
648
  await m.onWrite({ operation, storedType: storedType(m, resource), body, ...(root !== undefined ? { root } : {}), occurredAt, request: call.request, context });
615
649
  if (m.events) {
@@ -940,6 +974,7 @@ export async function serveCore(m, call, scope = {}) {
940
974
  if (!resource || !decl)
941
975
  return { unmodeled: `no manifest entry for resource ${resource ?? '(none)'}` };
942
976
  call = adoptedPath(m, call, root);
977
+ scopeOfCall.set(call, scope);
943
978
  // the body is read from a copy, so the call's own request is still unread for the write hook's context
944
979
  const params = await boundaryParams(m, { ...call, request: call.request.clone() }, root);
945
980
  const idParam = subjectOf(m, resource, call);
@@ -1094,6 +1129,7 @@ export async function contextFor(m, call, scope) {
1094
1129
  const root = scope.root;
1095
1130
  const asAsked = call;
1096
1131
  call = adoptedPath(m, call, root);
1132
+ scopeOfCall.set(call, scope);
1097
1133
  const at = (scope.clock ?? worldNow)();
1098
1134
  // the guard's parse when it made one (taken once), else this context's own
1099
1135
  const parsed = takeParsedBody(call.request);
@@ -1134,7 +1170,7 @@ export async function contextFor(m, call, scope) {
1134
1170
  publicBase: twinPublicBase(call.request),
1135
1171
  scenario: scenarioDecisionOf(call.request),
1136
1172
  deliver: (url, init) => deliverToApplication(url, init),
1137
- ask: (url, init, within) => askApplication(url, init, within),
1173
+ ask: (url, init, within) => askApplication(url, init, within, scope.application),
1138
1174
  worldEnv: (name) => worldEnvValue(name),
1139
1175
  asVendor: (fn) => runAsVendorMove(fn),
1140
1176
  git: (name) => {
@@ -117,6 +117,9 @@ export type DerivedFetch = ((request: Request) => Promise<Response>) & {
117
117
  boards?: boolean;
118
118
  /** Whether a built screen of it takes a URL: a board frame it draws. */
119
119
  draws?(url: string): boolean;
120
+ /** The hosts its built content screens name: a vendor's lane router sends `<base>/@<host>/<path>` for one of them to
121
+ * this fetch (pack-fetch.ts). */
122
+ contentHosts?: ReadonlyArray<string>;
120
123
  };
121
124
  /** A workspace screen a pack draws, as `GET /twin` lists it: its id and path (Viewing a World). */
122
125
  export type Workspace = {
@@ -198,9 +198,20 @@ export type ApplicationAnswer = {
198
198
  body: string;
199
199
  missed?: 'timeout' | 'unreachable';
200
200
  };
201
+ /** Who answers what the vendor asks the application (`ctx.ask`) before the World's application route does, given to a
202
+ * pack's fetch (`PackFetchOptions.application`) and belonging to that fetch alone: an answer it gives is the
203
+ * application's; `'unreachable'` is a question nobody answers (a walk's life, a sealed World, takes no other); undefined
204
+ * is a question it does not take, which goes on as any other does: the application route, the World's egress rule, the
205
+ * network. A walk's stand-in answers every question (its life's answers, else unreachable); a hosted World's router
206
+ * answers only what it can answer truthfully (WorldDoors.askInWorld) and leaves the rest. */
207
+ export type ApplicationStandIn = (url: string, init: RequestInit) => Promise<{
208
+ status: number;
209
+ body: string;
210
+ } | 'unreachable' | undefined>;
201
211
  /** A message the vendor sends the application's server and decides by its answer (Stripe's real-time authorization
202
- * request, answered within its window): over the same transport, waited on for at most `within` milliseconds. */
203
- export declare function askApplication(url: string, init: RequestInit, within: number): Promise<ApplicationAnswer>;
212
+ * request, answered within its window): over the same transport, waited on for at most `within` milliseconds, the
213
+ * fetch's stand-in first when it was given one (its window the same). */
214
+ export declare function askApplication(url: string, init: RequestInit, within: number, standIn?: ApplicationStandIn): Promise<ApplicationAnswer>;
204
215
  /** Every event a write sends, rendered, stored when the vendor keeps its events, signed, delivered to each endpoint
205
216
  * that takes it, and recorded. */
206
217
  export declare function deliverEvents(service: string, decl: EventsDecl, write: EventWrite, data: (type: string) => Promise<Record<string, unknown>>, transport?: EventTransport, values?: (type: string) => Promise<Record<string, unknown>>, onAnswer?: (answer: DeliveryAnswer) => Promise<void>): Promise<EventDelivery[]>;
@@ -223,8 +223,20 @@ export async function deliverToApplication(url, init) {
223
223
  }
224
224
  }
225
225
  /** A message the vendor sends the application's server and decides by its answer (Stripe's real-time authorization
226
- * request, answered within its window): over the same transport, waited on for at most `within` milliseconds. */
227
- export async function askApplication(url, init, within) {
226
+ * request, answered within its window): over the same transport, waited on for at most `within` milliseconds, the
227
+ * fetch's stand-in first when it was given one (its window the same). */
228
+ export async function askApplication(url, init, within, standIn) {
229
+ if (standIn) {
230
+ let timer;
231
+ const late = new Promise((done) => { timer = setTimeout(() => done('late'), within); });
232
+ const stood = await Promise.race([standIn(url, init), late]).finally(() => clearTimeout(timer));
233
+ if (stood === 'late')
234
+ return { status: 0, body: '', missed: 'timeout' };
235
+ if (stood === 'unreachable')
236
+ return { status: 0, body: '', missed: 'unreachable' };
237
+ if (stood !== undefined)
238
+ return stood;
239
+ }
228
240
  const [{ appDestination, appFetch }, { worldEgressRefusal }] = await Promise.all([
229
241
  import('../app-route.cjs'),
230
242
  import('../network-policy.cjs'),
@@ -2,8 +2,9 @@
2
2
  export declare function contentTypeOf(path: string): string;
3
3
  export declare function fileResponse(path: string, init?: ResponseInit): Response;
4
4
  /**
5
- * Stored bytes as a response, honouring a single `Range: bytes=` as RFC 9110 §14 has it: a range that does not parse, a
6
- * first-byte-pos past its last-byte-pos (`bytes=5-2`) included, is ignored and the whole body is a 200; a well-formed
7
- * range that starts past the end is a 416. `nosniff` stops a browser second-guessing the content type the vendor gave.
5
+ * Stored bytes as a response, honouring a single `Range: bytes=` as RFC 9110 §14 has it: every satisfiable range is a
6
+ * 206 with its Content-Range, `bytes=0-` (the whole body) included; a range that does not parse, a first-byte-pos past
7
+ * its last-byte-pos (`bytes=5-2`) included, is ignored and the whole body is a 200; a well-formed range that starts past
8
+ * the end (any range of an empty body) is a 416. `nosniff` stops a browser second-guessing the content type the vendor gave.
8
9
  */
9
10
  export declare function bytesResponse(request: Request, bytes: Uint8Array, contentType: string, cacheControl?: string): Response;
@@ -21,9 +21,10 @@ export function fileResponse(path, init) {
21
21
  return new Response(readFileSync(path), { ...init, headers });
22
22
  }
23
23
  /**
24
- * Stored bytes as a response, honouring a single `Range: bytes=` as RFC 9110 §14 has it: a range that does not parse, a
25
- * first-byte-pos past its last-byte-pos (`bytes=5-2`) included, is ignored and the whole body is a 200; a well-formed
26
- * range that starts past the end is a 416. `nosniff` stops a browser second-guessing the content type the vendor gave.
24
+ * Stored bytes as a response, honouring a single `Range: bytes=` as RFC 9110 §14 has it: every satisfiable range is a
25
+ * 206 with its Content-Range, `bytes=0-` (the whole body) included; a range that does not parse, a first-byte-pos past
26
+ * its last-byte-pos (`bytes=5-2`) included, is ignored and the whole body is a 200; a well-formed range that starts past
27
+ * the end (any range of an empty body) is a 416. `nosniff` stops a browser second-guessing the content type the vendor gave.
27
28
  */
28
29
  export function bytesResponse(request, bytes, contentType, cacheControl = 'public, max-age=604800, immutable') {
29
30
  const total = bytes.length;
@@ -43,7 +44,9 @@ export function bytesResponse(request, bytes, contentType, cacheControl = 'publi
43
44
  if (start >= total || (range[1] === '' && Number(range[2]) === 0))
44
45
  return new Response(null, { status: 416, headers: { ...base, 'content-range': `bytes */${total}` } });
45
46
  }
46
- const partial = start !== 0 || end !== total - 1;
47
+ // every satisfiable range is answered as one, the whole body's `bytes=0-` included: a media player (Safari's) reads a
48
+ // 206 with its Content-Range as the server taking ranges, and a 200 as one it cannot seek in
49
+ const partial = valid;
47
50
  const body = bytes.slice(start, end + 1);
48
51
  return new Response(request.method === 'HEAD' ? null : new Blob([body]), {
49
52
  status: partial ? 206 : 200,
@@ -36,7 +36,7 @@ export { VendorUnreachableError } from './vendor-call.js';
36
36
  export { isReadOnlyRequest, READ_ONLY_REQUEST_HEADER } from './request-scope.js';
37
37
  export { serveHttp, serveStream, type HttpHandler, type HttpServer, type ServeHttpOptions } from './serve-http.js';
38
38
  export type { TwinStream, TwinStreamConnection, TwinStreamSink } from './twin-fetch.js';
39
- export { twinSiteUrl } from './twin-fetch.js';
39
+ export { siteLabel, siteUrlOf, twinSiteUrl } from './twin-fetch.js';
40
40
  export { statefulTwinManifest, twinManifest } from './scenario.js';
41
41
  export { assetContentType, packAsset } from './pack-assets.js';
42
42
  export { bytesResponse, contentTypeOf, fileResponse } from './file-response.js';
package/dist/src/index.js CHANGED
@@ -29,7 +29,7 @@ export { isReadOnlyRequest, READ_ONLY_REQUEST_HEADER } from "./request-scope.js"
29
29
  // ── the serve factory ──────────────────────────────────────────────────────────────────────────
30
30
  export { serveHttp, serveStream } from "./serve-http.js";
31
31
  // where a page links a hostname its twin answers, inside the World that shows it
32
- export { twinSiteUrl } from "./twin-fetch.js";
32
+ export { siteLabel, siteUrlOf, twinSiteUrl } from "./twin-fetch.js";
33
33
  export { statefulTwinManifest, twinManifest } from "./scenario.js";
34
34
  export { assetContentType, packAsset } from "./pack-assets.js";
35
35
  export { bytesResponse, contentTypeOf, fileResponse } from "./file-response.js";
@@ -1,6 +1,7 @@
1
1
  import { type DerivedManifest, type Handler, type HandlerContext, type VendorManifest } from './derived-core.js';
2
2
  import { type DerivedFetch, type DerivedOwner, type DerivedSurface, type Workspace, type BoardFrame } from './derived.js';
3
3
  import { type PackScenarioAdapter } from './scenario.js';
4
+ import type { ApplicationStandIn } from './events.js';
4
5
  import { type SocketEngine } from './sockets.js';
5
6
  import { type GraphqlPart } from './graphql-wire.js';
6
7
  /** What a pack declares and writes, and nothing it assembles itself. */
@@ -56,6 +57,8 @@ export type LanePart = {
56
57
  serves?: (method: string, path: string) => boolean;
57
58
  boards?: boolean;
58
59
  draws?: (url: string) => boolean;
60
+ /** the hosts the lane's built content screens name: its vendor's router sends `<base>/@<host>/` there */
61
+ contentHosts?: ReadonlyArray<string>;
59
62
  };
60
63
  manifest: {
61
64
  doors?: ReadonlyArray<{
@@ -73,6 +76,8 @@ export type PackFetchOptions = {
73
76
  clock?: () => string;
74
77
  database?: string;
75
78
  scenarioPath?: string;
79
+ /** the application a walk stands in for (a life's `application` answers): this fetch's `ctx.ask` is answered by it */
80
+ application?: ApplicationStandIn;
76
81
  };
77
82
  /** A pack's HTTP surface, as the kernel serves it from the pack's parts. */
78
83
  export declare function createPackFetch(parts: PackParts, options?: PackFetchOptions): DerivedFetch;
@@ -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.js";
9
- import { TWIN_PREFIX_HEADER } from "./twin-fetch.js";
9
+ import { placedCookies, TWIN_PREFIX_HEADER } from "./twin-fetch.js";
10
10
  import { PREFIX_PARAMS_HEADER } from "./derived.js";
11
11
  import { withHeaders } from "./with-headers.js";
12
12
  import { authRefusal, bindSemantics, contextFor, LANE_HEADER, SCENARIO_DECISION_HEADER, coreFor, crossCutting, derivedRequestScopes, vendorError } from "./derived-core.js";
@@ -17,6 +17,9 @@ import { getActiveWorldStore } from "./world-store.js";
17
17
  import { runWithCorrelationId } from "./actions.js";
18
18
  import { deferPerform, performRequestGroup, RefusedWriteError, VendorWriteError } from "./head.js";
19
19
  import { withCors } from "./cors.js";
20
+ import { contentAddressOf } from "./content-route.js";
21
+ import { getPack } from "./packRegistry.js";
22
+ import { hostRules } from '../vendor-hosts.cjs';
20
23
  import { socketUpgrade } from "./sockets.js";
21
24
  import { MACHINE_POOL, POOL_KINDS } from "./machines.js";
22
25
  import { applyTwinWrite } from "./serve.js";
@@ -112,6 +115,7 @@ export function createPackFetch(parts, options = {}) {
112
115
  ...(options.clock ? { clock: options.clock } : {}),
113
116
  ...(options.database !== undefined ? { database: options.database, readOnly: options.readOnly ?? false } : {}),
114
117
  ...(parts.tenant ? { tenant: parts.tenant } : {}),
118
+ ...(options.application ? { application: options.application } : {}),
115
119
  };
116
120
  // a command table's surface (Redis's, a vendor that carries Redis over HTTP) names no HTTP operation: its wire is the
117
121
  // pack's front (its `around`), and the table is the denominator the grade counts
@@ -267,6 +271,78 @@ export function createPackFetch(parts, options = {}) {
267
271
  };
268
272
  const rootServes = (method, path) => Boolean(matchOperation(routes, method, path));
269
273
  const lane = m.lanes ? laneRouter(m.vendor, m.lanes, parts.lanes ?? {}, rootServes) : undefined;
274
+ // at a World's place, a page's asset or upload on another of the pack's content hosts is `<base>/@<host>/<path>`
275
+ // (architecture, "A page at a World's place"): answered by the built content screen that names that host exactly,
276
+ // routed from it, and by nothing else; a request naming no such host is general routing's
277
+ const contentScreens = screens.filter((s) => s.decl.kind === 'content');
278
+ for (const s of contentScreens)
279
+ if (s.decl.yieldsToApi)
280
+ throw new Error(`${m.vendor}: the content screen ${s.decl.id} declares yieldsToApi; a content screen answers its host's requests alone (architecture, "Screens")`);
281
+ const contentHosts = [...new Set(contentScreens.flatMap((s) => [s.decl.host, ...(s.decl.hosts ?? [])]).filter((h) => !/[{<]/.test(h)).map((h) => h.toLowerCase()))];
282
+ const contentNotFound = (host, path) => {
283
+ const nf = m.notFound;
284
+ 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 } : {}) });
285
+ };
286
+ /** A request at a World's place for `/@<host>/<path>`, `<host>` one a built content screen of this unit names exactly:
287
+ * 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
288
+ * the descriptor's host rules (`rules`: this unit's, or its vendor's for a lane), read as the injector reads them.
289
+ * Undefined for any other request. */
290
+ // the descriptor's host rules: this unit's, or, for a lane (which declares none), its vendor's as registered
291
+ const descriptorHosts = () => m.descriptor?.hosts ?? getPack(m.vendor)?.hosts;
292
+ const atContentHost = async (request, rules = descriptorHosts()) => {
293
+ if (!request.headers.has(TWIN_PREFIX_HEADER))
294
+ return undefined;
295
+ const url = new URL(request.url);
296
+ const address = contentAddressOf(url.pathname);
297
+ if (!address || !contentHosts.includes(address.host))
298
+ return undefined;
299
+ const { host, path: inner } = address;
300
+ if (address.refused)
301
+ return contentNotFound(host, inner);
302
+ const declared = hostRules(rules);
303
+ if (declared.names(host) && !declared.takes(host, inner))
304
+ return contentNotFound(host, inner);
305
+ const onHost = contentScreens.filter((s) => [s.decl.host, ...(s.decl.hosts ?? [])].some((h) => h.toLowerCase() === host));
306
+ const method = request.method.toUpperCase();
307
+ const allowed = (s) => method === 'GET' || method === 'HEAD' || (method === 'POST' && (s.decl.takes ?? []).includes('POST'));
308
+ // the request as it reaches the content host, through the pack's own routing of a host (its path prefix and labels)
309
+ url.pathname = inner;
310
+ const headers = new Headers(request.headers);
311
+ headers.set('x-volter-twin-original-host', host);
312
+ const sent = method === 'POST' && request.body ? { body: request.body, duplex: 'half' } : {};
313
+ const there = routed(new Request(url, { method, headers, signal: request.signal, ...sent }));
314
+ const path = new URL(there.url).pathname.replace(/\/+$/, '') || '/';
315
+ const hit = onHost.map((s) => ({ s, hit: s.path(path) })).find((x) => x.hit && allowed(x.s));
316
+ if (!hit)
317
+ return contentNotFound(host, inner);
318
+ const asked = options.readOnly && !isReadOnlyRequest(there) ? withHeaders(there, new Headers([...there.headers, [READ_ONLY_REQUEST_HEADER, '1']])) : there;
319
+ if (method === 'POST' && ((options.readOnly ?? false) || isReadOnlyRequest(asked)))
320
+ return refusedReadOnly();
321
+ if (parts.clock)
322
+ await runAsVendorMove(async () => parts.clock(await open(new Request(asked.url, m.tenant ? { headers: asked.headers } : {}), 'clock', 'POST', '/_twin/clock', {})));
323
+ return hit.s.answer(await open(asked, hit.s.decl.id, method, hit.s.decl.path, { ...(hit.s.host(host)?.params ?? {}), ...hit.hit.params }));
324
+ };
325
+ /** A request as the kernel reads it: the headers only the kernel names taken off, its body decoded (undefined when it
326
+ * does not decode as its Content-Encoding says). */
327
+ const prepared = async (arrived) => {
328
+ if (arrived.headers.has(SCENARIO_DECISION_HEADER) || arrived.headers.has(PREFIX_PARAMS_HEADER)) {
329
+ const headers = new Headers(arrived.headers);
330
+ headers.delete(SCENARIO_DECISION_HEADER);
331
+ headers.delete(PREFIX_PARAMS_HEADER);
332
+ arrived = new Request(arrived, { headers });
333
+ }
334
+ return decoded(m, arrived);
335
+ };
336
+ const undecodable = (arrived) => vendorError(m, m.encodings?.undecodable ?? { status: 400, message: `the body does not decode as its Content-Encoding (${arrived.headers.get('content-encoding')}) says` });
337
+ /** The content route at the request's arrival: the answer, or undefined when this unit's content screens name no such
338
+ * host. */
339
+ const answerContent = async (arrived) => {
340
+ const address = arrived.headers.has(TWIN_PREFIX_HEADER) ? contentAddressOf(new URL(arrived.url).pathname) : undefined;
341
+ if (!address || !contentHosts.includes(address.host))
342
+ return undefined;
343
+ const readable = await prepared(arrived);
344
+ return readable ? atContentHost(readable) : undecodable(arrived);
345
+ };
270
346
  const fetch = async (arrived) => {
271
347
  // the discovery door is the pack's own, whichever lane would take its path
272
348
  const asked = new URL(arrived.url).pathname.replace(/\/+$/, '') || '/';
@@ -280,19 +356,18 @@ export function createPackFetch(parts, options = {}) {
280
356
  const frames = await parts.board(await open(request, 'board', 'GET', '/twin/board', {}));
281
357
  return Response.json({ frames: frames.map((f) => ({ ...f, drawn: drawsUrl(f.url) })) });
282
358
  }
359
+ // `<base>/@<host>/<path>` at a World's place, for a host a built content screen of this pack names, is that screen's
360
+ // alone (only the kernel names a scenario's decision or a path prefix's parameters: `prepared` takes them off); for
361
+ // one a lane's names, the lane router sends it to that lane's own fetch, wrapped as any request to it is
362
+ const own = await answerContent(arrived);
363
+ if (own)
364
+ return own;
283
365
  const toLane = discovery ? undefined : lane?.(arrived);
284
366
  if (toLane)
285
367
  return toLane(arrived);
286
- // only the kernel names a scenario's decision, or a path prefix's parameters
287
- if (arrived.headers.has(SCENARIO_DECISION_HEADER) || arrived.headers.has(PREFIX_PARAMS_HEADER)) {
288
- const headers = new Headers(arrived.headers);
289
- headers.delete(SCENARIO_DECISION_HEADER);
290
- headers.delete(PREFIX_PARAMS_HEADER);
291
- arrived = new Request(arrived, { headers });
292
- }
293
- const readable = await decoded(m, arrived);
368
+ const readable = await prepared(arrived);
294
369
  if (!readable)
295
- return vendorError(m, m.encodings?.undecodable ?? { status: 400, message: `the body does not decode as its Content-Encoding (${arrived.headers.get('content-encoding')}) says` });
370
+ return undecodable(arrived);
296
371
  const incoming = routed(readable);
297
372
  // a twin started read-only marks every request so, so every context it opens knows (isReadOnlyRequest)
298
373
  const request = options.readOnly && !isReadOnlyRequest(incoming) ? withHeaders(incoming, new Headers([...incoming.headers, [READ_ONLY_REQUEST_HEADER, '1']])) : incoming;
@@ -355,8 +430,10 @@ export function createPackFetch(parts, options = {}) {
355
430
  // whichever host the twin is reached (its links carry the twin's own base), never a root screen off its own host
356
431
  const host = hostOf(request);
357
432
  const exact = (s) => [s.decl.host, ...(s.decl.hosts ?? [])].some((h) => !/[{<]/.test(h) && h.toLowerCase() === host);
358
- const hits = screens.map((s) => ({ s, hit: s.path(path), onHost: s.host(host) })).filter((x) => x.hit);
359
433
  const apiPath = Boolean(matchOperation(routes, request.method, url.pathname, url.searchParams, request.headers));
434
+ // a screen that yields to the API draws only what no operation names, wherever it would answer (architecture,
435
+ // "Screens": Hacker News's /login page, whose form posts to POST /login)
436
+ const hits = screens.map((s) => ({ s, hit: s.path(path), onHost: s.host(host) })).filter((x) => x.hit && !(apiPath && x.s.decl.yieldsToApi));
360
437
  // reached at a World's place for the twin (`x-forwarded-prefix`, served-world's wire), the host is the World's: a
361
438
  // workspace is shown there by its path alone, its root too (the console frames it so)
362
439
  const placed = request.headers.has(TWIN_PREFIX_HEADER);
@@ -421,7 +498,9 @@ export function createPackFetch(parts, options = {}) {
421
498
  headers.set('content-type', m.jsonContentType);
422
499
  return new Response(res.body, { status: res.status, statusText: res.statusText, headers });
423
500
  };
424
- const served = m.cors ? withCors(m.cors, labelled) : labelled;
501
+ const cors = m.cors ? withCors(m.cors, labelled) : labelled;
502
+ // at a World's place every cookie the twin sets is kept to the twin's base path (twin-fetch.ts, placedCookies)
503
+ const served = async (request) => placedCookies(request, await cors(request));
425
504
  // which paths this pack's API serves, for a vendor's lane router that asks (unlessOnlyRoot)
426
505
  const serves = (method, path) => Boolean(matchOperation(routes, method, path));
427
506
  // the vendor's sockets: a request to a declared socket's path opens a session of its engine, over a context of the
@@ -434,7 +513,7 @@ export function createPackFetch(parts, options = {}) {
434
513
  async (asked) => engineOf()?.serve(asked)) : undefined;
435
514
  // what serves each operation: the pack's own, and each lane's
436
515
  const owners = () => ({ ...api.owners(), ...lanesOwners(parts.lanes ?? {}) });
437
- return derivedRequestScopes(m, Object.assign(served, { owners, serves, workspaces, boards: Boolean(parts.board), draws: drawsUrl, ...(upgrade ? { upgrade } : {}) }));
516
+ return derivedRequestScopes(m, Object.assign(served, { owners, serves, workspaces, boards: Boolean(parts.board), draws: drawsUrl, contentHosts, ...(upgrade ? { upgrade } : {}) }));
438
517
  }
439
518
  /** The vendor's gateway over its lanes: the lane a request goes to (a door to the lane that declares it, then the first
440
519
  * route whose conditions all hold, then the default), behind the gateway's CORS; undefined when none takes it. */
@@ -470,6 +549,13 @@ function laneRouter(vendor, decl, lanes, rootServes = () => false) {
470
549
  });
471
550
  const fallback = decl.default !== undefined ? served(decl.default) : undefined;
472
551
  return (request) => {
552
+ // `<base>/@<host>/<path>` at a World's place, for a host a lane's built content screen names, goes to that lane, before
553
+ // any lane is picked by the request's host or path: its own fetch answers it by its content route, under the vendor's
554
+ // host rules (architecture, "A page at a World's place")
555
+ const address = request.headers.has(TWIN_PREFIX_HEADER) ? contentAddressOf(new URL(request.url).pathname) : undefined;
556
+ const content = address && Object.keys(lanes).find((l) => lanes[l].fetch.contentHosts?.includes(address.host));
557
+ if (content)
558
+ return served(content);
473
559
  const path = new URL(request.url).pathname.replace(/\/{2,}/g, '/');
474
560
  if (path.startsWith('/_twin/')) {
475
561
  const door = doors.find((d) => d.method === request.method && d.matches(path));
@@ -4,7 +4,7 @@ export { parseScenarioDocument, scenarioFaultResult, ScenarioEngine, ScenarioErr
4
4
  export { WORLD_CLOCK_ENV, worldNow } from './world-clock.js';
5
5
  export { WORLD_ENV_NAMES_ENV, worldEnvValue } from './world-env.js';
6
6
  export { ORIGINAL_PATH_HEADER } from './sigv4.js';
7
- export { createTwinFetchFromHandler, TWIN_PREFIX_HEADER, TWIN_REQUEST_SCOPES, TWIN_SITES_HEADER, twinPublicBase, twinSiteUrl, withRequestScopes } from './twin-fetch.js';
7
+ export { createTwinFetchFromHandler, TWIN_PREFIX_HEADER, TWIN_REQUEST_SCOPES, TWIN_SITES_HEADER, siteLabel, siteUrlOf, twinPublicBase, twinSiteUrl, withRequestScopes } from './twin-fetch.js';
8
8
  export { isReadOnlyRequest, READ_ONLY_REQUEST_HEADER, ReadOnlyRequestError, runAsReadOnlyRequest, runAsVendorMove, writesRefused } from './request-scope.js';
9
9
  export { compileSurface, createDerivedFetch, matchOperation } from './derived.js';
10
10
  export { currentTraceparent, deliveryTraceHeaders, newTraceparent, parseTraceparent, runWithRequestTrace, runWithTraceparent, TRACEPARENT_HEADER, traceparentForDelivery, validTraceparent } from './trace-context.js';
@@ -12,7 +12,7 @@ export { parseScenarioDocument, scenarioFaultResult, ScenarioEngine, ScenarioErr
12
12
  export { WORLD_CLOCK_ENV, worldNow } from "./world-clock.js";
13
13
  export { WORLD_ENV_NAMES_ENV, worldEnvValue } from "./world-env.js";
14
14
  export { ORIGINAL_PATH_HEADER } from "./sigv4.js";
15
- export { createTwinFetchFromHandler, TWIN_PREFIX_HEADER, TWIN_REQUEST_SCOPES, TWIN_SITES_HEADER, twinPublicBase, twinSiteUrl, withRequestScopes } from "./twin-fetch.js";
15
+ export { createTwinFetchFromHandler, TWIN_PREFIX_HEADER, TWIN_REQUEST_SCOPES, TWIN_SITES_HEADER, siteLabel, siteUrlOf, twinPublicBase, twinSiteUrl, withRequestScopes } from "./twin-fetch.js";
16
16
  export { isReadOnlyRequest, READ_ONLY_REQUEST_HEADER, ReadOnlyRequestError, runAsReadOnlyRequest, runAsVendorMove, writesRefused } from "./request-scope.js";
17
17
  export { compileSurface, createDerivedFetch, matchOperation } from "./derived.js";
18
18
  export { currentTraceparent, deliveryTraceHeaders, newTraceparent, parseTraceparent, runWithRequestTrace, runWithTraceparent, TRACEPARENT_HEADER, traceparentForDelivery, validTraceparent } from "./trace-context.js";