@zhin.js/host-http 1.1.17 → 1.2.0

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.
Files changed (89) hide show
  1. package/README.md +60 -21
  2. package/lib/access-control.d.ts +127 -0
  3. package/lib/access-control.js +148 -0
  4. package/lib/addresses.d.ts +9 -0
  5. package/lib/addresses.js +9 -0
  6. package/lib/bridge/bridged-host.d.ts +43 -0
  7. package/lib/bridge/bridged-host.js +497 -0
  8. package/lib/bridge/control-channel.d.ts +59 -0
  9. package/lib/bridge/control-channel.js +131 -0
  10. package/lib/bridge/flow.d.ts +58 -0
  11. package/lib/bridge/flow.js +188 -0
  12. package/lib/bridge/index.d.ts +6 -0
  13. package/lib/bridge/index.js +6 -0
  14. package/lib/bridge/instance-transport.d.ts +112 -0
  15. package/lib/bridge/instance-transport.js +532 -0
  16. package/lib/bridge/protocol.d.ts +242 -0
  17. package/lib/bridge/protocol.js +369 -0
  18. package/lib/console-events.d.ts +5 -3
  19. package/lib/console-events.js +53 -27
  20. package/lib/console-plugin-rpc.d.ts +5 -1
  21. package/lib/console-plugin-rpc.js +5 -40
  22. package/lib/console-rest-pages-helpers.d.ts +4 -5
  23. package/lib/console-rest-pages-helpers.js +62 -79
  24. package/lib/console-rest-pages.js +80 -65
  25. package/lib/console-rpc-extended/dispatcher.js +1 -6
  26. package/lib/console-rpc-handlers.d.ts +0 -2
  27. package/lib/console-rpc-handlers.js +2 -40
  28. package/lib/console-rpc.d.ts +6 -14
  29. package/lib/console-rpc.js +6 -6
  30. package/lib/host-installer.d.ts +6 -3
  31. package/lib/host-installer.js +10 -40
  32. package/lib/host-options.d.ts +47 -0
  33. package/lib/host-options.js +1 -0
  34. package/lib/http-host.d.ts +124 -48
  35. package/lib/http-host.js +319 -332
  36. package/lib/index.d.ts +10 -5
  37. package/lib/index.js +9 -5
  38. package/lib/json-body.d.ts +1 -2
  39. package/lib/json-body.js +1 -2
  40. package/lib/marketplace-registry.d.ts +17 -0
  41. package/lib/marketplace-registry.js +52 -0
  42. package/lib/node/index.d.ts +10 -0
  43. package/lib/node/index.js +10 -0
  44. package/lib/{listener-group.d.ts → node/listener-group.d.ts} +2 -3
  45. package/lib/{listener-group.js → node/listener-group.js} +6 -10
  46. package/lib/node/listener.d.ts +26 -0
  47. package/lib/node/listener.js +231 -0
  48. package/lib/node/node-http.d.ts +38 -0
  49. package/lib/node/node-http.js +272 -0
  50. package/lib/node/server.d.ts +13 -0
  51. package/lib/node/server.js +64 -0
  52. package/lib/protocol-auth.d.ts +24 -0
  53. package/lib/protocol-auth.js +48 -0
  54. package/lib/public-api.d.ts +1 -1
  55. package/lib/token-registry.d.ts +54 -36
  56. package/lib/token-registry.js +133 -126
  57. package/lib/trust-proxy.d.ts +22 -0
  58. package/lib/trust-proxy.js +168 -0
  59. package/package.json +14 -6
  60. package/src/access-control.ts +255 -0
  61. package/src/addresses.ts +16 -0
  62. package/src/bridge/bridged-host.ts +548 -0
  63. package/src/bridge/control-channel.ts +173 -0
  64. package/src/bridge/flow.ts +208 -0
  65. package/src/bridge/index.ts +31 -0
  66. package/src/bridge/instance-transport.ts +627 -0
  67. package/src/bridge/protocol.ts +574 -0
  68. package/src/console-events.ts +59 -28
  69. package/src/console-plugin-rpc.ts +5 -51
  70. package/src/console-rest-pages-helpers.ts +60 -82
  71. package/src/console-rest-pages.ts +87 -67
  72. package/src/console-rpc-extended/dispatcher.ts +1 -6
  73. package/src/console-rpc-handlers.ts +2 -31
  74. package/src/console-rpc.ts +10 -19
  75. package/src/host-installer.ts +12 -44
  76. package/src/host-options.ts +47 -0
  77. package/src/http-host.ts +467 -421
  78. package/src/index.ts +55 -13
  79. package/src/json-body.ts +7 -2
  80. package/src/marketplace-registry.ts +62 -0
  81. package/src/node/index.ts +21 -0
  82. package/src/{listener-group.ts → node/listener-group.ts} +13 -24
  83. package/src/node/listener.ts +265 -0
  84. package/src/node/node-http.ts +263 -0
  85. package/src/node/server.ts +82 -0
  86. package/src/protocol-auth.ts +52 -0
  87. package/src/public-api.ts +2 -0
  88. package/src/token-registry.ts +143 -146
  89. package/src/trust-proxy.ts +178 -0
package/src/http-host.ts CHANGED
@@ -1,16 +1,12 @@
1
- import {
2
- createServer as createHttpServer,
3
- type IncomingMessage,
4
- type Server as HttpServer,
5
- type ServerResponse,
6
- } from 'node:http';
7
- import {
8
- createServer as createHttpsServer,
9
- type Server as HttpsServer,
10
- type ServerOptions as HttpsServerOptions,
11
- } from 'node:https';
12
- import type { Socket } from 'node:net';
13
- import { WebSocket, WebSocketServer } from 'ws';
1
+ /**
2
+ * Transport-independent HTTP/WebSocket route surface (ADR 0054 §2).
3
+ *
4
+ * Routes take a Web standard `Request` and return a `Response`; WebSocket routes
5
+ * receive a {@link WsConnection}. The router owns matching, CORS, Console
6
+ * authentication and generation admission. A transport (the in-process Node
7
+ * server in `@zhin.js/host-http/node`, later the gateway bridge) feeds it
8
+ * `Request`s and upgrade requests. Nothing here imports `node:http` or `ws`.
9
+ */
14
10
  import { formatCompact, getLogger } from '@zhin.js/logger';
