@zhin.js/host-http 1.1.16 → 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.
- package/README.md +60 -21
- package/lib/access-control.d.ts +127 -0
- package/lib/access-control.js +148 -0
- package/lib/addresses.d.ts +9 -0
- package/lib/addresses.js +9 -0
- package/lib/bridge/bridged-host.d.ts +43 -0
- package/lib/bridge/bridged-host.js +497 -0
- package/lib/bridge/control-channel.d.ts +59 -0
- package/lib/bridge/control-channel.js +131 -0
- package/lib/bridge/flow.d.ts +58 -0
- package/lib/bridge/flow.js +188 -0
- package/lib/bridge/index.d.ts +6 -0
- package/lib/bridge/index.js +6 -0
- package/lib/bridge/instance-transport.d.ts +112 -0
- package/lib/bridge/instance-transport.js +532 -0
- package/lib/bridge/protocol.d.ts +242 -0
- package/lib/bridge/protocol.js +369 -0
- package/lib/console-events.d.ts +5 -3
- package/lib/console-events.js +53 -27
- package/lib/console-plugin-rpc.d.ts +5 -1
- package/lib/console-plugin-rpc.js +5 -40
- package/lib/console-rest-pages-helpers.d.ts +4 -5
- package/lib/console-rest-pages-helpers.js +62 -79
- package/lib/console-rest-pages.js +80 -65
- package/lib/console-rpc-extended/dispatcher.js +1 -6
- package/lib/console-rpc-handlers.d.ts +0 -2
- package/lib/console-rpc-handlers.js +2 -40
- package/lib/console-rpc.d.ts +6 -14
- package/lib/console-rpc.js +6 -6
- package/lib/host-installer.d.ts +6 -3
- package/lib/host-installer.js +10 -40
- package/lib/host-options.d.ts +47 -0
- package/lib/host-options.js +1 -0
- package/lib/http-host.d.ts +124 -48
- package/lib/http-host.js +319 -332
- package/lib/index.d.ts +10 -5
- package/lib/index.js +9 -5
- package/lib/json-body.d.ts +1 -2
- package/lib/json-body.js +1 -2
- package/lib/marketplace-registry.d.ts +17 -0
- package/lib/marketplace-registry.js +52 -0
- package/lib/node/index.d.ts +10 -0
- package/lib/node/index.js +10 -0
- package/lib/{listener-group.d.ts → node/listener-group.d.ts} +2 -3
- package/lib/{listener-group.js → node/listener-group.js} +6 -10
- package/lib/node/listener.d.ts +26 -0
- package/lib/node/listener.js +231 -0
- package/lib/node/node-http.d.ts +38 -0
- package/lib/node/node-http.js +272 -0
- package/lib/node/server.d.ts +13 -0
- package/lib/node/server.js +64 -0
- package/lib/protocol-auth.d.ts +24 -0
- package/lib/protocol-auth.js +48 -0
- package/lib/public-api.d.ts +1 -1
- package/lib/token-registry.d.ts +54 -36
- package/lib/token-registry.js +133 -126
- package/lib/trust-proxy.d.ts +22 -0
- package/lib/trust-proxy.js +168 -0
- package/package.json +14 -6
- package/src/access-control.ts +255 -0
- package/src/addresses.ts +16 -0
- package/src/bridge/bridged-host.ts +548 -0
- package/src/bridge/control-channel.ts +173 -0
- package/src/bridge/flow.ts +208 -0
- package/src/bridge/index.ts +31 -0
- package/src/bridge/instance-transport.ts +627 -0
- package/src/bridge/protocol.ts +574 -0
- package/src/console-events.ts +59 -28
- package/src/console-plugin-rpc.ts +5 -51
- package/src/console-rest-pages-helpers.ts +60 -82
- package/src/console-rest-pages.ts +87 -67
- package/src/console-rpc-extended/dispatcher.ts +1 -6
- package/src/console-rpc-handlers.ts +2 -31
- package/src/console-rpc.ts +10 -19
- package/src/host-installer.ts +12 -44
- package/src/host-options.ts +47 -0
- package/src/http-host.ts +467 -421
- package/src/index.ts +55 -13
- package/src/json-body.ts +7 -2
- package/src/marketplace-registry.ts +62 -0
- package/src/node/index.ts +21 -0
- package/src/{listener-group.ts → node/listener-group.ts} +13 -24
- package/src/node/listener.ts +265 -0
- package/src/node/node-http.ts +263 -0
- package/src/node/server.ts +82 -0
- package/src/protocol-auth.ts +52 -0
- package/src/public-api.ts +2 -0
- package/src/token-registry.ts +143 -146
- package/src/trust-proxy.ts +178 -0
package/src/http-host.ts
CHANGED
|
@@ -1,16 +1,12 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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 {
|
|
36
|
-
|
|
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
|
|
45
|
-
readonly
|
|
46
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
71
|
-
|
|
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
|
|
80
|
-
|
|
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
|
-
/**
|
|
96
|
-
readonly
|
|
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
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
|
108
|
-
|
|
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
|
-
/**
|
|
112
|
-
export interface
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
/**
|
|
127
|
-
export interface
|
|
128
|
-
|
|
129
|
-
close():
|
|
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
|
-
|
|
151
|
-
export const httpHostToken = createToken<HttpHost>('zhin.host.http');
|
|
229
|
+
const NO_PARAMS: Readonly<Record<string, string>> = Object.freeze({});
|
|
152
230
|
|
|
153
|
-
|
|
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
|
-
|
|
247
|
+
const changeListeners = new Set<() => void>();
|
|
171
248
|
let closed = false;
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
|
|
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
|
-
|
|
244
|
-
return;
|
|
339
|
+
return Object.freeze({ accepted: false, status: 404 });
|
|
245
340
|
}
|
|
246
|
-
|
|
247
|
-
const
|
|
248
|
-
|
|
249
|
-
|
|
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
|
-
|
|
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
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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
|
-
|
|
261
|
-
|
|
262
|
-
|
|
365
|
+
let finished = false;
|
|
366
|
+
transport.onClose(() => {
|
|
367
|
+
if (finished) return;
|
|
368
|
+
finished = true;
|
|
263
369
|
for (const unsubscribe of stopSubscriptions) unsubscribe();
|
|
264
|
-
|
|
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 (
|
|
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
|
-
|
|
281
|
-
request,
|
|
282
|
-
|
|
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)
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
}
|
|
296
|
-
|
|
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
|
|
323
|
-
if (closed) {
|
|
324
|
-
|
|
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
|
|
414
|
+
url = new URL(request.url);
|
|
332
415
|
} catch {
|
|
333
|
-
|
|
334
|
-
response.end();
|
|
335
|
-
return;
|
|
416
|
+
return new Response(null, { status: 400 });
|
|
336
417
|
}
|
|
337
418
|
const pathname = normalizePath(url.pathname);
|
|
338
|
-
const origin =
|
|
339
|
-
const
|
|
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
|
-
|
|
351
|
-
writeJson(response, auth.forbidden ? 403 : 401, {
|
|
426
|
+
return withHeaders(jsonResponse(auth.status, {
|
|
352
427
|
success: false,
|
|
353
|
-
code: auth.
|
|
428
|
+
code: auth.code,
|
|
354
429
|
requestOutcome: 'not-dispatched',
|
|
355
|
-
error: auth.
|
|
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
|
-
|
|
363
|
-
|
|
364
|
-
|
|
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
|
-
|
|
369
|
-
response
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
468
|
+
async function authenticateHttp(
|
|
469
|
+
request: Request,
|
|
415
470
|
url: URL,
|
|
416
471
|
pathname: string,
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
|
|
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
|
-
|
|
486
|
+
request.headers.get('authorization') ?? undefined,
|
|
426
487
|
url.searchParams.get('token'),
|
|
427
488
|
);
|
|
428
|
-
const
|
|
429
|
-
if (!
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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({ ...
|
|
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
|
-
|
|
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
|
|
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,
|
|
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
|
|
596
|
+
get address() { return readAddress(); },
|
|
519
597
|
get tokenRegistry() { return tokenRegistry; },
|
|
520
598
|
};
|
|
521
599
|
return Object.freeze(bound);
|
|
522
600
|
}
|
|
523
601
|
|
|
524
|
-
|
|
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
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
get
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
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
|
-
|
|
544
|
-
|
|
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
|
-
|
|
586
|
-
|
|
627
|
+
apiBase,
|
|
628
|
+
corsOrigins,
|
|
629
|
+
close() {
|
|
587
630
|
closed = true;
|
|
588
631
|
wsRoutes.clear();
|
|
589
632
|
httpRoutes.length = 0;
|
|
590
|
-
|
|
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
|
-
|
|
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
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
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
|
|
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
|
-
}
|