@volter/world-core 3.0.162 → 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/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. 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`
@@ -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, Bun retains its 128 MiB default. */
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<AbortController>();
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
- active.add(controller);
256
- const abort = () => { if (!controller.signal.aborted) controller.abort(); };
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
- req.socket.on('close', onSocketClose);
264
- req.socket.on('error', abort);
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
- const init: RequestInit & { duplex?: 'half' } = { method, headers, signal: controller.signal, ...(withBody ? { body: Readable.toWeb(req) as unknown as ReadableStream, duplex: 'half' } : {}) };
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
- active.delete(controller);
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
- req.socket.off('close', onSocketClose);
390
- req.socket.off('error', abort);
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 controller of active) controller.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(); }),
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