15
11
  import {
16
12
  createToken,
@@ -18,13 +14,10 @@ import {
18
14
  type GenerationAdmissionBindable,
19
15
  type GenerationAdmissionGate,
20
16
  } from '@zhin.js/plugin-runtime';
17
+ import { HttpBodyError, jsonResponse, type HttpRouteContext } from '@zhin.js/host-http-contract';
21
18
  import {
22
19
  TokenRegistry,
23
20
  extractBearerToken,
24
- isDemoWebSocketPath,
25
- type AuthenticatedTokenPrincipal,
26
- type AuthScope,
27
- type ScopedTokenConfig,
28
21
  type TokenRegistryConfig,
29
22
  } from './token-registry.js';
30
23
  import {
@@ -32,18 +25,54 @@ import {
32
25
  type ListedRoute,
33
26
  type RouteMeta,
34
27
  } from './openapi.js';
35
- import { isDemoHttpAllowed } from './console-rpc.js';
36
- import { HttpBodyError } from './json-body.js';
28
+ import {
29
+ auditActor,
30
+ authorizeConsoleHttp,
31
+ authorizeConsoleWebSocket,
32
+ requestMeta,
33
+ type ConsolePrincipal,
34
+ } from './access-control.js';
35
+ import type { HttpHostAddress } from './host-options.js';
36
+
37
+ export type { HttpHostAddress } from './host-options.js';
37
38
 
38
39
  const logger = getLogger('http');
39
40
 
40
41
  export const REMOTE_CONSOLE_ORIGIN = 'https://console.zhin.dev';
41
42
  const DEFAULT_CORS_ORIGINS = Object.freeze([REMOTE_CONSOLE_ORIGIN]);
42
43
 
44
+ /** Per-request data resolved by the Host before a route runs. */
45
+ export interface RouteContext extends HttpRouteContext {
46
+ readonly url: URL;
47
+ readonly params: Readonly<Record<string, string>>;
48
+ /**
49
+ * Console principal the Host authenticated. Set for `{apiBase}` routes and for public
50
+ * routes that presented a valid credential; absent otherwise.
51
+ */
52
+ readonly principal?: ConsolePrincipal;
53
+ readonly remoteAddress?: string;
54
+ }
55
+
56
+ export type HttpHandler = (request: Request, context: RouteContext) => Response | Promise<Response>;
57
+
58
+ export interface HttpRouteRegistration {
59
+ (): void;
60
+ }
61
+
62
+ /** An accepted WebSocket, independent of the transport that carries it. */
43
63
  export interface WsConnection {
44
- readonly socket: WebSocket;
45
- readonly request: IncomingMessage;
46
- readonly authScope: AuthScope;
64
+ readonly url: URL;
65
+ readonly headers: Headers;
66
+ /** Authenticated principal (absent for `auth: 'protocol'` routes, which authenticate themselves). */
67
+ readonly principal?: ConsolePrincipal;
68
+ readonly remoteAddress?: string;
69
+ /** Text frames as strings, binary frames as bytes. Dropped once the socket is closing. */
70
+ send(data: string | Uint8Array): void;
71
+ close(code?: number, reason?: string): void;
72
+ onMessage(listener: (data: string | Uint8Array) => void): () => void;
73
+ onClose(listener: (code: number, reason: string) => void): () => void;
74
+ /** Bytes queued but not yet sent (backpressure). */
75
+ readonly bufferedAmount: number;
47
76
  }
48
77
 
49
78
  export interface WsHandle {
@@ -59,30 +88,27 @@ export interface WsRouteOptions {
59
88
  readonly auth?: 'host' | 'protocol';
60
89
  }
61
90
 
62
- export interface HttpHostAddress {
63
- readonly host: string;
64
- readonly port: number;
65
- readonly protocol: 'http' | 'https';
66
- readonly secure: boolean;
67
- readonly origin: string;
91
+ /** @public Stable HTTP and WebSocket Host contract resolved through `httpHostToken`. */
92
+ export interface HttpHost {
93
+ ws(path: string, options?: WsRouteOptions): WsHandle;
94
+ route(
95
+ method: string,
96
+ path: string,
97
+ handler: HttpHandler,
98
+ meta?: RouteMeta,
99
+ ): HttpRouteRegistration;
100
+ listRoutes(): readonly ListedRoute[];
101
+ get address(): HttpHostAddress | undefined;
102
+ /** @internal Console authentication registry owned by the process Host. */
103
+ get tokenRegistry(): TokenRegistry;
68
104
  }
69
105
 
70
- export interface HttpHostTlsOptions {
71
- readonly key: NonNullable<HttpsServerOptions['key']>;
72
- readonly cert: NonNullable<HttpsServerOptions['cert']>;
73
- readonly ca?: HttpsServerOptions['ca'];
74
- readonly passphrase?: string;
75
- readonly minVersion?: HttpsServerOptions['minVersion'];
76
- readonly ciphers?: string;
77
- }
106
+ /** @public Stable Plugin Runtime HTTP Host token. */
107
+ export const httpHostToken = createToken<HttpHost>('zhin.host.http');
78
108
 
79
- export interface HttpHostOptions {
80
- readonly host?: string;
81
- readonly port?: number;
82
- /** Primary full-scope Bearer token (`http.token`). */
109
+ export interface HttpRouterOptions {
110
+ /** Bootstrap Bearer token (`http.token`), accepted only until the first owner exists. */
83
111
  readonly token?: string;
84
- /** Additional scoped tokens (`http.tokens`). */
85
- readonly tokens?: readonly ScopedTokenConfig[];
86
112
  /** CORS allowlist; always merged with Remote Console origin. */
87
113
  readonly corsOrigins?: readonly string[];
88
114
  /**
@@ -92,41 +118,94 @@ export interface HttpHostOptions {
92
118
  readonly apiBase?: string;
93
119
  /** Extra HTTP path prefixes that skip auth even under `apiBase`. */
94
120
  readonly authExemptPaths?: readonly string[];
95
- /** Enables HTTPS and WSS on this listener. Certificate lifecycle belongs to the process composition root. */
96
- readonly tls?: HttpHostTlsOptions;
121
+ /** Shared registry for listener groups; `token` is ignored when set. */
122
+ readonly tokenRegistry?: TokenRegistry;
123
+ /** Whether the carrying transport is TLS (OpenAPI server URL default). */
124
+ readonly secure?: boolean;
125
+ /** Bound listener address as reported by the transport. */
126
+ readonly address?: () => HttpHostAddress | undefined;
127
+ /**
128
+ * Who authenticates requests (ADR 0054 §3):
129
+ * - `credentials` (default): the router resolves the Bearer / `?token=` credential through
130
+ * {@link TokenRegistry}. A `principal` passed by the transport is ignored.
131
+ * - `forwarded`: the carrying transport already authenticated the request (the gateway, over
132
+ * the bridge) and passes the principal in {@link HttpRequestInfo.principal} /
133
+ * {@link WsUpgradeRequest.principal}. The router never parses credentials itself but still
134
+ * authorizes every `{apiBase}` route and host-authenticated socket against the Console
135
+ * permission map, exactly as in `credentials` mode. A missing principal is anonymous.
136
+ */
137
+ readonly authentication?: 'credentials' | 'forwarded';
138
+ /**
139
+ * Register the built-in `/pub/health` and `/pub/openapi.json` routes (default true). The
140
+ * gateway turns them off: it owns its own health probe and forwards the instance's catalog.
141
+ */
142
+ readonly builtinRoutes?: boolean;
97
143
  }
98
144
 
99
- export type HttpHandler = (
100
- request: IncomingMessage,
101
- response: ServerResponse,
102
- url: URL,
103
- authScope: AuthScope,
104
- authenticatedPrincipal?: AuthenticatedTokenPrincipal,
105
- ) => void | Promise<void>;
145
+ export interface HttpRequestInfo {
146
+ readonly remoteAddress?: string;
147
+ /**
148
+ * An in-process caller with no network peer (tests, embedding). Only such an explicit caller is
149
+ * admitted to an open Host without a loopback `remoteAddress`.
150
+ */
151
+ readonly inProcess?: boolean;
152
+ /** Pre-authenticated principal; honoured only by a `forwarded` router. */
153
+ readonly principal?: ConsolePrincipal;
154
+ }
106
155
 
107
- export interface HttpRouteRegistration {
108
- (): void;
156
+ export interface WsUpgradeRequest {
157
+ readonly url: URL;
158
+ readonly headers: Headers;
159
+ readonly remoteAddress?: string;
160
+ /** Pre-authenticated principal; honoured only by a `forwarded` router. */
161
+ readonly principal?: ConsolePrincipal;
109
162
  }
110
163
 
111
- /** @public Stable HTTP and WebSocket Host contract resolved through `httpHostToken`. */
112
- export interface HttpHost {
113
- ws(path: string, options?: WsRouteOptions): WsHandle;
114
- route(
115
- method: string,
116
- path: string,
117
- handler: HttpHandler,
118
- meta?: RouteMeta,
119
- ): HttpRouteRegistration;
120
- listRoutes(): readonly ListedRoute[];
121
- get address(): HttpHostAddress | undefined;
122
- /** @internal Console authentication registry owned by the process Host. */
123
- get tokenRegistry(): TokenRegistry;
164
+ /** A registered WebSocket path and how it authenticates (admission-active only). */
165
+ export interface ListedWsRoute {
166
+ readonly path: string;
167
+ readonly auth: 'host' | 'protocol';
124
168
  }
125
169
 
126
- /** @internal Process-root lifecycle and generation admission surface. */
127
- export interface ProcessHttpHost extends HttpHost, GenerationAdmissionBindable<HttpHost> {
128
- listen(): Promise<HttpHostAddress>;
129
- close(): Promise<void>;
170
+ /** The transport half of a {@link WsConnection}. */
171
+ export interface WsTransport {
172
+ send(data: string | Uint8Array): void;
173
+ close(code?: number, reason?: string): void;
174
+ onMessage(listener: (data: string | Uint8Array) => void): () => void;
175
+ onClose(listener: (code: number, reason: string) => void): () => void;
176
+ readonly bufferedAmount: number;
177
+ }
178
+
179
+ export type WsUpgradeDecision =
180
+ | Readonly<{
181
+ accepted: false;
182
+ /** 404 drops the socket silently; other statuses are written as a bare HTTP response. */
183
+ status: 401 | 404 | 503;
184
+ /** Extra response headers for the refusal (e.g. `Retry-After`). */
185
+ headers?: Readonly<Record<string, string>>;
186
+ }>
187
+ | Readonly<{
188
+ accepted: true;
189
+ /** Hand the established socket to the route listeners. */
190
+ accept(transport: WsTransport): void;
191
+ /** The transport failed to complete the handshake. */
192
+ reject(): void;
193
+ }>;
194
+
195
+ /** @internal Transport-facing router; `createHttpHost` (node) and the gateway bridge drive it. */
196
+ export interface HttpRouter extends HttpHost, GenerationAdmissionBindable<HttpHost> {
197
+ handle(request: Request, info?: HttpRequestInfo): Promise<Response>;
198
+ upgrade(request: WsUpgradeRequest): Promise<WsUpgradeDecision>;
199
+ readonly routeCount: number;
200
+ readonly wsPaths: readonly string[];
201
+ /** Admission-active WebSocket routes (`protocol` only when every listener uses it). */
202
+ listWsRoutes(): readonly ListedWsRoute[];
203
+ /** Called after any route or socket registration changes (not on admission changes). */
204
+ onRoutesChanged(listener: () => void): () => void;
205
+ readonly apiBase: string;
206
+ readonly corsOrigins: readonly string[];
207
+ /** Terminal: routes are dropped and every later request gets 503. */
208
+ close(): void;
130
209
  }
131
210
 
132
211
  interface HttpRoute {
@@ -147,13 +226,9 @@ interface WsRoute {
147
226
  readonly options: WsRouteOptions;
148
227
  }
149
228
 
150
- /** @public Stable Plugin Runtime HTTP Host token. */
151
- export const httpHostToken = createToken<HttpHost>('zhin.host.http');
229
+ const NO_PARAMS: Readonly<Record<string, string>> = Object.freeze({});
152
230
 
153
- /** @internal CLI composition-root factory. */
154
- export function createHttpHost(options: HttpHostOptions = {}): ProcessHttpHost {
155
- const host = options.host ?? '127.0.0.1';
156
- const port = options.port ?? 8086;
231
+ export function createHttpRouter(options: HttpRouterOptions = {}): HttpRouter {
157
232
  const apiBase = normalizePath(options.apiBase ?? '/api');
158
233
  const corsOrigins = Object.freeze([
159
234
  ...new Set([...DEFAULT_CORS_ORIGINS, ...(options.corsOrigins ?? [])]),
@@ -161,77 +236,98 @@ export function createHttpHost(options: HttpHostOptions = {}): ProcessHttpHost {
161
236
  const authExempt = Object.freeze(
162
237
  (options.authExemptPaths ?? []).map((path) => normalizePath(path)),
163
238
  );
164
- const tokenRegistry = new TokenRegistry({
239
+ const tokenRegistry = options.tokenRegistry ?? new TokenRegistry({
165
240
  primaryToken: options.token,
166
- scopedTokens: options.tokens,
167
241
  } satisfies TokenRegistryConfig);
242
+ const secure = options.secure === true;
243
+ const readAddress = options.address ?? (() => undefined);
244
+ const forwarded = options.authentication === 'forwarded';
168
245
  const wsRoutes = new Map<string, Set<WsRoute>>();
169
246
  const httpRoutes: HttpRoute[] = [];
170
- let address: HttpHostAddress | undefined;
247
+ const changeListeners = new Set<() => void>();
171
248
  let closed = false;
172
- let closeResult: Promise<void> | undefined;
173
- let listenResult: Promise<HttpHostAddress> | undefined;
174
-
175
- const secure = options.tls !== undefined;
176
- const handleRequest = (request: IncomingMessage, response: ServerResponse): void => {
177
- void dispatchHttp(request, response);
249
+ const routesChanged = (): void => {
250
+ for (const listener of [...changeListeners]) {
251
+ try { listener(); } catch { /* observers never break registration */ }
252
+ }
178
253
  };
179
- const server: HttpServer | HttpsServer = secure
180
- ? createHttpsServer(options.tls, handleRequest)
181
- : createHttpServer(handleRequest);
182
-
183
- // Track live TCP sockets so close() can destroy long-lived connections
184
- // (SSE /api/events, keep-alive) that would otherwise block server.close().
185
- const sockets = new Set<Socket>();
186
- server.on('connection', (socket) => {
187
- sockets.add(socket);
188
- socket.once('close', () => {
189
- sockets.delete(socket);
190
- });
191
- });
192
-
193
- const wss = new WebSocketServer({ noServer: true });
194
-
195
- // Built-in public health probe (parity with host-router `/pub/health`).
196
- httpRoutes.push(Object.freeze({
197
- method: 'GET',
198
- path: '/pub/health',
199
- pattern: '/pub/health',
200
- prefix: false,
201
- meta: Object.freeze({ summary: 'Health probe', tags: ['pub'] }),
202
- handler: (_request: IncomingMessage, response: ServerResponse) => {
203
- writeJson(response, 200, {
254
+
255
+ if (options.builtinRoutes !== false) registerBuiltinRoutes();
256
+
257
+ function registerBuiltinRoutes(): void {
258
+ // Built-in public health probe (parity with host-router `/pub/health`).
259
+ httpRoutes.push(Object.freeze({
260
+ method: 'GET',
261
+ path: '/pub/health',
262
+ pattern: '/pub/health',
263
+ prefix: false,
264
+ meta: Object.freeze({ summary: 'Health probe', tags: ['pub'] }),
265
+ handler: () => jsonResponse(200, {
204
266
  success: true,
205
267
  status: 'ok',
206
268
  timestamp: new Date().toISOString(),
207
- });
208
- },
209
- }));
210
-
211
- // OpenAPI catalog (parity with host-router `/pub/openapi.json`).
212
- httpRoutes.push(Object.freeze({
213
- method: 'GET',
214
- path: '/pub/openapi.json',
215
- pattern: '/pub/openapi.json',
216
- prefix: false,
217
- meta: Object.freeze({ summary: 'OpenAPI 3.1 document', tags: ['pub'] }),
218
- handler: (request: IncomingMessage, response: ServerResponse) => {
219
- const hostHeader = headerValue(request.headers.host) ?? 'localhost';
220
- const proto = headerValue(request.headers['x-forwarded-proto']) ?? (secure ? 'https' : 'http');
221
- writeJson(response, 200, buildOpenApiDocument(listListedRoutes(), {
222
- apiBase,
223
- serverUrl: `${proto}://${hostHeader}`,
224
- version: process.env.npm_package_version ?? '0.0.0',
225
- }));
226
- },
227
- }));
269
+ }),
270
+ }));
271
+
272
+ // OpenAPI catalog (parity with host-router `/pub/openapi.json`).
273
+ httpRoutes.push(Object.freeze({
274
+ method: 'GET',
275
+ path: '/pub/openapi.json',
276
+ pattern: '/pub/openapi.json',
277
+ prefix: false,
278
+ meta: Object.freeze({ summary: 'OpenAPI 3.1 document', tags: ['pub'] }),
279
+ handler: (request: Request) => {
280
+ const hostHeader = request.headers.get('host') ?? 'localhost';
281
+ const proto = request.headers.get('x-forwarded-proto') ?? (secure ? 'https' : 'http');
282
+ return jsonResponse(200, buildOpenApiDocument(listListedRoutes(), {
283
+ apiBase,
284
+ serverUrl: `${proto}://${hostHeader}`,
285
+ version: process.env.npm_package_version ?? '0.0.0',
286
+ }));
287
+ },
288
+ }));
289
+ }
228
290
 
229
- server.on('upgrade', (request, socket, head) => {
230
- if (closed) {
231
- socket.destroy();
232
- return;
291
+ async function authenticateUpgrade(
292
+ request: WsUpgradeRequest,
293
+ pathname: string,
294
+ ): Promise<ConsolePrincipal | undefined> {
295
+ const { url, headers, remoteAddress } = request;
296
+ const principal = forwarded
297
+ ? request.principal
298
+ : await tokenRegistry.authenticate(
299
+ extractBearerToken(headers.get('authorization') ?? undefined, url.searchParams.get('token')),
300
+ requestMeta({ headers }, remoteAddress),
301
+ );
302
+ if (!principal) return undefined;
303
+ if (!authorizeConsoleWebSocket(principal, pathname)) {
304
+ audit(principal, remoteAddress, `ws:${pathname}`, 'denied');
305
+ return undefined;
233
306
  }
234
- const pathname = upgradePath(request.url);
307
+ return principal;
308
+ }
309
+
310
+ function audit(
311
+ principal: ConsolePrincipal | undefined,
312
+ remoteAddress: string | undefined,
313
+ action: string,
314
+ outcome: 'ok' | 'denied' | 'error',
315
+ ): void {
316
+ const sink = tokenRegistry.accessProvider?.audit;
317
+ if (!sink) return;
318
+ try {
319
+ sink.call(tokenRegistry.accessProvider, {
320
+ actor: auditActor(principal),
321
+ action,
322
+ outcome,
323
+ ...(remoteAddress ? { ip: remoteAddress } : {}),
324
+ });
325
+ } catch { /* audit never breaks a request */ }
326
+ }
327
+
328
+ async function upgrade(request: WsUpgradeRequest): Promise<WsUpgradeDecision> {
329
+ if (closed) return Object.freeze({ accepted: false, status: 404 });
330
+ const pathname = normalizePath(request.url.pathname);
235
331
  const listeners = wsRoutes.get(pathname);
236
332
  const admitted = listeners ? [...listeners].flatMap((entry) => {
237
333
  const release = entry.admission?.acquire();
@@ -240,199 +336,182 @@ export function createHttpHost(options: HttpHostOptions = {}): ProcessHttpHost {
240
336
  }) : [];
241
337
  if (admitted.length === 0) {
242
338
  logger.debug(formatCompact({ op: 'ws_upgrade_unmatched', path: pathname }));
243
- socket.destroy();
244
- return;
339
+ return Object.freeze({ accepted: false, status: 404 });
245
340
  }
246
- const protocolAuthenticated = admitted.every(({ entry }) => entry.options.auth === 'protocol');
247
- const auth = protocolAuthenticated
248
- ? { ok: true as const, scope: 'full' as const }
249
- : authenticateUpgrade(request, pathname);
250
- if (!auth.ok) {
251
- socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
252
- socket.destroy();
341
+ let settled = false;
342
+ const releaseAll = (): void => {
343
+ if (settled) return;
344
+ settled = true;
253
345
  for (const item of admitted) item.release?.();
254
- return;
346
+ };
347
+ const protocolAuthenticated = admitted.every(({ entry }) => entry.options.auth === 'protocol');
348
+ let principal: ConsolePrincipal | undefined;
349
+ if (!protocolAuthenticated) {
350
+ principal = await authenticateUpgrade(request, pathname).catch(() => undefined);
351
+ if (!principal || closed) {
352
+ releaseAll();
353
+ return Object.freeze({ accepted: false, status: 401 });
354
+ }
255
355
  }
256
- try {
257
- wss.handleUpgrade(request, socket, head, (ws) => {
258
- let settled = false;
356
+ return Object.freeze({
357
+ accepted: true as const,
358
+ reject: releaseAll,
359
+ accept(transport: WsTransport): void {
360
+ if (settled) {
361
+ transport.close(1012, 'Root stopped');
362
+ return;
363
+ }
259
364
  const stopSubscriptions: Array<() => void> = [];
260
- const releaseAdmissions = () => {
261
- if (settled) return;
262
- settled = true;
365
+ let finished = false;
366
+ transport.onClose(() => {
367
+ if (finished) return;
368
+ finished = true;
263
369
  for (const unsubscribe of stopSubscriptions) unsubscribe();
264
- for (const item of admitted) item.release?.();
265
- };
266
- ws.once('close', releaseAdmissions);
267
- ws.once('error', releaseAdmissions);
370
+ releaseAll();
371
+ });
268
372
  // Replacement closes new upgrades, not accepted plugin work. Explicit Root stop
269
373
  // still closes its sockets before waiting for their admission leases to drain.
270
374
  const stops = new Set(admitted.map(item => item.entry.admission?.stopSignal).filter(signal => signal !== undefined));
271
375
  for (const signal of stops) {
272
376
  const stop = () => {
273
- if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) ws.close(1012, 'Root stopped');
377
+ if (!finished) transport.close(1012, 'Root stopped');
274
378
  };
275
379
  signal.addEventListener('abort', stop, { once: true });
276
380
  stopSubscriptions.push(() => signal.removeEventListener('abort', stop));
277
381
  if (signal.aborted) stop();
278
382
  }
279
- const connection = Object.freeze({
280
- socket: ws,
281
- request,
282
- authScope: auth.scope,
383
+ const connection: WsConnection = Object.freeze({
384
+ url: request.url,
385
+ headers: request.headers,
386
+ ...(principal ? { principal } : {}),
387
+ ...(request.remoteAddress ? { remoteAddress: request.remoteAddress } : {}),
388
+ send: (data: string | Uint8Array) => transport.send(data),
389
+ close: (code?: number, reason?: string) => transport.close(code, reason),
390
+ onMessage: (listener: (data: string | Uint8Array) => void) => transport.onMessage(listener),
391
+ onClose: (listener: (code: number, reason: string) => void) => transport.onClose(listener),
392
+ get bufferedAmount() { return transport.bufferedAmount; },
283
393
  });
284
- for (const item of admitted) item.entry.listener(connection);
285
- });
286
- } catch (err) {
287
- for (const item of admitted) item.release?.();
288
- logger.warn(formatCompact({
289
- op: 'ws_upgrade_failed',
290
- path: pathname,
291
- error: err instanceof Error ? err.message : String(err),
292
- }));
293
- try {
294
- socket.destroy();
295
- } catch {
296
- /* already destroyed */
297
- }
298
- }
299
- });
300
-
301
- function authenticateUpgrade(
302
- request: IncomingMessage,
303
- pathname: string,
304
- ): { ok: true; scope: AuthScope } | { ok: false } {
305
- if (!tokenRegistry.hasAnyToken()) return { ok: true, scope: 'full' };
306
- let url: URL;
307
- try {
308
- url = new URL(request.url ?? '/', 'http://localhost');
309
- } catch {
310
- return { ok: false };
311
- }
312
- const token = extractBearerToken(
313
- headerValue(request.headers.authorization),
314
- url.searchParams.get('token'),
315
- );
316
- const scope = tokenRegistry.resolve(token);
317
- if (!scope) return { ok: false };
318
- if (scope === 'demo' && !isDemoWebSocketPath(pathname)) return { ok: false };
319
- return { ok: true, scope };
394
+ for (const item of admitted) {
395
+ try {
396
+ item.entry.listener(connection);
397
+ } catch (error) {
398
+ logger.warn(formatCompact({
399
+ op: 'ws_listener_failed',
400
+ path: pathname,
401
+ error: error instanceof Error ? error.message : String(error),
402
+ }));
403
+ }
404
+ }
405
+ },
406
+ });
320
407
  }
321
408
 
322
- async function dispatchHttp(request: IncomingMessage, response: ServerResponse): Promise<void> {
323
- if (closed) {
324
- response.writeHead(503);
325
- response.end();
326
- return;
327
- }
328
- const method = (request.method ?? 'GET').toUpperCase();
409
+ async function handle(request: Request, info: HttpRequestInfo = {}): Promise<Response> {
410
+ if (closed) return new Response(null, { status: 503 });
411
+ const method = request.method.toUpperCase();
329
412
  let url: URL;
330
413
  try {
331
- url = new URL(request.url ?? '/', 'http://localhost');
414
+ url = new URL(request.url);
332
415
  } catch {
333
- response.writeHead(400);
334
- response.end();
335
- return;
416
+ return new Response(null, { status: 400 });
336
417
  }
337
418
  const pathname = normalizePath(url.pathname);
338
- const origin = headerValue(request.headers.origin);
339
- const corsOk = corsMatch(origin, corsOrigins);
340
-
341
- if (method === 'OPTIONS') {
342
- if (corsOk && origin) applyCors(response, origin, corsOrigins);
343
- response.writeHead(204);
344
- response.end();
345
- return;
346
- }
419
+ const origin = request.headers.get('origin') ?? undefined;
420
+ const cors = origin && corsMatch(origin, corsOrigins) ? corsHeaders(origin, corsOrigins) : undefined;
421
+
422
+ if (method === 'OPTIONS') return withHeaders(new Response(null, { status: 204 }), cors);
347
423
 
348
- const auth = authenticateHttp(request, url, pathname);
424
+ const auth = await authenticateHttp(request, url, pathname, method, info);
349
425
  if (!auth.ok) {
350
- if (corsOk && origin) applyCors(response, origin, corsOrigins);
351
- writeJson(response, auth.forbidden ? 403 : 401, {
426
+ return withHeaders(jsonResponse(auth.status, {
352
427
  success: false,
353
- code: auth.forbidden ? 'forbidden' : 'unauthorized',
428
+ code: auth.code,
354
429
  requestOutcome: 'not-dispatched',
355
- error: auth.forbidden ? 'Token scope does not allow this route' : 'Invalid or missing token',
356
- });
357
- return;
430
+ error: auth.error,
431
+ }), cors);
358
432
  }
359
433
 
360
434
  const route = matchHttpRoute(httpRoutes, method, pathname);
361
- if (!route) {
362
- if (corsOk && origin) applyCors(response, origin, corsOrigins);
363
- response.writeHead(404);
364
- response.end();
365
- return;
435
+ if (!route) return withHeaders(new Response(null, { status: 404 }), cors);
436
+ const defaults = { ...cors, 'x-content-type-options': 'nosniff', 'x-frame-options': 'SAMEORIGIN' };
437
+ const release = route.admission?.acquire();
438
+ if (route.admission && !release) {
439
+ return withHeaders(jsonResponse(503, { success: false, code: 'unavailable', requestOutcome: 'not-dispatched' }), defaults);
366
440
  }
441
+ const context: RouteContext = Object.freeze({
442
+ url,
443
+ params: route.prefix ? Object.freeze({ '*': prefixRemainder(route.path, pathname) }) : NO_PARAMS,
444
+ ...(auth.principal ? { principal: auth.principal } : {}),
445
+ ...(info.remoteAddress ? { remoteAddress: info.remoteAddress } : {}),
446
+ });
447
+ let response: Response;
367
448
  try {
368
- if (corsOk && origin) applyCors(response, origin, corsOrigins);
369
- response.setHeader('X-Content-Type-Options', 'nosniff');
370
- response.setHeader('X-Frame-Options', 'SAMEORIGIN');
371
- const release = route.admission?.acquire();
372
- if (route.admission && !release) {
373
- writeJson(response, 503, { success: false, code: 'unavailable', requestOutcome: 'not-dispatched' });
374
- return;
375
- }
376
- if (!release) {
377
- await route.handler(request, response, url, auth.scope, auth.principal);
378
- return;
379
- }
380
- let released = false;
381
- const releaseResponse = () => {
382
- if (released) return;
383
- released = true;
384
- response.off('finish', releaseResponse);
385
- response.off('close', releaseResponse);
386
- release();
387
- };
388
- response.once('finish', releaseResponse);
389
- response.once('close', releaseResponse);
390
- await route.handler(request, response, url, auth.scope, auth.principal);
391
- if (response.writableFinished || response.destroyed) releaseResponse();
449
+ response = await route.handler(request, context);
450
+ if (!(response instanceof Response)) throw new TypeError('HTTP route handler did not return a Response');
392
451
  } catch (err) {
452
+ release?.();
393
453
  // 统一把请求体错误(400/413)映射回对应状态码,
394
454
  // 避免个别端点未捕获时退化成 500 空响应。
395
455
  if (err instanceof HttpBodyError) {
396
- if (!response.headersSent) {
397
- writeJson(response, err.statusCode, { success: false, error: err.message });
398
- }
399
- return;
456
+ return withHeaders(jsonResponse(err.statusCode, { success: false, error: err.message }), defaults);
400
457
  }
401
458
  logger.warn(formatCompact({
402
459
  op: 'http_handler_failed',
403
460
  path: pathname,
404
461
  error: err instanceof Error ? err.message : String(err),
405
462
  }));
406
- if (!response.headersSent) {
407
- response.writeHead(500);
408
- response.end();
409
- }
463
+ return withHeaders(new Response(null, { status: 500 }), defaults);
410
464
  }
465
+ return withHeaders(response, defaults, release);
411
466
  }
412
467
 
413
- function authenticateHttp(
414
- request: IncomingMessage,
468
+ async function authenticateHttp(
469
+ request: Request,
415
470
  url: URL,
416
471
  pathname: string,
417
- ): { ok: true; scope: AuthScope; principal?: AuthenticatedTokenPrincipal } | { ok: false; forbidden?: boolean } {
418
- if (!requiresHttpAuth(pathname, apiBase, authExempt)) {
419
- return { ok: true, scope: 'full' };
420
- }
421
- if (!tokenRegistry.hasAnyToken()) {
422
- return { ok: true, scope: 'full' };
472
+ method: string,
473
+ info: HttpRequestInfo,
474
+ ): Promise<
475
+ | { ok: true; principal?: ConsolePrincipal }
476
+ | { ok: false; status: 401 | 403; code: string; error: string }
477
+ > {
478
+ const remoteAddress = info.remoteAddress;
479
+ if (forwarded) {
480
+ // The gateway authenticated; authorization still happens here (ADR 0054 §3).
481
+ const principal = info.principal;
482
+ if (!requiresHttpAuth(pathname, apiBase, authExempt)) return { ok: true, ...(principal ? { principal } : {}) };
483
+ return authorizeApi(principal, method, pathname, remoteAddress);
423
484
  }
424
485
  const token = extractBearerToken(
425
- headerValue(request.headers.authorization),
486
+ request.headers.get('authorization') ?? undefined,
426
487
  url.searchParams.get('token'),
427
488
  );
428
- const scope = tokenRegistry.resolve(token);
429
- if (!scope) return { ok: false };
430
- if (scope === 'demo') {
431
- const method = (request.method ?? 'GET').toUpperCase();
432
- if (!isDemoHttpAllowed(method, pathname, apiBase)) return { ok: false, forbidden: true };
489
+ const meta = requestMeta(request, remoteAddress, { inProcess: info.inProcess === true });
490
+ if (!requiresHttpAuth(pathname, apiBase, authExempt)) {
491
+ // Public route: still attach a presented credential so page catalogs can filter by it.
492
+ if (token || !tokenRegistry.hasAnyToken()) {
493
+ const principal = await tokenRegistry.authenticate(token, meta);
494
+ if (principal) return { ok: true, principal };
495
+ }
496
+ return { ok: true };
497
+ }
498
+ const principal = await tokenRegistry.authenticate(token, meta) ?? undefined;
499
+ return authorizeApi(principal, method, pathname, remoteAddress);
500
+ }
501
+
502
+ function authorizeApi(
503
+ principal: ConsolePrincipal | undefined,
504
+ method: string,
505
+ pathname: string,
506
+ remoteAddress: string | undefined,
507
+ ): { ok: true; principal?: ConsolePrincipal } | { ok: false; status: 401 | 403; code: string; error: string } {
508
+ const relative = pathname === apiBase ? '/' : pathname.slice(apiBase.length);
509
+ const decision = authorizeConsoleHttp(principal, method, relative);
510
+ if (!decision.allowed) {
511
+ if (principal) audit(principal, remoteAddress, `http:${method} ${relative}`, 'denied');
512
+ return { ok: false, status: decision.status, code: decision.code, error: decision.error };
433
513
  }
434
- const principal = tokenRegistry.resolvePrincipal(token);
435
- return { ok: true, scope, ...(principal ? { principal } : {}) };
514
+ return { ok: true, ...(principal ? { principal } : {}) };
436
515
  }
437
516
 
438
517
  function listListedRoutes(): readonly ListedRoute[] {
@@ -448,7 +527,7 @@ export function createHttpHost(options: HttpHostOptions = {}): ProcessHttpHost {
448
527
  function registerWs(
449
528
  pathname: string,
450
529
  admission?: GenerationAdmissionGate,
451
- options: WsRouteOptions = {},
530
+ wsOptions: WsRouteOptions = {},
452
531
  ): WsHandle {
453
532
  const normalized = normalizePath(pathname);
454
533
  let listeners = wsRoutes.get(normalized);
@@ -459,17 +538,21 @@ export function createHttpHost(options: HttpHostOptions = {}): ProcessHttpHost {
459
538
  const owned = new Set<WsRoute>();
460
539
  return {
461
540
  onConnection(listener) {
462
- const entry = Object.freeze({ listener, admission, options: Object.freeze({ ...options }) });
541
+ const entry = Object.freeze({ listener, admission, options: Object.freeze({ ...wsOptions }) });
463
542
  listeners!.add(entry);
464
543
  owned.add(entry);
544
+ routesChanged();
465
545
  return () => {
546
+ if (!owned.delete(entry)) return;
466
547
  listeners!.delete(entry);
467
- owned.delete(entry);
548
+ routesChanged();
468
549
  };
469
550
  },
470
551
  close() {
552
+ if (owned.size === 0) return;
471
553
  for (const entry of owned) listeners!.delete(entry);
472
554
  owned.clear();
555
+ routesChanged();
473
556
  },
474
557
  };
475
558
  }
@@ -495,134 +578,117 @@ export function createHttpHost(options: HttpHostOptions = {}): ProcessHttpHost {
495
578
  admission,
496
579
  });
497
580
  httpRoutes.push(entry);
581
+ routesChanged();
498
582
  return () => {
499
583
  const index = httpRoutes.indexOf(entry);
500
- if (index >= 0) httpRoutes.splice(index, 1);
584
+ if (index < 0) return;
585
+ httpRoutes.splice(index, 1);
586
+ routesChanged();
501
587
  };
502
588
  }
503
589
 
504
- function createBoundHost(
505
- admission: GenerationAdmissionGate,
506
- ): AdmissionBoundHttpHost {
590
+ function createBoundHost(admission: GenerationAdmissionGate): AdmissionBoundHttpHost {
507
591
  const bound: AdmissionBoundHttpHost = {
508
592
  [generationAdmissionBinder]: (next) => createBoundHost(next),
509
- ws: (path, options) => registerWs(path, admission, options),
510
- route: (method, path, handler, meta) => registerRoute(
511
- method,
512
- path,
513
- handler,
514
- meta,
515
- admission,
516
- ),
593
+ ws: (path, wsOptions) => registerWs(path, admission, wsOptions),
594
+ route: (method, path, handler, meta) => registerRoute(method, path, handler, meta, admission),
517
595
  listRoutes: () => listListedRoutes(),
518
- get address() { return runtime.address; },
596
+ get address() { return readAddress(); },
519
597
  get tokenRegistry() { return tokenRegistry; },
520
598
  };
521
599
  return Object.freeze(bound);
522
600
  }
523
601
 
524
- const runtime: ProcessHttpHost = {
602
+ return {
525
603
  [generationAdmissionBinder]: (admission) => createBoundHost(admission),
526
-
527
- ws: (pathname, options) => registerWs(pathname, undefined, options),
528
-
604
+ ws: (pathname, wsOptions) => registerWs(pathname, undefined, wsOptions),
529
605
  route: (method, path, handler, meta) => registerRoute(method, path, handler, meta),
530
-
531
- listRoutes(): readonly ListedRoute[] {
532
- return listListedRoutes();
533
- },
534
-
535
- get address() {
536
- return address;
537
- },
538
-
539
- get tokenRegistry() {
540
- return tokenRegistry;
606
+ listRoutes: () => listListedRoutes(),
607
+ get address() { return readAddress(); },
608
+ get tokenRegistry() { return tokenRegistry; },
609
+ handle,
610
+ upgrade,
611
+ get routeCount() { return httpRoutes.length; },
612
+ get wsPaths() { return Object.freeze([...wsRoutes.keys()]); },
613
+ listWsRoutes() {
614
+ const listed: ListedWsRoute[] = [];
615
+ for (const [path, entries] of wsRoutes) {
616
+ const active = [...entries].filter((entry) => !entry.admission || entry.admission.active);
617
+ if (active.length === 0) continue;
618
+ const auth = active.every((entry) => entry.options.auth === 'protocol') ? 'protocol' : 'host';
619
+ listed.push(Object.freeze({ path, auth }));
620
+ }
621
+ return Object.freeze(listed);
541
622
  },
542
-
543
- listen(): Promise<HttpHostAddress> {
544
- if (closed) return Promise.reject(new Error('HTTP Host is closed'));
545
- if (listenResult) return listenResult;
546
- if (address) return Promise.resolve(address);
547
- listenResult = new Promise((resolve, reject) => {
548
- const onError = (error: Error): void => {
549
- listenResult = undefined;
550
- reject(error);
551
- };
552
- server.once('error', onError);
553
- server.listen({ host, port }, () => {
554
- server.off('error', onError);
555
- if (closed) {
556
- reject(new Error('HTTP Host closed before listen completed'));
557
- return;
558
- }
559
- const bound = server.address();
560
- const listenPort = typeof bound === 'object' && bound ? bound.port : port;
561
- const publicHost = host === '0.0.0.0' || host === '::' ? '127.0.0.1' : host;
562
- const protocol = secure ? 'https' : 'http';
563
- const originHost = publicHost.includes(':') ? `[${publicHost}]` : publicHost;
564
- address = Object.freeze({
565
- host: publicHost, port: listenPort, protocol, secure,
566
- origin: `${protocol}://${originHost}:${listenPort}`,
567
- });
568
- logger.info(
569
- `listening ${address.origin}`
570
- + ` | routes: ${httpRoutes.length}`
571
- + ` | token: ${tokenRegistry.hasAnyToken()
572
- ? `${tokenRegistry.primaryTokenPrefixForLog()}…`
573
- : '(none)'}`,
574
- );
575
- logger.debug(
576
- `listen detail | cors: ${corsOrigins.join(',')}`
577
- + ` | ws: ${[...wsRoutes.keys()].join(',') || '(none)'}`,
578
- );
579
- resolve(address);
580
- });
581
- });
582
- return listenResult;
623
+ onRoutesChanged(listener) {
624
+ changeListeners.add(listener);
625
+ return () => { changeListeners.delete(listener); };
583
626
  },
584
-
585
- close(): Promise<void> {
586
- if (closeResult) return closeResult;
627
+ apiBase,
628
+ corsOrigins,
629
+ close() {
587
630
  closed = true;
588
631
  wsRoutes.clear();
589
632
  httpRoutes.length = 0;
590
- closeResult = (async () => {
591
- // A pending listen must settle before deciding whether there is a
592
- // listener to close. Otherwise it can bind after close has resolved.
593
- await listenResult?.catch(() => undefined);
594
- if (!server.listening) {
595
- address = undefined;
596
- return;
597
- }
598
- await new Promise<void>((resolve, reject) => {
599
- // Long-lived connections (SSE, keep-alive, WS) never end on their own:
600
- // destroy tracked sockets and terminate WS clients before close(),
601
- // otherwise the close callbacks would never fire.
602
- for (const socket of sockets) socket.destroy();
603
- sockets.clear();
604
- for (const client of wss.clients) client.terminate();
605
- wss.close((wssError) => {
606
- if (wssError) {
607
- reject(wssError);
608
- return;
609
- }
610
- server.close((serverError) => {
611
- if (serverError) {
612
- reject(serverError);
613
- return;
614
- }
615
- logger.debug(formatCompact({ op: 'http_host_closed' }));
616
- address = undefined;
617
- resolve();
618
- });
619
- });
620
- });
621
- })();
622
- return closeResult;
633
+ changeListeners.clear();
623
634
  },
624
635
  };
625
- return runtime;
636
+ }
637
+
638
+ /**
639
+ * Adds host default headers (handler headers win) and, when `release` is given, keeps the
640
+ * generation lease until the body is fully delivered or cancelled.
641
+ */
642
+ function withHeaders(
643
+ response: Response,
644
+ defaults: Readonly<Record<string, string>> | undefined,
645
+ release?: () => void,
646
+ ): Response {
647
+ const entries = defaults ? Object.entries(defaults) : [];
648
+ if (entries.length === 0 && !release) return response;
649
+ const headers = new Headers(response.headers);
650
+ for (const [name, value] of entries) if (!headers.has(name)) headers.set(name, value);
651
+ let body: ReadableStream<Uint8Array> | null = response.body;
652
+ if (release) {
653
+ if (!body) release();
654
+ else body = releaseWhenDrained(body, release);
655
+ }
656
+ try {
657
+ return new Response(body as BodyInit | null, { status: response.status, statusText: response.statusText, headers });
658
+ } catch {
659
+ // Response.error() and friends cannot be re-wrapped.
660
+ return new Response(null, { status: 500 });
661
+ }
662
+ }
663
+
664
+ function releaseWhenDrained(source: ReadableStream<Uint8Array>, release: () => void): ReadableStream<Uint8Array> {
665
+ const reader = source.getReader();
666
+ let done = false;
667
+ const finish = (): void => {
668
+ if (done) return;
669
+ done = true;
670
+ release();
671
+ };
672
+ return new ReadableStream<Uint8Array>({
673
+ async pull(controller) {
674
+ try {
675
+ const next = await reader.read();
676
+ if (next.done) {
677
+ finish();
678
+ controller.close();
679
+ } else {
680
+ controller.enqueue(next.value);
681
+ }
682
+ } catch (error) {
683
+ finish();
684
+ controller.error(error);
685
+ }
686
+ },
687
+ async cancel(reason) {
688
+ finish();
689
+ await reader.cancel(reason).catch(() => undefined);
690
+ },
691
+ }, { highWaterMark: 0 });
626
692
  }
627
693
 
628
694
  function requiresHttpAuth(
@@ -646,32 +712,15 @@ function corsMatch(origin: string | undefined, allowed: readonly string[]): bool
646
712
  ));
647
713
  }
648
714
 
649
- function applyCors(
650
- response: ServerResponse,
651
- origin: string,
652
- allowed: readonly string[],
653
- ): void {
654
- response.setHeader(
655
- 'Access-Control-Allow-Origin',
656
- allowed.includes('*') ? '*' : origin,
657
- );
658
- response.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
659
- response.setHeader('Access-Control-Allow-Headers', 'Authorization, Content-Type, Accept');
660
- response.setHeader('Access-Control-Max-Age', '86400');
661
- }
662
-
663
- function writeJson(response: ServerResponse, status: number, body: unknown): void {
664
- const payload = JSON.stringify(body);
665
- response.writeHead(status, {
666
- 'content-type': 'application/json; charset=utf-8',
667
- 'content-length': Buffer.byteLength(payload),
668
- });
669
- response.end(payload);
670
- }
671
-
672
- function headerValue(value: string | string[] | undefined): string | undefined {
673
- if (Array.isArray(value)) return value[0];
674
- return value;
715
+ function corsHeaders(origin: string, allowed: readonly string[]): Record<string, string> {
716
+ return {
717
+ 'access-control-allow-origin': allowed.includes('*') ? '*' : origin,
718
+ 'access-control-allow-methods': 'GET, POST, PUT, DELETE, OPTIONS',
719
+ 'access-control-allow-headers': 'Authorization, Content-Type, Accept',
720
+ 'access-control-max-age': '86400',
721
+ // Browsers hide non-safelisted response headers unless exposed (503 instance-unavailable).
722
+ 'access-control-expose-headers': 'Retry-After',
723
+ };
675
724
  }
676
725
 
677
726
  function matchHttpRoute(routes: readonly HttpRoute[], method: string, pathname: string): HttpRoute | undefined {
@@ -698,7 +747,12 @@ function matchHttpRoute(routes: readonly HttpRoute[], method: string, pathname:
698
747
  return best;
699
748
  }
700
749
 
701
- function normalizePath(pathname: string): string {
750
+ function prefixRemainder(prefix: string, pathname: string): string {
751
+ if (prefix === '/') return pathname.slice(1);
752
+ return pathname.length > prefix.length ? pathname.slice(prefix.length + 1) : '';
753
+ }
754
+
755
+ export function normalizePath(pathname: string): string {
702
756
  if (!pathname.startsWith('/')) return `/${pathname}`;
703
757
  // 线性裁剪尾部斜杠(等价于 /\/+$/u,但避免长串 `/` 无匹配时的
704
758
  // 二次方回溯 — js/polynomial-redos)。
@@ -706,11 +760,3 @@ function normalizePath(pathname: string): string {
706
760
  while (end > 0 && pathname[end - 1] === '/') end -= 1;
707
761
  return pathname.slice(0, end) || '/';
708
762
  }
709
-
710
- function upgradePath(url: string | undefined): string {
711
- try {
712
- return normalizePath(new URL(url ?? '/', 'http://localhost').pathname);
713
- } catch {
714
- return '/';
715
- }
716
- }