@volter/world-core 3.0.163 → 3.0.164

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.
@@ -71,7 +71,7 @@ function hostMatcher(pattern) {
71
71
  };
72
72
  }
73
73
  /** The host a request is for: the vendor host the World's injector names, else its own. */
74
- const hostOf = (request) => readHostPort(request.headers.get('x-volter-twin-original-host') ?? request.headers.get('host') ?? new URL(request.url).host)?.host ?? '';
74
+ export const hostOf = (request) => readHostPort(request.headers.get('x-volter-twin-original-host') ?? request.headers.get('host') ?? new URL(request.url).host)?.host ?? '';
75
75
  /** Where a workspace asked `?show=<type>:<id>` opens the record (architecture, "Workspace"): the built workspace page
76
76
  * that shows that type, its parameter the id; else the one that lists the type; else the workspace's home (its root
77
77
  * page). Every other parameter a page's path names is the asking page's, and a page that cannot be filled is passed
@@ -152,12 +152,26 @@ async function encoded(m, request, res) {
152
152
  const buffered = new ReadableStream({ start(controller) { controller.enqueue(body); controller.close(); } });
153
153
  return new Response(buffered, { status: res.status, statusText: res.statusText, headers });
154
154
  }
155
- /** A pack's HTTP surface, as the kernel serves it from the pack's parts. */
156
155
  /** A door's path as it is served and listed: under `/_twin/`, whether the manifest writes the prefix or not. */
157
156
  const doorPath = (path) => (path.startsWith('/_twin/') ? path : `/_twin${path.startsWith('/') ? '' : '/'}${path}`);
157
+ /** A declared World door, matched by the same normalized path for serving and response ownership. */
158
+ export function worldDoorMatcher(doors) {
159
+ const routes = doors.map((door) => ({ door, method: door.method.toUpperCase(), matches: pathMatcher(doorPath(door.path), false) }));
160
+ return (method, path) => {
161
+ const at = path.replace(/\/+$/, '') || '/';
162
+ for (const route of routes) {
163
+ if (route.method !== method.toUpperCase())
164
+ continue;
165
+ const hit = route.matches(at);
166
+ if (hit)
167
+ return { door: route.door, params: hit.params };
168
+ }
169
+ return undefined;
170
+ };
171
+ }
158
172
  /** A request with its path's repeated slashes merged, as a front that merges them routes it (the manifest's
159
173
  * `mergeSlashes`); one with none is itself. */
