@zhin.js/host-http 1.1.17 → 1.2.1
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 +12 -1
- package/lib/console-plugin-rpc.js +16 -66
- 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 -18
- 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 +11 -6
- package/lib/index.js +10 -6
- 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 +17 -77
- 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 -24
- 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 +56 -14
- 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/lib/http-host.js
CHANGED
|
@@ -1,91 +1,113 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
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
|
+
*/
|
|
4
10
|
import { formatCompact, getLogger } from '@zhin.js/logger';
|
|
5
11
|
import { createToken, generationAdmissionBinder, } from '@zhin.js/plugin-runtime';
|
|
6
|
-
import {
|
|
12
|
+
import { HttpBodyError, jsonResponse } from '@zhin.js/host-http-contract';
|
|
13
|
+
import { TokenRegistry, extractBearerToken, } from './token-registry.js';
|
|
7
14
|
import { buildOpenApiDocument, } from './openapi.js';
|
|
8
|
-
import {
|
|
9
|
-
import { HttpBodyError } from './json-body.js';
|
|
15
|
+
import { auditActor, authorizeConsoleHttp, authorizeConsoleWebSocket, requestMeta, } from './access-control.js';
|
|
10
16
|
const logger = getLogger('http');
|
|
11
17
|
export const REMOTE_CONSOLE_ORIGIN = 'https://console.zhin.dev';
|
|
12
18
|
const DEFAULT_CORS_ORIGINS = Object.freeze([REMOTE_CONSOLE_ORIGIN]);
|
|
13
19
|
/** @public Stable Plugin Runtime HTTP Host token. */
|
|
14
20
|
export const httpHostToken = createToken('zhin.host.http');
|
|
15
|
-
|
|
16
|
-
export function
|
|
17
|
-
const host = options.host ?? '127.0.0.1';
|
|
18
|
-
const port = options.port ?? 8086;
|
|
21
|
+
const NO_PARAMS = Object.freeze({});
|
|
22
|
+
export function createHttpRouter(options = {}) {
|
|
19
23
|
const apiBase = normalizePath(options.apiBase ?? '/api');
|
|
20
24
|
const corsOrigins = Object.freeze([
|
|
21
25
|
...new Set([...DEFAULT_CORS_ORIGINS, ...(options.corsOrigins ?? [])]),
|
|
22
26
|
]);
|
|
23
27
|
const authExempt = Object.freeze((options.authExemptPaths ?? []).map((path) => normalizePath(path)));
|
|
24
|
-
const tokenRegistry = new TokenRegistry({
|
|
28
|
+
const tokenRegistry = options.tokenRegistry ?? new TokenRegistry({
|
|
25
29
|
primaryToken: options.token,
|
|
26
|
-
scopedTokens: options.tokens,
|
|
27
30
|
});
|
|
31
|
+
const secure = options.secure === true;
|
|
32
|
+
const readAddress = options.address ?? (() => undefined);
|
|
33
|
+
const forwarded = options.authentication === 'forwarded';
|
|
28
34
|
const wsRoutes = new Map();
|
|
29
35
|
const httpRoutes = [];
|
|
30
|
-
|
|
36
|
+
const changeListeners = new Set();
|
|
31
37
|
let closed = false;
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
38
|
+
const routesChanged = () => {
|
|
39
|
+
for (const listener of [...changeListeners]) {
|
|
40
|
+
try {
|
|
41
|
+
listener();
|
|
42
|
+
}
|
|
43
|
+
catch { /* observers never break registration */ }
|
|
44
|
+
}
|
|
37
45
|
};
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
});
|
|
50
|
-
const wss = new WebSocketServer({ noServer: true });
|
|
51
|
-
// Built-in public health probe (parity with host-router `/pub/health`).
|
|
52
|
-
httpRoutes.push(Object.freeze({
|
|
53
|
-
method: 'GET',
|
|
54
|
-
path: '/pub/health',
|
|
55
|
-
pattern: '/pub/health',
|
|
56
|
-
prefix: false,
|
|
57
|
-
meta: Object.freeze({ summary: 'Health probe', tags: ['pub'] }),
|
|
58
|
-
handler: (_request, response) => {
|
|
59
|
-
writeJson(response, 200, {
|
|
46
|
+
if (options.builtinRoutes !== false)
|
|
47
|
+
registerBuiltinRoutes();
|
|
48
|
+
function registerBuiltinRoutes() {
|
|
49
|
+
// Built-in public health probe (parity with host-router `/pub/health`).
|
|
50
|
+
httpRoutes.push(Object.freeze({
|
|
51
|
+
method: 'GET',
|
|
52
|
+
path: '/pub/health',
|
|
53
|
+
pattern: '/pub/health',
|
|
54
|
+
prefix: false,
|
|
55
|
+
meta: Object.freeze({ summary: 'Health probe', tags: ['pub'] }),
|
|
56
|
+
handler: () => jsonResponse(200, {
|
|
60
57
|
success: true,
|
|
61
58
|
status: 'ok',
|
|
62
59
|
timestamp: new Date().toISOString(),
|
|
63
|
-
})
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
60
|
+
}),
|
|
61
|
+
}));
|
|
62
|
+
// OpenAPI catalog (parity with host-router `/pub/openapi.json`).
|
|
63
|
+
httpRoutes.push(Object.freeze({
|
|
64
|
+
method: 'GET',
|
|
65
|
+
path: '/pub/openapi.json',
|
|
66
|
+
pattern: '/pub/openapi.json',
|
|
67
|
+
prefix: false,
|
|
68
|
+
meta: Object.freeze({ summary: 'OpenAPI 3.1 document', tags: ['pub'] }),
|
|
69
|
+
handler: (request) => {
|
|
70
|
+
const hostHeader = request.headers.get('host') ?? 'localhost';
|
|
71
|
+
const proto = request.headers.get('x-forwarded-proto') ?? (secure ? 'https' : 'http');
|
|
72
|
+
return jsonResponse(200, buildOpenApiDocument(listListedRoutes(), {
|
|
73
|
+
apiBase,
|
|
74
|
+
serverUrl: `${proto}://${hostHeader}`,
|
|
75
|
+
version: process.env.npm_package_version ?? '0.0.0',
|
|
76
|
+
}));
|
|
77
|
+
},
|
|
78
|
+
}));
|
|
79
|
+
}
|
|
80
|
+
async function authenticateUpgrade(request, pathname) {
|
|
81
|
+
const { url, headers, remoteAddress } = request;
|
|
82
|
+
const principal = forwarded
|
|
83
|
+
? request.principal
|
|
84
|
+
: await tokenRegistry.authenticate(extractBearerToken(headers.get('authorization') ?? undefined, url.searchParams.get('token')), requestMeta({ headers }, remoteAddress));
|
|
85
|
+
if (!principal)
|
|
86
|
+
return undefined;
|
|
87
|
+
if (!authorizeConsoleWebSocket(principal, pathname)) {
|
|
88
|
+
audit(principal, remoteAddress, `ws:${pathname}`, 'denied');
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
91
|
+
return principal;
|
|
92
|
+
}
|
|
93
|
+
function audit(principal, remoteAddress, action, outcome) {
|
|
94
|
+
const sink = tokenRegistry.accessProvider?.audit;
|
|
95
|
+
if (!sink)
|
|
86
96
|
return;
|
|
97
|
+
try {
|
|
98
|
+
sink.call(tokenRegistry.accessProvider, {
|
|
99
|
+
actor: auditActor(principal),
|
|
100
|
+
action,
|
|
101
|
+
outcome,
|
|
102
|
+
...(remoteAddress ? { ip: remoteAddress } : {}),
|
|
103
|
+
});
|
|
87
104
|
}
|
|
88
|
-
|
|
105
|
+
catch { /* audit never breaks a request */ }
|
|
106
|
+
}
|
|
107
|
+
async function upgrade(request) {
|
|
108
|
+
if (closed)
|
|
109
|
+
return Object.freeze({ accepted: false, status: 404 });
|
|
110
|
+
const pathname = normalizePath(request.url.pathname);
|
|
89
111
|
const listeners = wsRoutes.get(pathname);
|
|
90
112
|
const admitted = listeners ? [...listeners].flatMap((entry) => {
|
|
91
113
|
const release = entry.admission?.acquire();
|
|
@@ -95,42 +117,50 @@ export function createHttpHost(options = {}) {
|
|
|
95
117
|
}) : [];
|
|
96
118
|
if (admitted.length === 0) {
|
|
97
119
|
logger.debug(formatCompact({ op: 'ws_upgrade_unmatched', path: pathname }));
|
|
98
|
-
|
|
99
|
-
return;
|
|
120
|
+
return Object.freeze({ accepted: false, status: 404 });
|
|
100
121
|
}
|
|
101
|
-
|
|
102
|
-
const
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
|
|
107
|
-
socket.destroy();
|
|
122
|
+
let settled = false;
|
|
123
|
+
const releaseAll = () => {
|
|
124
|
+
if (settled)
|
|
125
|
+
return;
|
|
126
|
+
settled = true;
|
|
108
127
|
for (const item of admitted)
|
|
109
128
|
item.release?.();
|
|
110
|
-
|
|
129
|
+
};
|
|
130
|
+
const protocolAuthenticated = admitted.every(({ entry }) => entry.options.auth === 'protocol');
|
|
131
|
+
let principal;
|
|
132
|
+
if (!protocolAuthenticated) {
|
|
133
|
+
principal = await authenticateUpgrade(request, pathname).catch(() => undefined);
|
|
134
|
+
if (!principal || closed) {
|
|
135
|
+
releaseAll();
|
|
136
|
+
return Object.freeze({ accepted: false, status: 401 });
|
|
137
|
+
}
|
|
111
138
|
}
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
139
|
+
return Object.freeze({
|
|
140
|
+
accepted: true,
|
|
141
|
+
reject: releaseAll,
|
|
142
|
+
accept(transport) {
|
|
143
|
+
if (settled) {
|
|
144
|
+
transport.close(1012, 'Root stopped');
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
115
147
|
const stopSubscriptions = [];
|
|
116
|
-
|
|
117
|
-
|
|
148
|
+
let finished = false;
|
|
149
|
+
transport.onClose(() => {
|
|
150
|
+
if (finished)
|
|
118
151
|
return;
|
|
119
|
-
|
|
152
|
+
finished = true;
|
|
120
153
|
for (const unsubscribe of stopSubscriptions)
|
|
121
154
|
unsubscribe();
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
};
|
|
125
|
-
ws.once('close', releaseAdmissions);
|
|
126
|
-
ws.once('error', releaseAdmissions);
|
|
155
|
+
releaseAll();
|
|
156
|
+
});
|
|
127
157
|
// Replacement closes new upgrades, not accepted plugin work. Explicit Root stop
|
|
128
158
|
// still closes its sockets before waiting for their admission leases to drain.
|
|
129
159
|
const stops = new Set(admitted.map(item => item.entry.admission?.stopSignal).filter(signal => signal !== undefined));
|
|
130
160
|
for (const signal of stops) {
|
|
131
161
|
const stop = () => {
|
|
132
|
-
if (
|
|
133
|
-
|
|
162
|
+
if (!finished)
|
|
163
|
+
transport.close(1012, 'Root stopped');
|
|
134
164
|
};
|
|
135
165
|
signal.addEventListener('abort', stop, { once: true });
|
|
136
166
|
stopSubscriptions.push(() => signal.removeEventListener('abort', stop));
|
|
@@ -138,161 +168,124 @@ export function createHttpHost(options = {}) {
|
|
|
138
168
|
stop();
|
|
139
169
|
}
|
|
140
170
|
const connection = Object.freeze({
|
|
141
|
-
|
|
142
|
-
request,
|
|
143
|
-
|
|
171
|
+
url: request.url,
|
|
172
|
+
headers: request.headers,
|
|
173
|
+
...(principal ? { principal } : {}),
|
|
174
|
+
...(request.remoteAddress ? { remoteAddress: request.remoteAddress } : {}),
|
|
175
|
+
send: (data) => transport.send(data),
|
|
176
|
+
close: (code, reason) => transport.close(code, reason),
|
|
177
|
+
onMessage: (listener) => transport.onMessage(listener),
|
|
178
|
+
onClose: (listener) => transport.onClose(listener),
|
|
179
|
+
get bufferedAmount() { return transport.bufferedAmount; },
|
|
144
180
|
});
|
|
145
|
-
for (const item of admitted)
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
}
|
|
160
|
-
catch {
|
|
161
|
-
/* already destroyed */
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
});
|
|
165
|
-
function authenticateUpgrade(request, pathname) {
|
|
166
|
-
if (!tokenRegistry.hasAnyToken())
|
|
167
|
-
return { ok: true, scope: 'full' };
|
|
168
|
-
let url;
|
|
169
|
-
try {
|
|
170
|
-
url = new URL(request.url ?? '/', 'http://localhost');
|
|
171
|
-
}
|
|
172
|
-
catch {
|
|
173
|
-
return { ok: false };
|
|
174
|
-
}
|
|
175
|
-
const token = extractBearerToken(headerValue(request.headers.authorization), url.searchParams.get('token'));
|
|
176
|
-
const scope = tokenRegistry.resolve(token);
|
|
177
|
-
if (!scope)
|
|
178
|
-
return { ok: false };
|
|
179
|
-
if (scope === 'demo' && !isDemoWebSocketPath(pathname))
|
|
180
|
-
return { ok: false };
|
|
181
|
-
return { ok: true, scope };
|
|
181
|
+
for (const item of admitted) {
|
|
182
|
+
try {
|
|
183
|
+
item.entry.listener(connection);
|
|
184
|
+
}
|
|
185
|
+
catch (error) {
|
|
186
|
+
logger.warn(formatCompact({
|
|
187
|
+
op: 'ws_listener_failed',
|
|
188
|
+
path: pathname,
|
|
189
|
+
error: error instanceof Error ? error.message : String(error),
|
|
190
|
+
}));
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
},
|
|
194
|
+
});
|
|
182
195
|
}
|
|
183
|
-
async function
|
|
184
|
-
if (closed)
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
return;
|
|
188
|
-
}
|
|
189
|
-
const method = (request.method ?? 'GET').toUpperCase();
|
|
196
|
+
async function handle(request, info = {}) {
|
|
197
|
+
if (closed)
|
|
198
|
+
return new Response(null, { status: 503 });
|
|
199
|
+
const method = request.method.toUpperCase();
|
|
190
200
|
let url;
|
|
191
201
|
try {
|
|
192
|
-
url = new URL(request.url
|
|
202
|
+
url = new URL(request.url);
|
|
193
203
|
}
|
|
194
204
|
catch {
|
|
195
|
-
|
|
196
|
-
response.end();
|
|
197
|
-
return;
|
|
205
|
+
return new Response(null, { status: 400 });
|
|
198
206
|
}
|
|
199
207
|
const pathname = normalizePath(url.pathname);
|
|
200
|
-
const origin =
|
|
201
|
-
const
|
|
202
|
-
if (method === 'OPTIONS')
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
response.writeHead(204);
|
|
206
|
-
response.end();
|
|
207
|
-
return;
|
|
208
|
-
}
|
|
209
|
-
const auth = authenticateHttp(request, url, pathname);
|
|
208
|
+
const origin = request.headers.get('origin') ?? undefined;
|
|
209
|
+
const cors = origin && corsMatch(origin, corsOrigins) ? corsHeaders(origin, corsOrigins) : undefined;
|
|
210
|
+
if (method === 'OPTIONS')
|
|
211
|
+
return withHeaders(new Response(null, { status: 204 }), cors);
|
|
212
|
+
const auth = await authenticateHttp(request, url, pathname, method, info);
|
|
210
213
|
if (!auth.ok) {
|
|
211
|
-
|
|
212
|
-
applyCors(response, origin, corsOrigins);
|
|
213
|
-
writeJson(response, auth.forbidden ? 403 : 401, {
|
|
214
|
+
return withHeaders(jsonResponse(auth.status, {
|
|
214
215
|
success: false,
|
|
215
|
-
code: auth.
|
|
216
|
+
code: auth.code,
|
|
216
217
|
requestOutcome: 'not-dispatched',
|
|
217
|
-
error: auth.
|
|
218
|
-
});
|
|
219
|
-
return;
|
|
218
|
+
error: auth.error,
|
|
219
|
+
}), cors);
|
|
220
220
|
}
|
|
221
221
|
const route = matchHttpRoute(httpRoutes, method, pathname);
|
|
222
|
-
if (!route)
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
return;
|
|
222
|
+
if (!route)
|
|
223
|
+
return withHeaders(new Response(null, { status: 404 }), cors);
|
|
224
|
+
const defaults = { ...cors, 'x-content-type-options': 'nosniff', 'x-frame-options': 'SAMEORIGIN' };
|
|
225
|
+
const release = route.admission?.acquire();
|
|
226
|
+
if (route.admission && !release) {
|
|
227
|
+
return withHeaders(jsonResponse(503, { success: false, code: 'unavailable', requestOutcome: 'not-dispatched' }), defaults);
|
|
228
228
|
}
|
|
229
|
+
const context = Object.freeze({
|
|
230
|
+
url,
|
|
231
|
+
params: route.prefix ? Object.freeze({ '*': prefixRemainder(route.path, pathname) }) : NO_PARAMS,
|
|
232
|
+
...(auth.principal ? { principal: auth.principal } : {}),
|
|
233
|
+
...(info.remoteAddress ? { remoteAddress: info.remoteAddress } : {}),
|
|
234
|
+
});
|
|
235
|
+
let response;
|
|
229
236
|
try {
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
response.setHeader('X-Frame-Options', 'SAMEORIGIN');
|
|
234
|
-
const release = route.admission?.acquire();
|
|
235
|
-
if (route.admission && !release) {
|
|
236
|
-
writeJson(response, 503, { success: false, code: 'unavailable', requestOutcome: 'not-dispatched' });
|
|
237
|
-
return;
|
|
238
|
-
}
|
|
239
|
-
if (!release) {
|
|
240
|
-
await route.handler(request, response, url, auth.scope, auth.principal);
|
|
241
|
-
return;
|
|
242
|
-
}
|
|
243
|
-
let released = false;
|
|
244
|
-
const releaseResponse = () => {
|
|
245
|
-
if (released)
|
|
246
|
-
return;
|
|
247
|
-
released = true;
|
|
248
|
-
response.off('finish', releaseResponse);
|
|
249
|
-
response.off('close', releaseResponse);
|
|
250
|
-
release();
|
|
251
|
-
};
|
|
252
|
-
response.once('finish', releaseResponse);
|
|
253
|
-
response.once('close', releaseResponse);
|
|
254
|
-
await route.handler(request, response, url, auth.scope, auth.principal);
|
|
255
|
-
if (response.writableFinished || response.destroyed)
|
|
256
|
-
releaseResponse();
|
|
237
|
+
response = await route.handler(request, context);
|
|
238
|
+
if (!(response instanceof Response))
|
|
239
|
+
throw new TypeError('HTTP route handler did not return a Response');
|
|
257
240
|
}
|
|
258
241
|
catch (err) {
|
|
242
|
+
release?.();
|
|
259
243
|
// 统一把请求体错误(400/413)映射回对应状态码,
|
|
260
244
|
// 避免个别端点未捕获时退化成 500 空响应。
|
|
261
245
|
if (err instanceof HttpBodyError) {
|
|
262
|
-
|
|
263
|
-
writeJson(response, err.statusCode, { success: false, error: err.message });
|
|
264
|
-
}
|
|
265
|
-
return;
|
|
246
|
+
return withHeaders(jsonResponse(err.statusCode, { success: false, error: err.message }), defaults);
|
|
266
247
|
}
|
|
267
248
|
logger.warn(formatCompact({
|
|
268
249
|
op: 'http_handler_failed',
|
|
269
250
|
path: pathname,
|
|
270
251
|
error: err instanceof Error ? err.message : String(err),
|
|
271
252
|
}));
|
|
272
|
-
|
|
273
|
-
response.writeHead(500);
|
|
274
|
-
response.end();
|
|
275
|
-
}
|
|
253
|
+
return withHeaders(new Response(null, { status: 500 }), defaults);
|
|
276
254
|
}
|
|
255
|
+
return withHeaders(response, defaults, release);
|
|
277
256
|
}
|
|
278
|
-
function authenticateHttp(request, url, pathname) {
|
|
279
|
-
|
|
280
|
-
|
|
257
|
+
async function authenticateHttp(request, url, pathname, method, info) {
|
|
258
|
+
const remoteAddress = info.remoteAddress;
|
|
259
|
+
if (forwarded) {
|
|
260
|
+
// The gateway authenticated; authorization still happens here (ADR 0054 §3).
|
|
261
|
+
const principal = info.principal;
|
|
262
|
+
if (!requiresHttpAuth(pathname, apiBase, authExempt))
|
|
263
|
+
return { ok: true, ...(principal ? { principal } : {}) };
|
|
264
|
+
return authorizeApi(principal, method, pathname, remoteAddress);
|
|
281
265
|
}
|
|
282
|
-
|
|
283
|
-
|
|
266
|
+
const token = extractBearerToken(request.headers.get('authorization') ?? undefined, url.searchParams.get('token'));
|
|
267
|
+
const meta = requestMeta(request, remoteAddress, { inProcess: info.inProcess === true });
|
|
268
|
+
if (!requiresHttpAuth(pathname, apiBase, authExempt)) {
|
|
269
|
+
// Public route: still attach a presented credential so page catalogs can filter by it.
|
|
270
|
+
if (token || !tokenRegistry.hasAnyToken()) {
|
|
271
|
+
const principal = await tokenRegistry.authenticate(token, meta);
|
|
272
|
+
if (principal)
|
|
273
|
+
return { ok: true, principal };
|
|
274
|
+
}
|
|
275
|
+
return { ok: true };
|
|
284
276
|
}
|
|
285
|
-
const
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
277
|
+
const principal = await tokenRegistry.authenticate(token, meta) ?? undefined;
|
|
278
|
+
return authorizeApi(principal, method, pathname, remoteAddress);
|
|
279
|
+
}
|
|
280
|
+
function authorizeApi(principal, method, pathname, remoteAddress) {
|
|
281
|
+
const relative = pathname === apiBase ? '/' : pathname.slice(apiBase.length);
|
|
282
|
+
const decision = authorizeConsoleHttp(principal, method, relative);
|
|
283
|
+
if (!decision.allowed) {
|
|
284
|
+
if (principal)
|
|
285
|
+
audit(principal, remoteAddress, `http:${method} ${relative}`, 'denied');
|
|
286
|
+
return { ok: false, status: decision.status, code: decision.code, error: decision.error };
|
|
293
287
|
}
|
|
294
|
-
|
|
295
|
-
return { ok: true, scope, ...(principal ? { principal } : {}) };
|
|
288
|
+
return { ok: true, ...(principal ? { principal } : {}) };
|
|
296
289
|
}
|
|
297
290
|
function listListedRoutes() {
|
|
298
291
|
return Object.freeze(httpRoutes
|
|
@@ -303,7 +296,7 @@ export function createHttpHost(options = {}) {
|
|
|
303
296
|
meta: route.meta,
|
|
304
297
|
})));
|
|
305
298
|
}
|
|
306
|
-
function registerWs(pathname, admission,
|
|
299
|
+
function registerWs(pathname, admission, wsOptions = {}) {
|
|
307
300
|
const normalized = normalizePath(pathname);
|
|
308
301
|
let listeners = wsRoutes.get(normalized);
|
|
309
302
|
if (!listeners) {
|
|
@@ -313,18 +306,24 @@ export function createHttpHost(options = {}) {
|
|
|
313
306
|
const owned = new Set();
|
|
314
307
|
return {
|
|
315
308
|
onConnection(listener) {
|
|
316
|
-
const entry = Object.freeze({ listener, admission, options: Object.freeze({ ...
|
|
309
|
+
const entry = Object.freeze({ listener, admission, options: Object.freeze({ ...wsOptions }) });
|
|
317
310
|
listeners.add(entry);
|
|
318
311
|
owned.add(entry);
|
|
312
|
+
routesChanged();
|
|
319
313
|
return () => {
|
|
314
|
+
if (!owned.delete(entry))
|
|
315
|
+
return;
|
|
320
316
|
listeners.delete(entry);
|
|
321
|
-
|
|
317
|
+
routesChanged();
|
|
322
318
|
};
|
|
323
319
|
},
|
|
324
320
|
close() {
|
|
321
|
+
if (owned.size === 0)
|
|
322
|
+
return;
|
|
325
323
|
for (const entry of owned)
|
|
326
324
|
listeners.delete(entry);
|
|
327
325
|
owned.clear();
|
|
326
|
+
routesChanged();
|
|
328
327
|
},
|
|
329
328
|
};
|
|
330
329
|
}
|
|
@@ -343,120 +342,120 @@ export function createHttpHost(options = {}) {
|
|
|
343
342
|
admission,
|
|
344
343
|
});
|
|
345
344
|
httpRoutes.push(entry);
|
|
345
|
+
routesChanged();
|
|
346
346
|
return () => {
|
|
347
347
|
const index = httpRoutes.indexOf(entry);
|
|
348
|
-
if (index
|
|
349
|
-
|
|
348
|
+
if (index < 0)
|
|
349
|
+
return;
|
|
350
|
+
httpRoutes.splice(index, 1);
|
|
351
|
+
routesChanged();
|
|
350
352
|
};
|
|
351
353
|
}
|
|
352
354
|
function createBoundHost(admission) {
|
|
353
355
|
const bound = {
|
|
354
356
|
[generationAdmissionBinder]: (next) => createBoundHost(next),
|
|
355
|
-
ws: (path,
|
|
357
|
+
ws: (path, wsOptions) => registerWs(path, admission, wsOptions),
|
|
356
358
|
route: (method, path, handler, meta) => registerRoute(method, path, handler, meta, admission),
|
|
357
359
|
listRoutes: () => listListedRoutes(),
|
|
358
|
-
get address() { return
|
|
360
|
+
get address() { return readAddress(); },
|
|
359
361
|
get tokenRegistry() { return tokenRegistry; },
|
|
360
362
|
};
|
|
361
363
|
return Object.freeze(bound);
|
|
362
364
|
}
|
|
363
|
-
|
|
365
|
+
return {
|
|
364
366
|
[generationAdmissionBinder]: (admission) => createBoundHost(admission),
|
|
365
|
-
ws: (pathname,
|
|
367
|
+
ws: (pathname, wsOptions) => registerWs(pathname, undefined, wsOptions),
|
|
366
368
|
route: (method, path, handler, meta) => registerRoute(method, path, handler, meta),
|
|
367
|
-
listRoutes()
|
|
368
|
-
|
|
369
|
-
},
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
},
|
|
373
|
-
get
|
|
374
|
-
|
|
369
|
+
listRoutes: () => listListedRoutes(),
|
|
370
|
+
get address() { return readAddress(); },
|
|
371
|
+
get tokenRegistry() { return tokenRegistry; },
|
|
372
|
+
handle,
|
|
373
|
+
upgrade,
|
|
374
|
+
get routeCount() { return httpRoutes.length; },
|
|
375
|
+
get wsPaths() { return Object.freeze([...wsRoutes.keys()]); },
|
|
376
|
+
listWsRoutes() {
|
|
377
|
+
const listed = [];
|
|
378
|
+
for (const [path, entries] of wsRoutes) {
|
|
379
|
+
const active = [...entries].filter((entry) => !entry.admission || entry.admission.active);
|
|
380
|
+
if (active.length === 0)
|
|
381
|
+
continue;
|
|
382
|
+
const auth = active.every((entry) => entry.options.auth === 'protocol') ? 'protocol' : 'host';
|
|
383
|
+
listed.push(Object.freeze({ path, auth }));
|
|
384
|
+
}
|
|
385
|
+
return Object.freeze(listed);
|
|
375
386
|
},
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
if (listenResult)
|
|
380
|
-
return listenResult;
|
|
381
|
-
if (address)
|
|
382
|
-
return Promise.resolve(address);
|
|
383
|
-
listenResult = new Promise((resolve, reject) => {
|
|
384
|
-
const onError = (error) => {
|
|
385
|
-
listenResult = undefined;
|
|
386
|
-
reject(error);
|
|
387
|
-
};
|
|
388
|
-
server.once('error', onError);
|
|
389
|
-
server.listen({ host, port }, () => {
|
|
390
|
-
server.off('error', onError);
|
|
391
|
-
if (closed) {
|
|
392
|
-
reject(new Error('HTTP Host closed before listen completed'));
|
|
393
|
-
return;
|
|
394
|
-
}
|
|
395
|
-
const bound = server.address();
|
|
396
|
-
const listenPort = typeof bound === 'object' && bound ? bound.port : port;
|
|
397
|
-
const publicHost = host === '0.0.0.0' || host === '::' ? '127.0.0.1' : host;
|
|
398
|
-
const protocol = secure ? 'https' : 'http';
|
|
399
|
-
const originHost = publicHost.includes(':') ? `[${publicHost}]` : publicHost;
|
|
400
|
-
address = Object.freeze({
|
|
401
|
-
host: publicHost, port: listenPort, protocol, secure,
|
|
402
|
-
origin: `${protocol}://${originHost}:${listenPort}`,
|
|
403
|
-
});
|
|
404
|
-
logger.info(`listening ${address.origin}`
|
|
405
|
-
+ ` | routes: ${httpRoutes.length}`
|
|
406
|
-
+ ` | token: ${tokenRegistry.hasAnyToken()
|
|
407
|
-
? `${tokenRegistry.primaryTokenPrefixForLog()}…`
|
|
408
|
-
: '(none)'}`);
|
|
409
|
-
logger.debug(`listen detail | cors: ${corsOrigins.join(',')}`
|
|
410
|
-
+ ` | ws: ${[...wsRoutes.keys()].join(',') || '(none)'}`);
|
|
411
|
-
resolve(address);
|
|
412
|
-
});
|
|
413
|
-
});
|
|
414
|
-
return listenResult;
|
|
387
|
+
onRoutesChanged(listener) {
|
|
388
|
+
changeListeners.add(listener);
|
|
389
|
+
return () => { changeListeners.delete(listener); };
|
|
415
390
|
},
|
|
391
|
+
apiBase,
|
|
392
|
+
corsOrigins,
|
|
416
393
|
close() {
|
|
417
|
-
if (closeResult)
|
|
418
|
-
return closeResult;
|
|
419
394
|
closed = true;
|
|
420
395
|
wsRoutes.clear();
|
|
421
396
|
httpRoutes.length = 0;
|
|
422
|
-
|
|
423
|
-
// A pending listen must settle before deciding whether there is a
|
|
424
|
-
// listener to close. Otherwise it can bind after close has resolved.
|
|
425
|
-
await listenResult?.catch(() => undefined);
|
|
426
|
-
if (!server.listening) {
|
|
427
|
-
address = undefined;
|
|
428
|
-
return;
|
|
429
|
-
}
|
|
430
|
-
await new Promise((resolve, reject) => {
|
|
431
|
-
// Long-lived connections (SSE, keep-alive, WS) never end on their own:
|
|
432
|
-
// destroy tracked sockets and terminate WS clients before close(),
|
|
433
|
-
// otherwise the close callbacks would never fire.
|
|
434
|
-
for (const socket of sockets)
|
|
435
|
-
socket.destroy();
|
|
436
|
-
sockets.clear();
|
|
437
|
-
for (const client of wss.clients)
|
|
438
|
-
client.terminate();
|
|
439
|
-
wss.close((wssError) => {
|
|
440
|
-
if (wssError) {
|
|
441
|
-
reject(wssError);
|
|
442
|
-
return;
|
|
443
|
-
}
|
|
444
|
-
server.close((serverError) => {
|
|
445
|
-
if (serverError) {
|
|
446
|
-
reject(serverError);
|
|
447
|
-
return;
|
|
448
|
-
}
|
|
449
|
-
logger.debug(formatCompact({ op: 'http_host_closed' }));
|
|
450
|
-
address = undefined;
|
|
451
|
-
resolve();
|
|
452
|
-
});
|
|
453
|
-
});
|
|
454
|
-
});
|
|
455
|
-
})();
|
|
456
|
-
return closeResult;
|
|
397
|
+
changeListeners.clear();
|
|
457
398
|
},
|
|
458
399
|
};
|
|
459
|
-
|
|
400
|
+
}
|
|
401
|
+
/**
|
|
402
|
+
* Adds host default headers (handler headers win) and, when `release` is given, keeps the
|
|
403
|
+
* generation lease until the body is fully delivered or cancelled.
|
|
404
|
+
*/
|
|
405
|
+
function withHeaders(response, defaults, release) {
|
|
406
|
+
const entries = defaults ? Object.entries(defaults) : [];
|
|
407
|
+
if (entries.length === 0 && !release)
|
|
408
|
+
return response;
|
|
409
|
+
const headers = new Headers(response.headers);
|
|
410
|
+
for (const [name, value] of entries)
|
|
411
|
+
if (!headers.has(name))
|
|
412
|
+
headers.set(name, value);
|
|
413
|
+
let body = response.body;
|
|
414
|
+
if (release) {
|
|
415
|
+
if (!body)
|
|
416
|
+
release();
|
|
417
|
+
else
|
|
418
|
+
body = releaseWhenDrained(body, release);
|
|
419
|
+
}
|
|
420
|
+
try {
|
|
421
|
+
return new Response(body, { status: response.status, statusText: response.statusText, headers });
|
|
422
|
+
}
|
|
423
|
+
catch {
|
|
424
|
+
// Response.error() and friends cannot be re-wrapped.
|
|
425
|
+
return new Response(null, { status: 500 });
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
function releaseWhenDrained(source, release) {
|
|
429
|
+
const reader = source.getReader();
|
|
430
|
+
let done = false;
|
|
431
|
+
const finish = () => {
|
|
432
|
+
if (done)
|
|
433
|
+
return;
|
|
434
|
+
done = true;
|
|
435
|
+
release();
|
|
436
|
+
};
|
|
437
|
+
return new ReadableStream({
|
|
438
|
+
async pull(controller) {
|
|
439
|
+
try {
|
|
440
|
+
const next = await reader.read();
|
|
441
|
+
if (next.done) {
|
|
442
|
+
finish();
|
|
443
|
+
controller.close();
|
|
444
|
+
}
|
|
445
|
+
else {
|
|
446
|
+
controller.enqueue(next.value);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
catch (error) {
|
|
450
|
+
finish();
|
|
451
|
+
controller.error(error);
|
|
452
|
+
}
|
|
453
|
+
},
|
|
454
|
+
async cancel(reason) {
|
|
455
|
+
finish();
|
|
456
|
+
await reader.cancel(reason).catch(() => undefined);
|
|
457
|
+
},
|
|
458
|
+
}, { highWaterMark: 0 });
|
|
460
459
|
}
|
|
461
460
|
function requiresHttpAuth(pathname, apiBase, authExempt) {
|
|
462
461
|
if (pathname === '/pub' || pathname.startsWith('/pub/'))
|
|
@@ -473,24 +472,15 @@ function corsMatch(origin, allowed) {
|
|
|
473
472
|
|| allowed.some((entry) => (entry === origin
|
|
474
473
|
|| (entry.endsWith('*') && origin.startsWith(entry.slice(0, -1)))));
|
|
475
474
|
}
|
|
476
|
-
function
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
'content-type': 'application/json; charset=utf-8',
|
|
486
|
-
'content-length': Buffer.byteLength(payload),
|
|
487
|
-
});
|
|
488
|
-
response.end(payload);
|
|
489
|
-
}
|
|
490
|
-
function headerValue(value) {
|
|
491
|
-
if (Array.isArray(value))
|
|
492
|
-
return value[0];
|
|
493
|
-
return value;
|
|
475
|
+
function corsHeaders(origin, allowed) {
|
|
476
|
+
return {
|
|
477
|
+
'access-control-allow-origin': allowed.includes('*') ? '*' : origin,
|
|
478
|
+
'access-control-allow-methods': 'GET, POST, PUT, DELETE, OPTIONS',
|
|
479
|
+
'access-control-allow-headers': 'Authorization, Content-Type, Accept',
|
|
480
|
+
'access-control-max-age': '86400',
|
|
481
|
+
// Browsers hide non-safelisted response headers unless exposed (503 instance-unavailable).
|
|
482
|
+
'access-control-expose-headers': 'Retry-After',
|
|
483
|
+
};
|
|
494
484
|
}
|
|
495
485
|
function matchHttpRoute(routes, method, pathname) {
|
|
496
486
|
let best;
|
|
@@ -517,7 +507,12 @@ function matchHttpRoute(routes, method, pathname) {
|
|
|
517
507
|
}
|
|
518
508
|
return best;
|
|
519
509
|
}
|
|
520
|
-
function
|
|
510
|
+
function prefixRemainder(prefix, pathname) {
|
|
511
|
+
if (prefix === '/')
|
|
512
|
+
return pathname.slice(1);
|
|
513
|
+
return pathname.length > prefix.length ? pathname.slice(prefix.length + 1) : '';
|
|
514
|
+
}
|
|
515
|
+
export function normalizePath(pathname) {
|
|
521
516
|
if (!pathname.startsWith('/'))
|
|
522
517
|
return `/${pathname}`;
|
|
523
518
|
// 线性裁剪尾部斜杠(等价于 /\/+$/u,但避免长串 `/` 无匹配时的
|
|
@@ -527,11 +522,3 @@ function normalizePath(pathname) {
|
|
|
527
522
|
end -= 1;
|
|
528
523
|
return pathname.slice(0, end) || '/';
|
|
529
524
|
}
|
|
530
|
-
function upgradePath(url) {
|
|
531
|
-
try {
|
|
532
|
-
return normalizePath(new URL(url ?? '/', 'http://localhost').pathname);
|
|
533
|
-
}
|
|
534
|
-
catch {
|
|
535
|
-
return '/';
|
|
536
|
-
}
|
|
537
|
-
}
|