@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.
- package/dist/src/browser-session.d.ts +7 -1
- package/dist/src/browser-session.js +21 -10
- package/dist/src/derived-core.d.ts +1 -1
- package/dist/src/derived-core.js +7 -5
- package/dist/src/derived-real.js +15 -3
- package/dist/src/events.d.ts +6 -3
- package/dist/src/events.js +52 -7
- package/dist/src/file-response.d.ts +6 -2
- package/dist/src/file-response.js +29 -5
- package/dist/src/index.d.ts +2 -1
- package/dist/src/index.js +2 -1
- package/dist/src/managed-database.d.ts +4 -2
- package/dist/src/managed-database.js +1 -0
- package/dist/src/media-header.d.ts +34 -0
- package/dist/src/media-header.js +129 -0
- package/dist/src/pack-fetch.d.ts +24 -3
- package/dist/src/pack-fetch.js +114 -94
- package/dist/src/request-body.d.ts +2 -3
- package/dist/src/request-body.js +2 -3
- package/dist/src/runtime.d.ts +2 -1
- package/dist/src/runtime.js +2 -1
- package/dist/src/serve-http.d.ts +1 -1
- package/dist/src/serve-http.js +93 -17
- package/package.json +1 -1
- package/src/browser-session.ts +17 -5
- package/src/derived-core.ts +9 -7
- package/src/derived-real.ts +10 -2
- package/src/events.ts +54 -9
- package/src/file-response.ts +28 -6
- package/src/index.ts +2 -1
- package/src/managed-database.ts +4 -3
- package/src/media-header.ts +108 -0
- package/src/pack-fetch.ts +107 -85
- package/src/request-body.ts +2 -3
- package/src/runtime.ts +2 -1
- package/src/serve-http.ts +70 -16
package/src/pack-fetch.ts
CHANGED
|
@@ -105,7 +105,7 @@ export type LanePart = {
|
|
|
105
105
|
* World's scenario file for a model vendor's turns (`scenarioPath`, the runtime's `--scenario`). */
|
|
106
106
|
export type PackFetchOptions = {
|
|
107
107
|
root?: string; readOnly?: boolean; clock?: () => string; database?: string; scenarioPath?: string;
|
|
108
|
-
/** the application a walk stands in for
|
|
108
|
+
/** the application a walk stands in for: this fetch's requests and declared events are answered by it */
|
|
109
109
|
application?: ApplicationStandIn;
|
|
110
110
|
};
|
|
111
111
|
|
|
@@ -143,7 +143,7 @@ function hostMatcher(pattern: string): (host: string) => Match | undefined {
|
|
|
143
143
|
};
|
|
144
144
|
}
|
|
145
145
|
/** The host a request is for: the vendor host the World's injector names, else its own. */
|
|
146
|
-
const hostOf = (request: Request): string => readHostPort(request.headers.get('x-volter-twin-original-host') ?? request.headers.get('host') ?? new URL(request.url).host)?.host ?? '';
|
|
146
|
+
export const hostOf = (request: Request): string => readHostPort(request.headers.get('x-volter-twin-original-host') ?? request.headers.get('host') ?? new URL(request.url).host)?.host ?? '';
|
|
147
147
|
|
|
148
148
|
/** Where a workspace asked `?show=<type>:<id>` opens the record (architecture, "Workspace"): the built workspace page
|
|
149
149
|
* that shows that type, its parameter the id; else the one that lists the type; else the workspace's home (its root
|
|
@@ -218,13 +218,26 @@ async function encoded(m: DerivedManifest, request: Request, res: Response): Pro
|
|
|
218
218
|
return new Response(buffered, { status: res.status, statusText: res.statusText, headers });
|
|
219
219
|
}
|
|
220
220
|
|
|
221
|
-
/** A pack's HTTP surface, as the kernel serves it from the pack's parts. */
|
|
222
221
|
/** A door's path as it is served and listed: under `/_twin/`, whether the manifest writes the prefix or not. */
|
|
223
222
|
const doorPath = (path: string): string => (path.startsWith('/_twin/') ? path : `/_twin${path.startsWith('/') ? '' : '/'}${path}`);
|
|
224
223
|
|
|
224
|
+
/** A declared World door, matched by the same normalized path for serving and response ownership. */
|
|
225
|
+
export function worldDoorMatcher<Door extends { method: string; path: string }>(doors: ReadonlyArray<Door>): (method: string, path: string) => { door: Door; params: Record<string, string> } | undefined {
|
|
226
|
+
const routes = doors.map((door) => ({ door, method: door.method.toUpperCase(), matches: pathMatcher(doorPath(door.path), false) }));
|
|
227
|
+
return (method, path) => {
|
|
228
|
+
const at = path.replace(/\/+$/, '') || '/';
|
|
229
|
+
for (const route of routes) {
|
|
230
|
+
if (route.method !== method.toUpperCase()) continue;
|
|
231
|
+
const hit = route.matches(at);
|
|
232
|
+
if (hit) return { door: route.door, params: hit.params };
|
|
233
|
+
}
|
|
234
|
+
return undefined;
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
225
238
|
/** A request with its path's repeated slashes merged, as a front that merges them routes it (the manifest's
|
|
226
239
|
* `mergeSlashes`); one with none is itself. */
|
|
227
|
-
function slashesMerged(request: Request): Request {
|
|
240
|
+
export function slashesMerged(request: Request): Request {
|
|
228
241
|
const url = new URL(request.url);
|
|
229
242
|
const merged = url.pathname.replace(/\/{2,}/g, '/');
|
|
230
243
|
if (merged === url.pathname) return request;
|
|
@@ -232,6 +245,58 @@ function slashesMerged(request: Request): Request {
|
|
|
232
245
|
return new Request(url, request);
|
|
233
246
|
}
|
|
234
247
|
|
|
248
|
+
/** The selected unit's request transformation, shared by serving and response judgment. */
|
|
249
|
+
export function unitRequestRouter(m: Pick<DerivedManifest, 'pathPrefix' | 'basePathAlias' | 'pathFromHost' | 'hostParams' | 'headerParams'>, surface: DerivedSurface, routes: ReturnType<typeof compileSurface>): (request: Request) => Request {
|
|
250
|
+
const prefix = m.pathPrefix ? new RegExp(`^(?:${m.pathPrefix})(?=/)`) : undefined;
|
|
251
|
+
const base = surface.basePath ?? '';
|
|
252
|
+
const baseAlias = m.basePathAlias && base ? new RegExp(`^(?:${m.basePathAlias})(?=/)`) : undefined;
|
|
253
|
+
const hostPath = m.pathFromHost ? new RegExp(m.pathFromHost, 'i') : undefined;
|
|
254
|
+
const hostParams = m.hostParams ? new RegExp(m.hostParams, 'i') : undefined;
|
|
255
|
+
// each operation's spanning parameters, keyed by the operation and the parameter as the route compiles them (a whole
|
|
256
|
+
// segment's label whose name the surface or the manifest declares spanning), so one operation's spanning name never
|
|
257
|
+
// keeps another's slashes, nor a label that only part of a segment holds
|
|
258
|
+
const spanningNames = new Set(surface.spanning ?? []);
|
|
259
|
+
const spanningOf = new Set(surface.operations.flatMap((op) => ((op.basePath ?? base) + op.path).split('/').flatMap((seg) => {
|
|
260
|
+
const label = /^\{([^}]+)\}$/.exec(seg)?.[1];
|
|
261
|
+
return label !== undefined && spanningNames.has(label) ? [`${op.id}\u0000${label}`] : [];
|
|
262
|
+
})));
|
|
263
|
+
/** 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
|
|
264
|
+
* path label (pathFromHost) put in front. */
|
|
265
|
+
const rewritten = (pathname: string, host: string): string => {
|
|
266
|
+
let path = pathname;
|
|
267
|
+
if (prefix) path = path.replace(prefix, '') || '/';
|
|
268
|
+
// a base the vendor serves as its own: the spec's base in its place (a path already under the spec's base stays)
|
|
269
|
+
if (baseAlias && !(path === base || path.startsWith(`${base}/`))) path = path.replace(baseAlias, base);
|
|
270
|
+
const label = hostPath?.exec(host)?.[1];
|
|
271
|
+
if (label) path = `/${label}${path === '/' ? '' : path}`;
|
|
272
|
+
return path;
|
|
273
|
+
};
|
|
274
|
+
/** The request as its vendor routes it: duplicate slashes collapsed, unless the rewritten path matches an operation
|
|
275
|
+
* through one of that operation's spanning parameters (the surface's and the manifest's), whose value keeps its
|
|
276
|
+
* slashes; then rewritten. */
|
|
277
|
+
return (request: Request): Request => {
|
|
278
|
+
const url = new URL(request.url);
|
|
279
|
+
const host = hostOf(request);
|
|
280
|
+
const kept = rewritten(url.pathname, host);
|
|
281
|
+
const matched = matchOperation(routes, request.method, kept);
|
|
282
|
+
const spanning = matched !== undefined && Object.keys(matched.params).some((name) => spanningOf.has(`${matched.operation.id}\u0000${name}`));
|
|
283
|
+
const source = spanning ? url.pathname : url.pathname.replace(/\/{2,}/g, '/');
|
|
284
|
+
// a prefix's named groups (Jira's `/ex/jira/(?<cloudId>[^/]+)`) and the host's (E2B's envd at
|
|
285
|
+
// `49983-(?<sandboxID>…).e2b.app`) join the operation's parameters
|
|
286
|
+
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]]; })) };
|
|
287
|
+
const path = spanning ? kept : rewritten(source, host);
|
|
288
|
+
if (path === url.pathname && !Object.keys(groups).length) return request;
|
|
289
|
+
// the path the client sent, which a signature over the request (SigV4's canonical URI) covers
|
|
290
|
+
const headers = new Headers(request.headers);
|
|
291
|
+
// the path as first sent: a front before this (an attach bridge) may have named the one its client signed
|
|
292
|
+
if (!headers.has(ORIGINAL_PATH_HEADER)) headers.set(ORIGINAL_PATH_HEADER, url.pathname);
|
|
293
|
+
if (Object.keys(groups).length) headers.set(PREFIX_PARAMS_HEADER, JSON.stringify(groups));
|
|
294
|
+
url.pathname = path;
|
|
295
|
+
return new Request(url, { method: request.method, headers, body: request.body, signal: request.signal, ...(request.body ? { duplex: 'half' } : {}) } as RequestInit);
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** A pack's HTTP surface, as the kernel serves it from the pack's parts. */
|
|
235
300
|
export function createPackFetch(parts: PackParts, options: PackFetchOptions = {}): DerivedFetch {
|
|
236
301
|
const m = parts.manifest;
|
|
237
302
|
const scope: CoreScope = {
|
|
@@ -322,11 +387,8 @@ export function createPackFetch(parts: PackParts, options: PackFetchOptions = {}
|
|
|
322
387
|
if (decision.kind === 'fault') return Response.json(decision.result.body, { status: decision.result.status, headers: decision.result.headers ?? {} });
|
|
323
388
|
return api(withHeaders(request, new Headers([...request.headers, [SCENARIO_DECISION_HEADER, scenarioDecisionHeader(decision)]])));
|
|
324
389
|
};
|
|
325
|
-
const
|
|
326
|
-
|
|
327
|
-
if (!answer) throw new Error(`${m.vendor}: the manifest declares the door ${d.id}, and semantics/doors.ts exports no ${d.id}`);
|
|
328
|
-
return { decl: d, path: pathMatcher(doorPath(d.path), false), answer };
|
|
329
|
-
});
|
|
390
|
+
for (const d of m.doors ?? []) if (!parts.doors?.[d.id]) throw new Error(`${m.vendor}: the manifest declares the door ${d.id}, and semantics/doors.ts exports no ${d.id}`);
|
|
391
|
+
const door = worldDoorMatcher(m.doors ?? []);
|
|
330
392
|
const screens = (m.screens ?? []).filter((s) => s.status === 'done' && parts.screens?.[s.id]).map((s) => {
|
|
331
393
|
const hosts = [s.host, ...(s.hosts ?? [])].map(hostMatcher);
|
|
332
394
|
return { decl: s, host: (h: string) => hosts.map((match) => match(h)).find(Boolean), path: pathMatcher(s.path, true), answer: parts.screens![s.id]! };
|
|
@@ -355,53 +417,7 @@ export function createPackFetch(parts: PackParts, options: PackFetchOptions = {}
|
|
|
355
417
|
};
|
|
356
418
|
const refusedReadOnly = (): Response => vendorError(m, m.readOnly);
|
|
357
419
|
|
|
358
|
-
const
|
|
359
|
-
const base = surface.basePath ?? '';
|
|
360
|
-
const baseAlias = m.basePathAlias && base ? new RegExp(`^(?:${m.basePathAlias})(?=/)`) : undefined;
|
|
361
|
-
const hostPath = m.pathFromHost ? new RegExp(m.pathFromHost, 'i') : undefined;
|
|
362
|
-
const hostParams = m.hostParams ? new RegExp(m.hostParams, 'i') : undefined;
|
|
363
|
-
// each operation's spanning parameters, keyed by the operation and the parameter as the route compiles them (a whole
|
|
364
|
-
// segment's label whose name the surface or the manifest declares spanning), so one operation's spanning name never
|
|
365
|
-
// keeps another's slashes, nor a label that only part of a segment holds
|
|
366
|
-
const spanningNames = new Set(surface.spanning ?? []);
|
|
367
|
-
const spanningOf = new Set(surface.operations.flatMap((op) => ((op.basePath ?? base) + op.path).split('/').flatMap((seg) => {
|
|
368
|
-
const label = /^\{([^}]+)\}$/.exec(seg)?.[1];
|
|
369
|
-
return label !== undefined && spanningNames.has(label) ? [`${op.id}\u0000${label}`] : [];
|
|
370
|
-
})));
|
|
371
|
-
/** 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
|
|
372
|
-
* path label (pathFromHost) put in front. */
|
|
373
|
-
const rewritten = (pathname: string, host: string): string => {
|
|
374
|
-
let path = pathname;
|
|
375
|
-
if (prefix) path = path.replace(prefix, '') || '/';
|
|
376
|
-
// a base the vendor serves as its own: the spec's base in its place (a path already under the spec's base stays)
|
|
377
|
-
if (baseAlias && !(path === base || path.startsWith(`${base}/`))) path = path.replace(baseAlias, base);
|
|
378
|
-
const label = hostPath?.exec(host)?.[1];
|
|
379
|
-
if (label) path = `/${label}${path === '/' ? '' : path}`;
|
|
380
|
-
return path;
|
|
381
|
-
};
|
|
382
|
-
/** The request as its vendor routes it: duplicate slashes collapsed, unless the rewritten path matches an operation
|
|
383
|
-
* through one of that operation's spanning parameters (the surface's and the manifest's), whose value keeps its
|
|
384
|
-
* slashes; then rewritten. */
|
|
385
|
-
const routed = (request: Request): Request => {
|
|
386
|
-
const url = new URL(request.url);
|
|
387
|
-
const host = hostOf(request);
|
|
388
|
-
const kept = rewritten(url.pathname, host);
|
|
389
|
-
const matched = matchOperation(routes, request.method, kept);
|
|
390
|
-
const spanning = matched !== undefined && Object.keys(matched.params).some((name) => spanningOf.has(`${matched.operation.id}\u0000${name}`));
|
|
391
|
-
const source = spanning ? url.pathname : url.pathname.replace(/\/{2,}/g, '/');
|
|
392
|
-
// a prefix's named groups (Jira's `/ex/jira/(?<cloudId>[^/]+)`) and the host's (E2B's envd at
|
|
393
|
-
// `49983-(?<sandboxID>…).e2b.app`) join the operation's parameters
|
|
394
|
-
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]]; })) };
|
|
395
|
-
const path = spanning ? kept : rewritten(source, host);
|
|
396
|
-
if (path === url.pathname && !Object.keys(groups).length) return request;
|
|
397
|
-
// the path the client sent, which a signature over the request (SigV4's canonical URI) covers
|
|
398
|
-
const headers = new Headers(request.headers);
|
|
399
|
-
// the path as first sent: a front before this (an attach bridge) may have named the one its client signed
|
|
400
|
-
if (!headers.has(ORIGINAL_PATH_HEADER)) headers.set(ORIGINAL_PATH_HEADER, url.pathname);
|
|
401
|
-
if (Object.keys(groups).length) headers.set(PREFIX_PARAMS_HEADER, JSON.stringify(groups));
|
|
402
|
-
url.pathname = path;
|
|
403
|
-
return new Request(url, { method: request.method, headers, body: request.body, signal: request.signal, ...(request.body ? { duplex: 'half' } : {}) } as RequestInit);
|
|
404
|
-
};
|
|
420
|
+
const routed = unitRequestRouter(m, surface, routes);
|
|
405
421
|
const rootServes = (method: string, path: string): boolean => Boolean(matchOperation(routes, method, path));
|
|
406
422
|
const lane = m.lanes ? laneRouter(m.vendor, m.lanes, parts.lanes ?? {}, rootServes) : undefined;
|
|
407
423
|
// Inspect lane-owned World doors directly, without routing them back through the vendor gateway.
|
|
@@ -488,7 +504,7 @@ export function createPackFetch(parts: PackParts, options: PackFetchOptions = {}
|
|
|
488
504
|
const own = await answerContent(arrived);
|
|
489
505
|
if (own) return own;
|
|
490
506
|
// Root World doors belong to the pack at every routed host, including a lane's host.
|
|
491
|
-
const rootDoor =
|
|
507
|
+
const rootDoor = door(arrived.method, asked);
|
|
492
508
|
const toLane = discovery || rootDoor ? undefined : lane?.(arrived);
|
|
493
509
|
if (toLane) return toLane(arrived);
|
|
494
510
|
const readable = await prepared(arrived);
|
|
@@ -551,12 +567,11 @@ export function createPackFetch(parts: PackParts, options: PackFetchOptions = {}
|
|
|
551
567
|
if (kind === 'fake') resetFakePool(m.service, options.root);
|
|
552
568
|
return Response.json({ kind }, { status: 201 });
|
|
553
569
|
}
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
const hit = d.path(path);
|
|
557
|
-
if (!hit) continue;
|
|
570
|
+
const hit = door(request.method, path);
|
|
571
|
+
if (hit) {
|
|
558
572
|
if (readOnly && request.method !== 'GET') return refusedReadOnly();
|
|
559
|
-
|
|
573
|
+
const d = hit.door;
|
|
574
|
+
return parts.doors![d.id]!(await open(request, d.id, d.method, d.path, hit.params));
|
|
560
575
|
}
|
|
561
576
|
return gapAnswer(m, request);
|
|
562
577
|
}
|
|
@@ -717,30 +732,16 @@ export function createPackFetch(parts: PackParts, options: PackFetchOptions = {}
|
|
|
717
732
|
return derivedRequestScopes(m, Object.assign(served, { owners, ownersByUnit, serves, advanceClock: async (request: 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);
|
|
718
733
|
}
|
|
719
734
|
|
|
720
|
-
/** The
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
const served = (name: string): ((request: Request) => Promise<Response>) => {
|
|
724
|
-
const part = lanes[name];
|
|
725
|
-
if (!part) throw new Error(`${vendor}: the manifest routes to the lane ${name}, and the vendor's fetch is given no ${name}`);
|
|
726
|
-
// the lane is named on the request, so a write it makes records which lane's API made it (LANE_HEADER)
|
|
727
|
-
const named = (request: Request): Promise<Response> => {
|
|
728
|
-
const headers = new Headers(request.headers);
|
|
729
|
-
headers.set(LANE_HEADER, name);
|
|
730
|
-
return part.fetch(withHeaders(request, headers));
|
|
731
|
-
};
|
|
732
|
-
return decl.cors ? withCors(decl.cors, named) : named;
|
|
733
|
-
};
|
|
734
|
-
const doors = Object.entries(lanes).flatMap(([name, part]) => (part.manifest.doors ?? []).map((d) => ({
|
|
735
|
-
name, method: d.method.toUpperCase(), matches: pathMatcher(d.path.startsWith('/_twin/') ? d.path : `/_twin/${d.path.replace(/^\//, '')}`, false),
|
|
736
|
-
})));
|
|
735
|
+
/** The lane selected by the vendor gateway, shared by serving and response judgment. */
|
|
736
|
+
export function laneSelector(decl: LanesDecl, lanes: Record<string, { manifest: LanePart['manifest']; fetch: Pick<LanePart['fetch'], 'serves' | 'contentHosts'> }>, rootServes: (method: string, path: string) => boolean = () => false): (request: Request) => string | undefined {
|
|
737
|
+
const door = worldDoorMatcher(Object.entries(lanes).flatMap(([name, part]) => (part.manifest.doors ?? []).map((d) => ({ ...d, name }))));
|
|
737
738
|
const laneRoutes = decl.routes.map((r) => {
|
|
738
739
|
const at = r.path?.replace(/\/+$/, '');
|
|
739
740
|
const host = r.host !== undefined ? new RegExp(r.host, 'i') : undefined;
|
|
740
741
|
const except = (r.exceptHosts ?? []).map((h) => h.toLowerCase());
|
|
741
742
|
const own = lanes[r.lane]?.fetch.serves;
|
|
742
743
|
return {
|
|
743
|
-
lane:
|
|
744
|
+
lane: r.lane,
|
|
744
745
|
holds: (request: Request, path: string, name: string): boolean =>
|
|
745
746
|
(at === undefined || path === at || path.startsWith(`${at}/`))
|
|
746
747
|
&& (host === undefined || host.test(name))
|
|
@@ -749,21 +750,42 @@ function laneRouter(vendor: string, decl: LanesDecl, lanes: Record<string, LaneP
|
|
|
749
750
|
&& !(r.unlessOnlyRoot && rootServes(request.method, path) && !(own?.(request.method, path) ?? false)),
|
|
750
751
|
};
|
|
751
752
|
});
|
|
752
|
-
const fallback = decl.default !== undefined ? served(decl.default) : undefined;
|
|
753
753
|
return (request) => {
|
|
754
754
|
// `<base>/@<host>/<path>` at a World's place, for a host a lane's built content screen names, goes to that lane, before
|
|
755
755
|
// any lane is picked by the request's host or path: its own fetch answers it by its content route, under the vendor's
|
|
756
756
|
// host rules (architecture, "A page at a World's place")
|
|
757
757
|
const address = request.headers.has(TWIN_PREFIX_HEADER) ? contentAddressOf(new URL(request.url).pathname) : undefined;
|
|
758
758
|
const content = address && Object.keys(lanes).find((l) => lanes[l]!.fetch.contentHosts?.includes(address.host));
|
|
759
|
-
if (content) return
|
|
759
|
+
if (content) return content;
|
|
760
760
|
const path = new URL(request.url).pathname.replace(/\/{2,}/g, '/');
|
|
761
761
|
if (path.startsWith('/_twin/')) {
|
|
762
|
-
const
|
|
763
|
-
if (
|
|
762
|
+
const hit = door(request.method, path);
|
|
763
|
+
if (hit) return hit.door.name;
|
|
764
764
|
}
|
|
765
765
|
const name = hostOf(request);
|
|
766
|
-
return laneRoutes.find((r) => r.holds(request, path, name))?.lane ??
|
|
766
|
+
return laneRoutes.find((r) => r.holds(request, path, name))?.lane ?? decl.default;
|
|
767
|
+
};
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
/** The vendor's gateway over its lanes: the lane a request goes to (a door to the lane that declares it, then the first
|
|
771
|
+
* route whose conditions all hold, then the default), behind the gateway's CORS; undefined when none takes it. */
|
|
772
|
+
function laneRouter(vendor: string, decl: LanesDecl, lanes: Record<string, LanePart>, rootServes: (method: string, path: string) => boolean = () => false): (request: Request) => ((request: Request) => Promise<Response>) | undefined {
|
|
773
|
+
const served = (name: string): ((request: Request) => Promise<Response>) => {
|
|
774
|
+
const part = lanes[name];
|
|
775
|
+
if (!part) throw new Error(`${vendor}: the manifest routes to the lane ${name}, and the vendor's fetch is given no ${name}`);
|
|
776
|
+
// the lane is named on the request, so a write it makes records which lane's API made it (LANE_HEADER)
|
|
777
|
+
const named = (request: Request): Promise<Response> => {
|
|
778
|
+
const headers = new Headers(request.headers);
|
|
779
|
+
headers.set(LANE_HEADER, name);
|
|
780
|
+
return part.fetch(withHeaders(request, headers));
|
|
781
|
+
};
|
|
782
|
+
return decl.cors ? withCors(decl.cors, named) : named;
|
|
783
|
+
};
|
|
784
|
+
const selected = laneSelector(decl, lanes, rootServes);
|
|
785
|
+
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)]));
|
|
786
|
+
return (request) => {
|
|
787
|
+
const name = selected(request);
|
|
788
|
+
return name === undefined ? undefined : handlers.get(name);
|
|
767
789
|
};
|
|
768
790
|
}
|
|
769
791
|
|
package/src/request-body.ts
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
|
-
/**
|
|
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
|
|
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
|
|
|
8
7
|
export class RequestBodyTooLargeError extends Error {
|
package/src/runtime.ts
CHANGED
|
@@ -16,11 +16,12 @@ export { ORIGINAL_PATH_HEADER } from './sigv4.ts';
|
|
|
16
16
|
export { createTwinFetchFromHandler, placedCookies, TWIN_PREFIX_HEADER, TWIN_REQUEST_SCOPES, TWIN_SITES_HEADER, TWIN_SITE_HEADER, siteLabel, siteUrlOf, twinPublicBase, twinSiteUrl, twinVendorUrl, withRequestScopes } from './twin-fetch.ts';
|
|
17
17
|
export { isReadOnlyRequest, READ_ONLY_REQUEST_HEADER, ReadOnlyRequestError, runAsReadOnlyRequest, runAsVendorMove, writesRefused } from './request-scope.ts';
|
|
18
18
|
export { compileSurface, createDerivedFetch, matchOperation, observeDerivedDispatch } from './derived.ts';
|
|
19
|
+
export { hostOf, laneSelector, slashesMerged, unitRequestRouter, worldDoorMatcher } from './pack-fetch.ts';
|
|
19
20
|
export { currentTraceparent, deliveryTraceHeaders, newTraceparent, parseTraceparent, runWithRequestTrace, runWithTraceparent, TRACEPARENT_HEADER, traceparentForDelivery, validTraceparent } from './trace-context.ts';
|
|
20
21
|
export type { Traceparent } from './trace-context.ts';
|
|
21
22
|
export type { DerivedCall, DerivedCoreOutcome, DerivedDispatch, DerivedFetch, DerivedFetchOptions, DerivedHandler, DerivedOperation, DerivedOwner, DerivedSurface, BoardFrame } from './derived.ts';
|
|
22
23
|
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.ts';
|
|
23
|
-
export { deliverEvents, fillEnvelope, setDeliveryObserver, signEvent, signEventAll, verifyEvent } from './events.ts';
|
|
24
|
+
export { deliverEvents, fillEnvelope, setDeliveryObserver, settleEventDeliveries, signEvent, signEventAll, verifyEvent } from './events.ts';
|
|
24
25
|
export { equalSecrets, hmac, jwks, jwtDecode, jwtSign, jwtSignClaims, jwtVerify, jwtVerifySignature, keyId, lettersFrom, md5, sha256, SIGNING_ALGORITHMS, uuidFrom } from './signing.ts';
|
|
25
26
|
export type { HandlerCrypto, Jwk, Jwks, JwtDecodeOptions, JwtSignatureOptions, JwtVerdict, JwtVerificationKey, SigningKey } from './signing.ts';
|
|
26
27
|
export type { EventDelivery, EventRender, EventScheme, EventsDecl, EventTransport, EventWrite } from './events.ts';
|
package/src/serve-http.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
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.
|
|
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`
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
import type { TwinStream } from './twin-fetch.ts';
|
|
12
12
|
import { worldBootAnswer } from './world-boot.ts';
|
|
13
|
+
import { DEFAULT_MAX_DECODED_REQUEST_BODY_SIZE, RequestBodyTooLargeError } from './request-body.ts';
|
|
13
14
|
|
|
14
15
|
/** One socket library at the host seam, shared by inbound upgrades and authenticated outbound relays. */
|
|
15
16
|
const socketLibrary = () => import('ws');
|
|
@@ -41,7 +42,7 @@ export type ServeHttpOptions = {
|
|
|
41
42
|
fetch: HttpHandler;
|
|
42
43
|
/** seconds a connection may idle (Bun honors it; Node's keep-alive timeout is set from it) */
|
|
43
44
|
idleTimeout?: number;
|
|
44
|
-
/** Explicit transport limit in bytes; absent,
|
|
45
|
+
/** Explicit transport limit in bytes; absent, 128 MiB on both runtimes. */
|
|
45
46
|
maxRequestBodySize?: number;
|
|
46
47
|
/** what a thrown handler answers with; absent, a 500 carrying the error (as Bun.serve) */
|
|
47
48
|
error?: (error: Error) => Response | Promise<Response>;
|
|
@@ -101,7 +102,7 @@ export async function serveHttp(options: ServeHttpOptions): Promise<HttpServer>
|
|
|
101
102
|
const hostname = options.hostname ?? '127.0.0.1';
|
|
102
103
|
const urlHost = options.hostname ?? '127.0.0.1';
|
|
103
104
|
const bun = bunRuntime();
|
|
104
|
-
if (bun) {
|
|
105
|
+
if (bun && (options.upgrade !== undefined || 'websocket' in options)) {
|
|
105
106
|
// the global `Bun.serve` — the request journal's wrap of it (serve.ts) applies to packs and to
|
|
106
107
|
// this seam alike, so the seam never decorates twice on Bun
|
|
107
108
|
const upgrade = options.upgrade;
|
|
@@ -221,7 +222,8 @@ function builtin<T>(name: string): T {
|
|
|
221
222
|
|
|
222
223
|
async function serveOnNode(options: ServeHttpOptions & { hostname: string; urlHost: string }): Promise<HttpServer> {
|
|
223
224
|
const http = builtin<NodeHttp>('node:http');
|
|
224
|
-
const { Readable } = builtin<NodeStream>('node:stream');
|
|
225
|
+
const { Readable, Transform } = builtin<NodeStream>('node:stream');
|
|
226
|
+
const requestLimit = options.maxRequestBodySize ?? DEFAULT_MAX_DECODED_REQUEST_BODY_SIZE;
|
|
225
227
|
if ('websocket' in options) throw new Error('serveHttp: use the portable upgrade callbacks for WebSocket support on Node');
|
|
226
228
|
const protocol = options.tls ? 'https' : 'http';
|
|
227
229
|
const factory = (handler: import('node:http').RequestListener) => options.tls
|
|
@@ -245,23 +247,35 @@ async function serveOnNode(options: ServeHttpOptions & { hostname: string; urlHo
|
|
|
245
247
|
} catch { /* hostile thrown values are still reported by stage */ }
|
|
246
248
|
try { process.stderr.write(`serveHttp ${stage}: ${detail.slice(0, 512)}\n`); } catch { /* stderr is best-effort */ }
|
|
247
249
|
};
|
|
248
|
-
const active = new Set<
|
|
250
|
+
const active = new Set<() => void>();
|
|
249
251
|
// Node's Request signal is dependent on the source signal. Neither a pending
|
|
250
252
|
// handler nor a streaming response necessarily keeps the Request alive, so
|
|
251
253
|
// retain it until the whole HTTP response path (including body cleanup) ends.
|
|
252
254
|
const activeRequests = new Set<Request>();
|
|
253
255
|
const server = factory((req: import('node:http').IncomingMessage, res: import('node:http').ServerResponse) => {
|
|
254
256
|
const controller = new AbortController();
|
|
255
|
-
|
|
256
|
-
const
|
|
257
|
+
const socket = req.socket;
|
|
258
|
+
const hasInputBody = req.headers['transfer-encoding'] !== undefined || Number(req.headers['content-length']) > 0;
|
|
259
|
+
let incoming: import('node:stream').Transform | undefined;
|
|
260
|
+
let flushingBodyRefusal = false;
|
|
261
|
+
const abort = () => {
|
|
262
|
+
if (!controller.signal.aborted) controller.abort();
|
|
263
|
+
incoming?.destroy(controller.signal.reason);
|
|
264
|
+
if (!flushingBodyRefusal) {
|
|
265
|
+
req.destroy();
|
|
266
|
+
if (!res.writableFinished) res.destroy();
|
|
267
|
+
socket.destroy();
|
|
268
|
+
}
|
|
269
|
+
};
|
|
270
|
+
active.add(abort);
|
|
257
271
|
const onRequestClose = () => { if (!req.complete) abort(); };
|
|
258
|
-
const onResponseClose = () => { if (!res.writableFinished) abort(); };
|
|
272
|
+
const onResponseClose = () => { if (!res.writableFinished) { flushingBodyRefusal = false; abort(); } };
|
|
259
273
|
const onSocketClose = () => { if (!res.writableFinished) abort(); };
|
|
260
274
|
req.on('aborted', abort);
|
|
261
275
|
req.on('close', onRequestClose);
|
|
262
276
|
req.on('error', abort);
|
|
263
|
-
|
|
264
|
-
|
|
277
|
+
socket.on('close', onSocketClose);
|
|
278
|
+
socket.on('error', abort);
|
|
265
279
|
res.on('close', onResponseClose);
|
|
266
280
|
res.on('error', abort);
|
|
267
281
|
const disconnected = () => controller.signal.aborted || res.destroyed;
|
|
@@ -288,17 +302,50 @@ async function serveOnNode(options: ServeHttpOptions & { hostname: string; urlHo
|
|
|
288
302
|
if (disconnected()) closed();
|
|
289
303
|
});
|
|
290
304
|
let request: Request | undefined;
|
|
305
|
+
let bodyTooLarge = false;
|
|
306
|
+
const rejectBody = () => {
|
|
307
|
+
if (bodyTooLarge) return;
|
|
308
|
+
bodyTooLarge = true;
|
|
309
|
+
const error = new RequestBodyTooLargeError();
|
|
310
|
+
if (incoming) req.unpipe(incoming);
|
|
311
|
+
req.pause();
|
|
312
|
+
if (!res.headersSent && !res.destroyed) {
|
|
313
|
+
for (const name of res.getHeaderNames()) res.removeHeader(name);
|
|
314
|
+
flushingBodyRefusal = true;
|
|
315
|
+
res.writeHead(413, { 'connection': 'close', 'content-type': 'text/plain', 'content-length': Buffer.byteLength(error.message) });
|
|
316
|
+
res.once('finish', () => { flushingBodyRefusal = false; req.destroy(); });
|
|
317
|
+
res.end(error.message);
|
|
318
|
+
} else if (!res.destroyed) res.destroy(error);
|
|
319
|
+
abort();
|
|
320
|
+
};
|
|
321
|
+
const inputSettled = new Promise<void>((resolve) => {
|
|
322
|
+
const settled = () => { req.off('end', settled); req.off('close', settled); resolve(); };
|
|
323
|
+
req.once('end', settled);
|
|
324
|
+
req.once('close', settled);
|
|
325
|
+
});
|
|
291
326
|
const handle = async () => {
|
|
292
327
|
const method = req.method ?? 'GET';
|
|
328
|
+
if (Number(req.headers['content-length']) > requestLimit) { rejectBody(); return; }
|
|
293
329
|
let response: Response;
|
|
294
330
|
try {
|
|
295
331
|
const headers = new Headers();
|
|
296
332
|
for (const [k, v] of Object.entries(req.headers)) { if (v === undefined) continue; if (Array.isArray(v)) for (const one of v) headers.append(k, one); else headers.set(k, v); }
|
|
297
333
|
const url = `${protocol}://${req.headers.host ?? `${options.hostname}:${(server.address() as { port: number } | null)?.port ?? 0}`}${req.url ?? '/'}`;
|
|
298
334
|
const withBody = method !== 'GET' && method !== 'HEAD';
|
|
299
|
-
|
|
335
|
+
let received = 0;
|
|
336
|
+
incoming = new Transform({ transform(chunk: Buffer, _encoding, callback) {
|
|
337
|
+
received += chunk.byteLength;
|
|
338
|
+
if (received > requestLimit) { rejectBody(); callback(new RequestBodyTooLargeError()); }
|
|
339
|
+
else callback(null, chunk);
|
|
340
|
+
} });
|
|
341
|
+
const inputClosed = () => { if (!req.readableEnded) abort(); };
|
|
342
|
+
incoming.on('error', inputClosed);
|
|
343
|
+
incoming.once('close', inputClosed);
|
|
344
|
+
const init: RequestInit & { duplex?: 'half' } = { method, headers, signal: controller.signal, ...(withBody ? { body: Readable.toWeb(incoming) as unknown as ReadableStream, duplex: 'half' } : {}) };
|
|
300
345
|
request = new Request(url, init);
|
|
301
346
|
activeRequests.add(request);
|
|
347
|
+
req.pipe(incoming);
|
|
348
|
+
if (!withBody) incoming.resume();
|
|
302
349
|
response = await options.fetch(request);
|
|
303
350
|
} catch (error) {
|
|
304
351
|
if (disconnected()) return;
|
|
@@ -340,6 +387,7 @@ async function serveOnNode(options: ServeHttpOptions & { hostname: string; urlHo
|
|
|
340
387
|
if (disconnected()) { await cancelOwnedReader(); return; }
|
|
341
388
|
res.statusCode = response.status;
|
|
342
389
|
for (const [k, v] of response.headers) { if (k !== 'set-cookie') res.setHeader(k, v); }
|
|
390
|
+
res.strictContentLength = response.headers.has('content-length');
|
|
343
391
|
const cookies = response.headers.getSetCookie?.() ?? [];
|
|
344
392
|
if (cookies.length) res.setHeader('set-cookie', cookies);
|
|
345
393
|
if (reader) {
|
|
@@ -382,12 +430,18 @@ async function serveOnNode(options: ServeHttpOptions & { hostname: string; urlHo
|
|
|
382
430
|
if (!res.destroyed) res.destroy(error instanceof Error ? error : new Error(String(error)));
|
|
383
431
|
}).then(async () => {
|
|
384
432
|
await responseSettled;
|
|
385
|
-
|
|
433
|
+
// A runtime may retire its native body forwarding when the response ends.
|
|
434
|
+
// Close unfinished input after the response flushes; a consumed upload keeps its connection.
|
|
435
|
+
if (hasInputBody && !incoming?.readableEnded) abort();
|
|
436
|
+
else incoming?.resume();
|
|
437
|
+
await inputSettled;
|
|
438
|
+
if (incoming) { req.unpipe(incoming); incoming.destroy(); }
|
|
439
|
+
active.delete(abort);
|
|
386
440
|
req.off('aborted', abort);
|
|
387
441
|
req.off('close', onRequestClose);
|
|
388
442
|
req.off('error', abort);
|
|
389
|
-
|
|
390
|
-
|
|
443
|
+
socket.off('close', onSocketClose);
|
|
444
|
+
socket.off('error', abort);
|
|
391
445
|
res.off('close', onResponseClose);
|
|
392
446
|
res.off('error', abort);
|
|
393
447
|
});
|
|
@@ -415,7 +469,7 @@ async function serveOnNode(options: ServeHttpOptions & { hostname: string; urlHo
|
|
|
415
469
|
const port = (server.address() as { port: number }).port;
|
|
416
470
|
return {
|
|
417
471
|
hostname: options.hostname, port, url: new URL(`${protocol}://${options.urlHost}:${port}/`),
|
|
418
|
-
stop: (closeActive = true) => new Promise<void>((resolve) => { if (closeActive) { for (const
|
|
472
|
+
stop: (closeActive = true) => new Promise<void>((resolve) => { if (closeActive) { for (const abort of active) abort(); for (const peer of socketServer?.clients ?? []) peer.terminate(); } socketServer?.close(); server.close(() => { if (closeActive && active.size) reportCleanup('forced stop left pending handler cleanup', `${active.size} request(s)`); resolve(); }); if (closeActive) server.closeAllConnections(); }),
|
|
419
473
|
};
|
|
420
474
|
}
|
|
421
475
|
|