160
- function slashesMerged(request) {
174
+ export function slashesMerged(request) {
161
175
  const url = new URL(request.url);
162
176
  const merged = url.pathname.replace(/\/{2,}/g, '/');
163
177
  if (merged === url.pathname)
@@ -165,6 +179,63 @@ function slashesMerged(request) {
165
179
  url.pathname = merged;
166
180
  return new Request(url, request);
167
181
  }
182
+ /** The selected unit's request transformation, shared by serving and response judgment. */
183
+ export function unitRequestRouter(m, surface, routes) {
184
+ const prefix = m.pathPrefix ? new RegExp(`^(?:${m.pathPrefix})(?=/)`) : undefined;
185
+ const base = surface.basePath ?? '';
186
+ const baseAlias = m.basePathAlias && base ? new RegExp(`^(?:${m.basePathAlias})(?=/)`) : undefined;
187
+ const hostPath = m.pathFromHost ? new RegExp(m.pathFromHost, 'i') : undefined;
188
+ const hostParams = m.hostParams ? new RegExp(m.hostParams, 'i') : undefined;
189
+ // each operation's spanning parameters, keyed by the operation and the parameter as the route compiles them (a whole
190
+ // segment's label whose name the surface or the manifest declares spanning), so one operation's spanning name never
191
+ // keeps another's slashes, nor a label that only part of a segment holds
192
+ const spanningNames = new Set(surface.spanning ?? []);
193
+ const spanningOf = new Set(surface.operations.flatMap((op) => ((op.basePath ?? base) + op.path).split('/').flatMap((seg) => {
194
+ const label = /^\{([^}]+)\}$/.exec(seg)?.[1];
195
+ return label !== undefined && spanningNames.has(label) ? [`${op.id}\u0000${label}`] : [];
196
+ })));
197
+ /** A path as the vendor routes it: the manifest's path prefix taken off, the spec's base in an alias's place, a host's
198
+ * path label (pathFromHost) put in front. */
199
+ const rewritten = (pathname, host) => {
200
+ let path = pathname;
201
+ if (prefix)
202
+ path = path.replace(prefix, '') || '/';
203
+ // a base the vendor serves as its own: the spec's base in its place (a path already under the spec's base stays)
204
+ if (baseAlias && !(path === base || path.startsWith(`${base}/`)))
205
+ path = path.replace(baseAlias, base);
206
+ const label = hostPath?.exec(host)?.[1];
207
+ if (label)
208
+ path = `/${label}${path === '/' ? '' : path}`;
209
+ return path;
210
+ };
211
+ /** The request as its vendor routes it: duplicate slashes collapsed, unless the rewritten path matches an operation
212
+ * through one of that operation's spanning parameters (the surface's and the manifest's), whose value keeps its
213
+ * slashes; then rewritten. */
214
+ return (request) => {
215
+ const url = new URL(request.url);
216
+ const host = hostOf(request);
217
+ const kept = rewritten(url.pathname, host);
218
+ const matched = matchOperation(routes, request.method, kept);
219
+ const spanning = matched !== undefined && Object.keys(matched.params).some((name) => spanningOf.has(`${matched.operation.id}\u0000${name}`));
220
+ const source = spanning ? url.pathname : url.pathname.replace(/\/{2,}/g, '/');
221
+ // a prefix's named groups (Jira's `/ex/jira/(?<cloudId>[^/]+)`) and the host's (E2B's envd at
222
+ // `49983-(?<sandboxID>…).e2b.app`) join the operation's parameters
223
+ const groups = { ...(prefix?.exec(source)?.groups ?? {}), ...(hostParams?.exec(host)?.groups ?? {}), ...Object.fromEntries(Object.entries(m.headerParams ?? {}).flatMap(([param, header]) => { const value = request.headers.get(header); return value === null ? [] : [[param, value]]; })) };
224
+ const path = spanning ? kept : rewritten(source, host);
225
+ if (path === url.pathname && !Object.keys(groups).length)
226
+ return request;
227
+ // the path the client sent, which a signature over the request (SigV4's canonical URI) covers
228
+ const headers = new Headers(request.headers);
229
+ // the path as first sent: a front before this (an attach bridge) may have named the one its client signed
230
+ if (!headers.has(ORIGINAL_PATH_HEADER))
231
+ headers.set(ORIGINAL_PATH_HEADER, url.pathname);
232
+ if (Object.keys(groups).length)
233
+ headers.set(PREFIX_PARAMS_HEADER, JSON.stringify(groups));
234
+ url.pathname = path;
235
+ return new Request(url, { method: request.method, headers, body: request.body, signal: request.signal, ...(request.body ? { duplex: 'half' } : {}) });
236
+ };
237
+ }
238
+ /** A pack's HTTP surface, as the kernel serves it from the pack's parts. */
168
239
  export function createPackFetch(parts, options = {}) {
169
240
  const m = parts.manifest;
170
241
  const scope = {
@@ -267,12 +338,10 @@ export function createPackFetch(parts, options = {}) {
267
338
  return Response.json(decision.result.body, { status: decision.result.status, headers: decision.result.headers ?? {} });
268
339
  return api(withHeaders(request, new Headers([...request.headers, [SCENARIO_DECISION_HEADER, scenarioDecisionHeader(decision)]])));
269
340
  };
270
- const doors = (m.doors ?? []).map((d) => {
271
- const answer = parts.doors?.[d.id];
272
- if (!answer)
341
+ for (const d of m.doors ?? [])
342
+ if (!parts.doors?.[d.id])
273
343
  throw new Error(`${m.vendor}: the manifest declares the door ${d.id}, and semantics/doors.ts exports no ${d.id}`);
274
- return { decl: d, path: pathMatcher(doorPath(d.path), false), answer };
275
- });
344
+ const door = worldDoorMatcher(m.doors ?? []);
276
345
  const screens = (m.screens ?? []).filter((s) => s.status === 'done' && parts.screens?.[s.id]).map((s) => {
277
346
  const hosts = [s.host, ...(s.hosts ?? [])].map(hostMatcher);
278
347
  return { decl: s, host: (h) => hosts.map((match) => match(h)).find(Boolean), path: pathMatcher(s.path, true), answer: parts.screens[s.id] };
@@ -304,59 +373,7 @@ export function createPackFetch(parts, options = {}) {
304
373
  await runAsVendorMove(async () => parts.clock(await open(new Request(request.url, m.tenant ? { headers: request.headers } : {}), 'clock', 'POST', '/_twin/clock', {})));
305
374
  };
306
375
  const refusedReadOnly = () => vendorError(m, m.readOnly);
307
- const prefix = m.pathPrefix ? new RegExp(`^(?:${m.pathPrefix})(?=/)`) : undefined;
308
- const base = surface.basePath ?? '';
309
- const baseAlias = m.basePathAlias && base ? new RegExp(`^(?:${m.basePathAlias})(?=/)`) : undefined;
310
- const hostPath = m.pathFromHost ? new RegExp(m.pathFromHost, 'i') : undefined;
311
- const hostParams = m.hostParams ? new RegExp(m.hostParams, 'i') : undefined;
312
- // each operation's spanning parameters, keyed by the operation and the parameter as the route compiles them (a whole
313
- // segment's label whose name the surface or the manifest declares spanning), so one operation's spanning name never
314
- // keeps another's slashes, nor a label that only part of a segment holds
315
- const spanningNames = new Set(surface.spanning ?? []);
316
- const spanningOf = new Set(surface.operations.flatMap((op) => ((op.basePath ?? base) + op.path).split('/').flatMap((seg) => {
317
- const label = /^\{([^}]+)\}$/.exec(seg)?.[1];
318
- return label !== undefined && spanningNames.has(label) ? [`${op.id}\u0000${label}`] : [];
319
- })));
320
- /** A path as the vendor routes it: the manifest's path prefix taken off, the spec's base in an alias's place, a host's
321
- * path label (pathFromHost) put in front. */
322
- const rewritten = (pathname, host) => {
323
- let path = pathname;
324
- if (prefix)
325
- path = path.replace(prefix, '') || '/';
326
- // a base the vendor serves as its own: the spec's base in its place (a path already under the spec's base stays)
327
- if (baseAlias && !(path === base || path.startsWith(`${base}/`)))
328
- path = path.replace(baseAlias, base);
329
- const label = hostPath?.exec(host)?.[1];
330
- if (label)
331
- path = `/${label}${path === '/' ? '' : path}`;
332
- return path;
333
- };
334
- /** The request as its vendor routes it: duplicate slashes collapsed, unless the rewritten path matches an operation
335
- * through one of that operation's spanning parameters (the surface's and the manifest's), whose value keeps its
336
- * slashes; then rewritten. */
337
- const routed = (request) => {
338
- const url = new URL(request.url);
339
- const host = hostOf(request);
340
- const kept = rewritten(url.pathname, host);
341
- const matched = matchOperation(routes, request.method, kept);
342
- const spanning = matched !== undefined && Object.keys(matched.params).some((name) => spanningOf.has(`${matched.operation.id}\u0000${name}`));
343
- const source = spanning ? url.pathname : url.pathname.replace(/\/{2,}/g, '/');
344
- // a prefix's named groups (Jira's `/ex/jira/(?<cloudId>[^/]+)`) and the host's (E2B's envd at
345
- // `49983-(?<sandboxID>…).e2b.app`) join the operation's parameters
346
- const groups = { ...(prefix?.exec(source)?.groups ?? {}), ...(hostParams?.exec(host)?.groups ?? {}), ...Object.fromEntries(Object.entries(m.headerParams ?? {}).flatMap(([param, header]) => { const value = request.headers.get(header); return value === null ? [] : [[param, value]]; })) };
347
- const path = spanning ? kept : rewritten(source, host);
348
- if (path === url.pathname && !Object.keys(groups).length)
349
- return request;
350
- // the path the client sent, which a signature over the request (SigV4's canonical URI) covers
351
- const headers = new Headers(request.headers);
352
- // the path as first sent: a front before this (an attach bridge) may have named the one its client signed
353
- if (!headers.has(ORIGINAL_PATH_HEADER))
354
- headers.set(ORIGINAL_PATH_HEADER, url.pathname);
355
- if (Object.keys(groups).length)
356
- headers.set(PREFIX_PARAMS_HEADER, JSON.stringify(groups));
357
- url.pathname = path;
358
- return new Request(url, { method: request.method, headers, body: request.body, signal: request.signal, ...(request.body ? { duplex: 'half' } : {}) });
359
- };
376
+ const routed = unitRequestRouter(m, surface, routes);
360
377
  const rootServes = (method, path) => Boolean(matchOperation(routes, method, path));
361
378
  const lane = m.lanes ? laneRouter(m.vendor, m.lanes, parts.lanes ?? {}, rootServes) : undefined;
362
379
  // Inspect lane-owned World doors directly, without routing them back through the vendor gateway.
@@ -455,7 +472,7 @@ export function createPackFetch(parts, options = {}) {
455
472
  if (own)
456
473
  return own;
457
474
  // Root World doors belong to the pack at every routed host, including a lane's host.
458
- const rootDoor = doors.some((d) => d.decl.method.toUpperCase() === arrived.method && d.path(asked));
475
+ const rootDoor = door(arrived.method, asked);
459
476
  const toLane = discovery || rootDoor ? undefined : lane?.(arrived);
460
477
  if (toLane)
461
478
  return toLane(arrived);
@@ -531,15 +548,12 @@ export function createPackFetch(parts, options = {}) {
531
548
  resetFakePool(m.service, options.root);
532
549
  return Response.json({ kind }, { status: 201 });
533
550
  }
534
- for (const d of doors) {
535
- if (d.decl.method.toUpperCase() !== request.method)
536
- continue;
537
- const hit = d.path(path);
538
- if (!hit)
539
- continue;
551
+ const hit = door(request.method, path);
552
+ if (hit) {
540
553
  if (readOnly && request.method !== 'GET')
541
554
  return refusedReadOnly();
542
- return d.answer(await open(request, d.decl.id, d.decl.method, d.decl.path, hit.params));
555
+ const d = hit.door;
556
+ return parts.doors[d.id](await open(request, d.id, d.method, d.path, hit.params));
543
557
  }
544
558
  return gapAnswer(m, request);
545
559
  }
@@ -720,31 +734,16 @@ export function createPackFetch(parts, options = {}) {
720
734
  const owners = () => flatOwners(ownersByUnit());
721
735
  return derivedRequestScopes(m, Object.assign(served, { owners, ownersByUnit, serves, advanceClock: async (request) => runWithStorageTenant(m.service, await selectedAccount(request), () => advanceClock(request)), workspaces, boards: Boolean(parts.board), maxRequestBodySize: m.descriptor?.maxRequestBodySize, draws: drawsUrl, contentHosts, ...(upgrade ? { upgrade } : {}), ...(ingest ? { ingest } : {}) }), options.readOnly);
722
736
  }
723
- /** The vendor's gateway over its lanes: the lane a request goes to (a door to the lane that declares it, then the first
724
- * route whose conditions all hold, then the default), behind the gateway's CORS; undefined when none takes it. */
725
- function laneRouter(vendor, decl, lanes, rootServes = () => false) {
726
- const served = (name) => {
727
- const part = lanes[name];
728
- if (!part)
729
- throw new Error(`${vendor}: the manifest routes to the lane ${name}, and the vendor's fetch is given no ${name}`);
730
- // the lane is named on the request, so a write it makes records which lane's API made it (LANE_HEADER)
731
- const named = (request) => {
732
- const headers = new Headers(request.headers);
733
- headers.set(LANE_HEADER, name);
734
- return part.fetch(withHeaders(request, headers));
735
- };
736
- return decl.cors ? withCors(decl.cors, named) : named;
737
- };
738
- const doors = Object.entries(lanes).flatMap(([name, part]) => (part.manifest.doors ?? []).map((d) => ({
739
- name, method: d.method.toUpperCase(), matches: pathMatcher(d.path.startsWith('/_twin/') ? d.path : `/_twin/${d.path.replace(/^\//, '')}`, false),
740
- })));
737
+ /** The lane selected by the vendor gateway, shared by serving and response judgment. */
738
+ export function laneSelector(decl, lanes, rootServes = () => false) {
739
+ const door = worldDoorMatcher(Object.entries(lanes).flatMap(([name, part]) => (part.manifest.doors ?? []).map((d) => ({ ...d, name }))));
741
740
  const laneRoutes = decl.routes.map((r) => {
742
741
  const at = r.path?.replace(/\/+$/, '');
743
742
  const host = r.host !== undefined ? new RegExp(r.host, 'i') : undefined;
744
743
  const except = (r.exceptHosts ?? []).map((h) => h.toLowerCase());
745
744
  const own = lanes[r.lane]?.fetch.serves;
746
745
  return {
747
- lane: served(r.lane),
746
+ lane: r.lane,
748
747
  holds: (request, path, name) => (at === undefined || path === at || path.startsWith(`${at}/`))
749
748
  && (host === undefined || host.test(name))
750
749
  && (r.header === undefined || (request.headers.get(r.header.name) ?? '').startsWith(r.header.prefix) !== (r.header.absent === true))
@@ -752,7 +751,6 @@ function laneRouter(vendor, decl, lanes, rootServes = () => false) {
752
751
  && !(r.unlessOnlyRoot && rootServes(request.method, path) && !(own?.(request.method, path) ?? false)),
753
752
  };
754
753
  });
755
- const fallback = decl.default !== undefined ? served(decl.default) : undefined;
756
754
  return (request) => {
757
755
  // `<base>/@<host>/<path>` at a World's place, for a host a lane's built content screen names, goes to that lane, before
758
756
  // any lane is picked by the request's host or path: its own fetch answers it by its content route, under the vendor's
@@ -760,15 +758,37 @@ function laneRouter(vendor, decl, lanes, rootServes = () => false) {
760
758
  const address = request.headers.has(TWIN_PREFIX_HEADER) ? contentAddressOf(new URL(request.url).pathname) : undefined;
761
759
  const content = address && Object.keys(lanes).find((l) => lanes[l].fetch.contentHosts?.includes(address.host));
762
760
  if (content)
763
- return served(content);
761
+ return content;
764
762
  const path = new URL(request.url).pathname.replace(/\/{2,}/g, '/');
765
763
  if (path.startsWith('/_twin/')) {
766
- const door = doors.find((d) => d.method === request.method && d.matches(path));
767
- if (door)
768
- return served(door.name);
764
+ const hit = door(request.method, path);
765
+ if (hit)
766
+ return hit.door.name;
769
767
  }
770
768
  const name = hostOf(request);
771
- return laneRoutes.find((r) => r.holds(request, path, name))?.lane ?? fallback;
769
+ return laneRoutes.find((r) => r.holds(request, path, name))?.lane ?? decl.default;
770
+ };
771
+ }
772
+ /** The vendor's gateway over its lanes: the lane a request goes to (a door to the lane that declares it, then the first
773
+ * route whose conditions all hold, then the default), behind the gateway's CORS; undefined when none takes it. */
774
+ function laneRouter(vendor, decl, lanes, rootServes = () => false) {
775
+ const served = (name) => {
776
+ const part = lanes[name];
777
+ if (!part)
778
+ throw new Error(`${vendor}: the manifest routes to the lane ${name}, and the vendor's fetch is given no ${name}`);
779
+ // the lane is named on the request, so a write it makes records which lane's API made it (LANE_HEADER)
780
+ const named = (request) => {
781
+ const headers = new Headers(request.headers);
782
+ headers.set(LANE_HEADER, name);
783
+ return part.fetch(withHeaders(request, headers));
784
+ };
785
+ return decl.cors ? withCors(decl.cors, named) : named;
786
+ };
787
+ const selected = laneSelector(decl, lanes, rootServes);
788
+ const handlers = new Map([...new Set([...decl.routes.map((route) => route.lane), ...(decl.default === undefined ? [] : [decl.default]), ...Object.keys(lanes)])].map((name) => [name, served(name)]));
789
+ return (request) => {
790
+ const name = selected(request);
791
+ return name === undefined ? undefined : handlers.get(name);
772
792
  };
773
793
  }
774
794
  /** A vendor whose every API is a lane (Cloudflare's API v4 and R2, AWS's services), served from its manifest: the
@@ -1,8 +1,7 @@
1
- /** Compressed-request output only: Node's plain requests remain uncapped; Bun retains its own plain-body limit.
2
- * The default is Bun's existing largest plain body (128 MiB), so both runtimes decode the same.
1
+ /** The shared HTTP transport and compressed-request output retain Bun's 128 MiB plain-body default.
3
2
  * Source: bun-types/serve.d.ts, ServeOptions.maxRequestBodySize (@default 1024 * 1024 * 128),
4
3
  * https://github.com/oven-sh/bun/blob/main/packages/bun-types/serve.d.ts .
5
- * A pack's descriptor.maxRequestBodySize overrides decoded output as it does Bun's plain-body limit. */
4
+ * A pack's descriptor.maxRequestBodySize overrides both the transport and decoded-output limits. */
6
5
  export declare const DEFAULT_MAX_DECODED_REQUEST_BODY_SIZE: number;
7
6
  export declare class RequestBodyTooLargeError extends Error {
8
7
  constructor();
@@ -1,8 +1,7 @@
1
- /** Compressed-request output only: Node's plain requests remain uncapped; Bun retains its own plain-body limit.
2
- * The default is Bun's existing largest plain body (128 MiB), so both runtimes decode the same.
1
+ /** The shared HTTP transport and compressed-request output retain Bun's 128 MiB plain-body default.
3
2
  * Source: bun-types/serve.d.ts, ServeOptions.maxRequestBodySize (@default 1024 * 1024 * 128),
4
3
  * https://github.com/oven-sh/bun/blob/main/packages/bun-types/serve.d.ts .
5
- * A pack's descriptor.maxRequestBodySize overrides decoded output as it does Bun's plain-body limit. */
4
+ * A pack's descriptor.maxRequestBodySize overrides both the transport and decoded-output limits. */
6
5
  export const DEFAULT_MAX_DECODED_REQUEST_BODY_SIZE = 128 * 1024 * 1024;
7
6
  export class RequestBodyTooLargeError extends Error {
8
7
  constructor() { super('Payload Too Large'); this.name = 'RequestBodyTooLargeError'; }
@@ -7,11 +7,12 @@ export { ORIGINAL_PATH_HEADER } from './sigv4.js';
7
7
  export { createTwinFetchFromHandler, placedCookies, TWIN_PREFIX_HEADER, TWIN_REQUEST_SCOPES, TWIN_SITES_HEADER, TWIN_SITE_HEADER, siteLabel, siteUrlOf, twinPublicBase, twinSiteUrl, twinVendorUrl, 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, observeDerivedDispatch } from './derived.js';
10
+ export { hostOf, laneSelector, slashesMerged, unitRequestRouter, worldDoorMatcher } from './pack-fetch.js';
10
11
  export { currentTraceparent, deliveryTraceHeaders, newTraceparent, parseTraceparent, runWithRequestTrace, runWithTraceparent, TRACEPARENT_HEADER, traceparentForDelivery, validTraceparent } from './trace-context.js';
11
12
  export type { Traceparent } from './trace-context.js';
12
13
  export type { DerivedCall, DerivedCoreOutcome, DerivedDispatch, DerivedFetch, DerivedFetchOptions, DerivedHandler, DerivedOperation, DerivedOwner, DerivedSurface, BoardFrame } from './derived.js';
13
14
  export { authRefusal, bindSemantics, coreFor, crossCutting, flagOf, derivedRequestScopes, observeTransitions, resourcesOfType as twinResourcesOfType, semanticsContext, stateOf, transitionFor, parseBracketForm, readParams, render as renderDerived, serveCore, sse, vendorError } from './derived-core.js';
14
- export { deliverEvents, fillEnvelope, setDeliveryObserver, signEvent, signEventAll, verifyEvent } from './events.js';
15
+ export { deliverEvents, fillEnvelope, setDeliveryObserver, settleEventDeliveries, signEvent, signEventAll, verifyEvent } from './events.js';
15
16
  export { equalSecrets, hmac, jwks, jwtDecode, jwtSign, jwtSignClaims, jwtVerify, jwtVerifySignature, keyId, lettersFrom, md5, sha256, SIGNING_ALGORITHMS, uuidFrom } from './signing.js';
16
17
  export type { HandlerCrypto, Jwk, Jwks, JwtDecodeOptions, JwtSignatureOptions, JwtVerdict, JwtVerificationKey, SigningKey } from './signing.js';
17
18
  export type { EventDelivery, EventRender, EventScheme, EventsDecl, EventTransport, EventWrite } from './events.js';
@@ -15,9 +15,10 @@ export { ORIGINAL_PATH_HEADER } from "./sigv4.js";
15
15
  export { createTwinFetchFromHandler, placedCookies, TWIN_PREFIX_HEADER, TWIN_REQUEST_SCOPES, TWIN_SITES_HEADER, TWIN_SITE_HEADER, siteLabel, siteUrlOf, twinPublicBase, twinSiteUrl, twinVendorUrl, 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, observeDerivedDispatch } from "./derived.js";
18
+ export { hostOf, laneSelector, slashesMerged, unitRequestRouter, worldDoorMatcher } from "./pack-fetch.js";
18
19
  export { currentTraceparent, deliveryTraceHeaders, newTraceparent, parseTraceparent, runWithRequestTrace, runWithTraceparent, TRACEPARENT_HEADER, traceparentForDelivery, validTraceparent } from "./trace-context.js";
19
20
  export { authRefusal, bindSemantics, coreFor, crossCutting, flagOf, derivedRequestScopes, observeTransitions, resourcesOfType as twinResourcesOfType, semanticsContext, stateOf, transitionFor, parseBracketForm, readParams, render as renderDerived, serveCore, sse, vendorError } from "./derived-core.js";
20
- export { deliverEvents, fillEnvelope, setDeliveryObserver, signEvent, signEventAll, verifyEvent } from "./events.js";
21
+ export { deliverEvents, fillEnvelope, setDeliveryObserver, settleEventDeliveries, signEvent, signEventAll, verifyEvent } from "./events.js";
21
22
  export { equalSecrets, hmac, jwks, jwtDecode, jwtSign, jwtSignClaims, jwtVerify, jwtVerifySignature, keyId, lettersFrom, md5, sha256, SIGNING_ALGORITHMS, uuidFrom } from "./signing.js";
22
23
  export { emitTwinEvent, eventSubscriptionMatches, listEmittable, runEmitCli } from "./emit.js";
23
24
  // The vendor-agnostic CLIENT-SIDE RATE BUDGET — the fail-closed backstop a pack's guarded client
@@ -29,7 +29,7 @@ export type ServeHttpOptions = {
29
29
  fetch: HttpHandler;
30
30
  /** seconds a connection may idle (Bun honors it; Node's keep-alive timeout is set from it) */
31
31
  idleTimeout?: number;
32
- /** Explicit transport limit in bytes; absent, Bun retains its 128 MiB default. */
32
+ /** Explicit transport limit in bytes; absent, 128 MiB on both runtimes. */
33
33
  maxRequestBodySize?: number;
34
34
  /** what a thrown handler answers with; absent, a 500 carrying the error (as Bun.serve) */
35
35
  error?: (error: Error) => Response | Promise<Response>;
@@ -1,13 +1,14 @@
1
1
  // The HTTP server seam of the kernel (docs/contributing/architecture.md#the-serve-seam): a fetch handler on
2
2
  // a port, in the shape every `Bun.serve` call already has, so the serve path meets the runtime in
3
- // ONE place. Bun-backed where Bun is the runtime; `node:http`-backed otherwise, the request rebuilt
4
- // as a standard Request and the handler's Response written back. Async, because binding a port is
3
+ // ONE place. Ordinary HTTP uses `node:http`; requested WebSockets retain Bun's native backend.
4
+ // The request is rebuilt as a standard Request and the handler's Response written back. Async, because binding a port is
5
5
  // asynchronous on Node — Bun's synchronous bind is the special case.
6
6
  //
7
7
  // Nothing here runs at module scope (the served-handler registry is made on first use), and `node:http` is reached through `process.getBuiltinModule`
8
8
  // rather than a static import: this module rides into browser client bundles through the kernel's
9
9
  // entrypoint, where a static `node:` import is the 2026-09-06 class of break.
10
10
  import { worldBootAnswer } from "./world-boot.js";
11
+ import { DEFAULT_MAX_DECODED_REQUEST_BODY_SIZE, RequestBodyTooLargeError } from "./request-body.js";
11
12
  /** One socket library at the host seam, shared by inbound upgrades and authenticated outbound relays. */
12
13
  const socketLibrary = () => import('ws');
13
14
  export async function connectWebSocket(url, headers, protocols = []) {
@@ -56,7 +57,7 @@ export async function serveHttp(options) {
56
57
  const hostname = options.hostname ?? '127.0.0.1';
57
58
  const urlHost = options.hostname ?? '127.0.0.1';
58
59
  const bun = bunRuntime();
59
- if (bun) {
60
+ if (bun && (options.upgrade !== undefined || 'websocket' in options)) {
60
61
  // the global `Bun.serve` — the request journal's wrap of it (serve.ts) applies to packs and to
61
62
  // this seam alike, so the seam never decorates twice on Bun
62
63
  const upgrade = options.upgrade;
@@ -191,7 +192,8 @@ function builtin(name) {
191
192
  }
192
193
  async function serveOnNode(options) {
193
194
  const http = builtin('node:http');
194
- const { Readable } = builtin('node:stream');
195
+ const { Readable, Transform } = builtin('node:stream');
196
+ const requestLimit = options.maxRequestBodySize ?? DEFAULT_MAX_DECODED_REQUEST_BODY_SIZE;
195
197
  if ('websocket' in options)
196
198
  throw new Error('serveHttp: use the portable upgrade callbacks for WebSocket support on Node');
197
199
  const protocol = options.tls ? 'https' : 'http';
@@ -230,20 +232,35 @@ async function serveOnNode(options) {
230
232
  const activeRequests = new Set();
231
233
  const server = factory((req, res) => {
232
234
  const controller = new AbortController();
233
- active.add(controller);
234
- const abort = () => { if (!controller.signal.aborted)
235
- controller.abort(); };
235
+ const socket = req.socket;
236
+ const hasInputBody = req.headers['transfer-encoding'] !== undefined || Number(req.headers['content-length']) > 0;
237
+ let incoming;
238
+ let flushingBodyRefusal = false;
239
+ const abort = () => {
240
+ if (!controller.signal.aborted)
241
+ controller.abort();
242
+ incoming?.destroy(controller.signal.reason);
243
+ if (!flushingBodyRefusal) {
244
+ req.destroy();
245
+ if (!res.writableFinished)
246
+ res.destroy();
247
+ socket.destroy();
248
+ }
249
+ };
250
+ active.add(abort);
236
251
  const onRequestClose = () => { if (!req.complete)
237
252
  abort(); };
238
- const onResponseClose = () => { if (!res.writableFinished)
239
- abort(); };
253
+ const onResponseClose = () => { if (!res.writableFinished) {
254
+ flushingBodyRefusal = false;
255
+ abort();
256
+ } };
240
257
  const onSocketClose = () => { if (!res.writableFinished)
241
258
  abort(); };
242
259
  req.on('aborted', abort);
243
260
  req.on('close', onRequestClose);
244
261
  req.on('error', abort);
245
- req.socket.on('close', onSocketClose);
246
- req.socket.on('error', abort);
262
+ socket.on('close', onSocketClose);
263
+ socket.on('error', abort);
247
264
  res.on('close', onResponseClose);
248
265
  res.on('error', abort);
249
266
  const disconnected = () => controller.signal.aborted || res.destroyed;
@@ -279,8 +296,38 @@ async function serveOnNode(options) {
279
296
  closed();
280
297
  });
281
298
  let request;
299
+ let bodyTooLarge = false;
300
+ const rejectBody = () => {
301
+ if (bodyTooLarge)
302
+ return;
303
+ bodyTooLarge = true;
304
+ const error = new RequestBodyTooLargeError();
305
+ if (incoming)
306
+ req.unpipe(incoming);
307
+ req.pause();
308
+ if (!res.headersSent && !res.destroyed) {
309
+ for (const name of res.getHeaderNames())
310
+ res.removeHeader(name);
311
+ flushingBodyRefusal = true;
312
+ res.writeHead(413, { 'connection': 'close', 'content-type': 'text/plain', 'content-length': Buffer.byteLength(error.message) });
313
+ res.once('finish', () => { flushingBodyRefusal = false; req.destroy(); });
314
+ res.end(error.message);
315
+ }
316
+ else if (!res.destroyed)
317
+ res.destroy(error);
318
+ abort();
319
+ };
320
+ const inputSettled = new Promise((resolve) => {
321
+ const settled = () => { req.off('end', settled); req.off('close', settled); resolve(); };
322
+ req.once('end', settled);
323
+ req.once('close', settled);
324
+ });
282
325
  const handle = async () => {
283
326
  const method = req.method ?? 'GET';
327
+ if (Number(req.headers['content-length']) > requestLimit) {
328
+ rejectBody();
329
+ return;
330
+ }
284
331
  let response;
285
332
  try {
286
333
  const headers = new Headers();
@@ -295,9 +342,26 @@ async function serveOnNode(options) {
295
342
  }
296
343
  const url = `${protocol}://${req.headers.host ?? `${options.hostname}:${server.address()?.port ?? 0}`}${req.url ?? '/'}`;
297
344
  const withBody = method !== 'GET' && method !== 'HEAD';
298
- const init = { method, headers, signal: controller.signal, ...(withBody ? { body: Readable.toWeb(req), duplex: 'half' } : {}) };
345
+ let received = 0;
346
+ incoming = new Transform({ transform(chunk, _encoding, callback) {
347
+ received += chunk.byteLength;
348
+ if (received > requestLimit) {
349
+ rejectBody();
350
+ callback(new RequestBodyTooLargeError());
351
+ }
352
+ else
353
+ callback(null, chunk);
354
+ } });
355
+ const inputClosed = () => { if (!req.readableEnded)
356
+ abort(); };
357
+ incoming.on('error', inputClosed);
358
+ incoming.once('close', inputClosed);
359
+ const init = { method, headers, signal: controller.signal, ...(withBody ? { body: Readable.toWeb(incoming), duplex: 'half' } : {}) };
299
360
  request = new Request(url, init);
300
361
  activeRequests.add(request);
362
+ req.pipe(incoming);
363
+ if (!withBody)
364
+ incoming.resume();
301
365
  response = await options.fetch(request);
302
366
  }
303
367
  catch (error) {
@@ -365,6 +429,7 @@ async function serveOnNode(options) {
365
429
  if (k !== 'set-cookie')
366
430
  res.setHeader(k, v);
367
431
  }
432
+ res.strictContentLength = response.headers.has('content-length');
368
433
  const cookies = response.headers.getSetCookie?.() ?? [];
369
434
  if (cookies.length)
370
435
  res.setHeader('set-cookie', cookies);
@@ -424,12 +489,23 @@ async function serveOnNode(options) {
424
489
  res.destroy(error instanceof Error ? error : new Error(String(error)));
425
490
  }).then(async () => {
426
491
  await responseSettled;
427
- active.delete(controller);
492
+ // A runtime may retire its native body forwarding when the response ends.
493
+ // Close unfinished input after the response flushes; a consumed upload keeps its connection.
494
+ if (hasInputBody && !incoming?.readableEnded)
495
+ abort();
496
+ else
497
+ incoming?.resume();
498
+ await inputSettled;
499
+ if (incoming) {
500
+ req.unpipe(incoming);
501
+ incoming.destroy();
502
+ }
503
+ active.delete(abort);
428
504
  req.off('aborted', abort);
429
505
  req.off('close', onRequestClose);
430
506
  req.off('error', abort);
431
- req.socket.off('close', onSocketClose);
432
- req.socket.off('error', abort);
507
+ socket.off('close', onSocketClose);
508
+ socket.off('error', abort);
433
509
  res.off('close', onResponseClose);
434
510
  res.off('error', abort);
435
511
  });
@@ -462,8 +538,8 @@ async function serveOnNode(options) {
462
538
  return {
463
539
  hostname: options.hostname, port, url: new URL(`${protocol}://${options.urlHost}:${port}/`),
464
540
  stop: (closeActive = true) => new Promise((resolve) => { if (closeActive) {
465
- for (const controller of active)
466
- controller.abort();
541
+ for (const abort of active)
542
+ abort();
467
543
  for (const peer of socketServer?.clients ?? [])
468
544
  peer.terminate();
469
545
  } socketServer?.close(); server.close(() => { if (closeActive && active.size)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/world-core",
3
- "version": "3.0.163",
3
+ "version": "3.0.164",
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",
@@ -1,19 +1,31 @@
1
1
  // A vendor's browser session: the bookkeeping used by its own sign-in and sign-out pages.
2
2
  import type { HandlerContext } from './derived-core.ts';
3
+ import { personOf, type Person } from './people.ts';
3
4
 
4
5
  type Writer = Pick<HandlerContext, 'record' | 'secret' | 'occurredAt'>;
5
6
 
6
- /** The named cookie, without interpreting any other vendor's cookie. */
7
- export function browserSessionToken(request: Request, name: string): string | undefined {
7
+ /** The value of the cookie `name` the request carries, as a browser sends it back. */
8
+ export function cookieOf(request: Request, name: string): string | undefined {
8
9
  for (const part of (request.headers.get('cookie') ?? '').split(';')) {
9
10
  const at = part.indexOf('=');
10
- if (at > 0 && part.slice(0, at).trim() === name) {
11
- try { return decodeURIComponent(part.slice(at + 1).trim()); } catch { return undefined; }
12
- }
11
+ if (at > 0 && part.slice(0, at).trim() === name) return decodeURIComponent(part.slice(at + 1).trim());
13
12
  }
14
13
  return undefined;
15
14
  }
16
15
 
16
+ /** The named session cookie; malformed encodings do not name a session. */
17
+ export function browserSessionToken(request: Request, name: string): string | undefined {
18
+ try { return cookieOf(request, name); } catch { return undefined; }
19
+ }
20
+
21
+ /** The person the request's session cookie names, or undefined when nobody is signed in; with `scope`, only a session
22
+ * begun in it (a vendor's sandbox dashboard and its production one sign a person in apart). */
23
+ export function signedIn(ctx: HandlerContext, cookie: string, scope?: string): Person | undefined {
24
+ const token = cookieOf(ctx.call.request, cookie);
25
+ const session = token ? ctx.rowsRaw('_web_session').find((s) => s.token === token && (scope === undefined || s.scope === scope)) : undefined;
26
+ return session ? personOf(ctx, String(session.email)) : undefined;
27
+ }
28
+
17
29
  /** A new, independent sign-in, returning the cookie the vendor places on its response. */
18
30
  export async function startBrowserSession(ctx: Writer, email: string, cookie: string, scope?: string): Promise<string> {
19
31
  const issued = await ctx.record('_web_session_issue', { email, ...(scope !== undefined ? { scope } : {}) });