@zhin.js/host-http 1.0.6 → 1.0.9
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 +5 -0
- package/lib/console-rest-pages.d.ts +5 -2
- package/lib/console-rest-pages.js +90 -67
- package/lib/console-rpc-extended.d.ts +4 -6
- package/lib/console-rpc-extended.js +73 -124
- package/lib/console-rpc.d.ts +2 -2
- package/lib/console-rpc.js +6 -6
- package/lib/http-host.d.ts +8 -5
- package/lib/http-host.js +141 -64
- package/lib/index.d.ts +1 -1
- package/package.json +5 -5
- package/src/console-rest-pages.ts +98 -73
- package/src/console-rpc-extended.ts +78 -125
- package/src/console-rpc.ts +8 -8
- package/src/http-host.ts +174 -72
- package/src/index.ts +1 -0
package/src/http-host.ts
CHANGED
|
@@ -5,9 +5,14 @@ import {
|
|
|
5
5
|
type ServerResponse,
|
|
6
6
|
} from 'node:http';
|
|
7
7
|
import type { Socket } from 'node:net';
|
|
8
|
-
import {
|
|
8
|
+
import { WebSocket, WebSocketServer } from 'ws';
|
|
9
9
|
import { formatCompact, getLogger } from '@zhin.js/logger';
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
createToken,
|
|
12
|
+
generationAdmissionBinder,
|
|
13
|
+
type GenerationAdmissionBindable,
|
|
14
|
+
type GenerationAdmissionGate,
|
|
15
|
+
} from '@zhin.js/plugin-runtime';
|
|
11
16
|
import {
|
|
12
17
|
TokenRegistry,
|
|
13
18
|
extractBearerToken,
|
|
@@ -24,7 +29,7 @@ import {
|
|
|
24
29
|
import { isDemoHttpAllowed } from './console-rpc.js';
|
|
25
30
|
import { HttpBodyError } from './json-body.js';
|
|
26
31
|
|
|
27
|
-
const logger = getLogger('
|
|
32
|
+
const logger = getLogger('http');
|
|
28
33
|
|
|
29
34
|
export const REMOTE_CONSOLE_ORIGIN = 'https://console.zhin.dev';
|
|
30
35
|
const DEFAULT_CORS_ORIGINS = Object.freeze([REMOTE_CONSOLE_ORIGIN]);
|
|
@@ -74,7 +79,7 @@ export interface HttpRouteRegistration {
|
|
|
74
79
|
(): void;
|
|
75
80
|
}
|
|
76
81
|
|
|
77
|
-
export interface HttpHost {
|
|
82
|
+
export interface HttpHost extends GenerationAdmissionBindable<HttpHost> {
|
|
78
83
|
ws(path: string): WsHandle;
|
|
79
84
|
route(
|
|
80
85
|
method: string,
|
|
@@ -83,12 +88,15 @@ export interface HttpHost {
|
|
|
83
88
|
meta?: RouteMeta,
|
|
84
89
|
): HttpRouteRegistration;
|
|
85
90
|
listRoutes(): readonly ListedRoute[];
|
|
86
|
-
listen(): Promise<HttpHostAddress>;
|
|
87
|
-
close(): Promise<void>;
|
|
88
91
|
get address(): HttpHostAddress | undefined;
|
|
89
92
|
get tokenRegistry(): TokenRegistry;
|
|
90
93
|
}
|
|
91
94
|
|
|
95
|
+
export interface ProcessHttpHost extends HttpHost {
|
|
96
|
+
listen(): Promise<HttpHostAddress>;
|
|
97
|
+
close(): Promise<void>;
|
|
98
|
+
}
|
|
99
|
+
|
|
92
100
|
interface HttpRoute {
|
|
93
101
|
readonly method: string;
|
|
94
102
|
readonly path: string;
|
|
@@ -96,11 +104,17 @@ interface HttpRoute {
|
|
|
96
104
|
readonly prefix: boolean;
|
|
97
105
|
readonly handler: HttpHandler;
|
|
98
106
|
readonly meta?: RouteMeta;
|
|
107
|
+
readonly admission?: GenerationAdmissionGate;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
interface WsRoute {
|
|
111
|
+
readonly listener: (connection: WsConnection) => void;
|
|
112
|
+
readonly admission?: GenerationAdmissionGate;
|
|
99
113
|
}
|
|
100
114
|
|
|
101
115
|
export const httpHostToken = createToken<HttpHost>('zhin.host.http');
|
|
102
116
|
|
|
103
|
-
export function createHttpHost(options: HttpHostOptions = {}):
|
|
117
|
+
export function createHttpHost(options: HttpHostOptions = {}): ProcessHttpHost {
|
|
104
118
|
const host = options.host ?? '127.0.0.1';
|
|
105
119
|
const port = options.port ?? 8086;
|
|
106
120
|
const apiBase = normalizePath(options.apiBase ?? '/api');
|
|
@@ -114,10 +128,11 @@ export function createHttpHost(options: HttpHostOptions = {}): HttpHost {
|
|
|
114
128
|
primaryToken: options.token,
|
|
115
129
|
scopedTokens: options.tokens,
|
|
116
130
|
} satisfies TokenRegistryConfig);
|
|
117
|
-
const wsRoutes = new Map<string, Set<
|
|
131
|
+
const wsRoutes = new Map<string, Set<WsRoute>>();
|
|
118
132
|
const httpRoutes: HttpRoute[] = [];
|
|
119
133
|
let address: HttpHostAddress | undefined;
|
|
120
134
|
let closed = false;
|
|
135
|
+
let closeResult: Promise<void> | undefined;
|
|
121
136
|
|
|
122
137
|
const server: Server = createServer((request, response) => {
|
|
123
138
|
void dispatchHttp(request, response);
|
|
@@ -176,7 +191,12 @@ export function createHttpHost(options: HttpHostOptions = {}): HttpHost {
|
|
|
176
191
|
}
|
|
177
192
|
const pathname = upgradePath(request.url);
|
|
178
193
|
const listeners = wsRoutes.get(pathname);
|
|
179
|
-
|
|
194
|
+
const admitted = listeners ? [...listeners].flatMap((entry) => {
|
|
195
|
+
const release = entry.admission?.acquire();
|
|
196
|
+
if (entry.admission && !release) return [];
|
|
197
|
+
return [{ entry, release }];
|
|
198
|
+
}) : [];
|
|
199
|
+
if (admitted.length === 0) {
|
|
180
200
|
logger.debug(formatCompact({ op: 'ws_upgrade_unmatched', path: pathname }));
|
|
181
201
|
socket.destroy();
|
|
182
202
|
return;
|
|
@@ -185,18 +205,39 @@ export function createHttpHost(options: HttpHostOptions = {}): HttpHost {
|
|
|
185
205
|
if (!auth.ok) {
|
|
186
206
|
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
|
|
187
207
|
socket.destroy();
|
|
208
|
+
for (const item of admitted) item.release?.();
|
|
188
209
|
return;
|
|
189
210
|
}
|
|
190
211
|
try {
|
|
191
212
|
wss.handleUpgrade(request, socket, head, (ws) => {
|
|
213
|
+
let settled = false;
|
|
214
|
+
const retireSubscriptions: Array<() => void> = [];
|
|
215
|
+
const releaseAdmissions = () => {
|
|
216
|
+
if (settled) return;
|
|
217
|
+
settled = true;
|
|
218
|
+
for (const dispose of retireSubscriptions) dispose();
|
|
219
|
+
for (const item of admitted) item.release?.();
|
|
220
|
+
};
|
|
221
|
+
ws.once('close', releaseAdmissions);
|
|
222
|
+
ws.once('error', releaseAdmissions);
|
|
223
|
+
for (const item of admitted) {
|
|
224
|
+
if (item.entry.admission) {
|
|
225
|
+
retireSubscriptions.push(item.entry.admission.onDeactivate(() => {
|
|
226
|
+
if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
|
|
227
|
+
ws.close(1012, 'Generation retired');
|
|
228
|
+
}
|
|
229
|
+
}));
|
|
230
|
+
}
|
|
231
|
+
}
|
|
192
232
|
const connection = Object.freeze({
|
|
193
233
|
socket: ws,
|
|
194
234
|
request,
|
|
195
235
|
authScope: auth.scope,
|
|
196
236
|
});
|
|
197
|
-
for (const
|
|
237
|
+
for (const item of admitted) item.entry.listener(connection);
|
|
198
238
|
});
|
|
199
239
|
} catch (err) {
|
|
240
|
+
for (const item of admitted) item.release?.();
|
|
200
241
|
logger.warn(formatCompact({
|
|
201
242
|
op: 'ws_upgrade_failed',
|
|
202
243
|
path: pathname,
|
|
@@ -275,7 +316,28 @@ export function createHttpHost(options: HttpHostOptions = {}): HttpHost {
|
|
|
275
316
|
if (corsOk && origin) applyCors(response, origin, corsOrigins);
|
|
276
317
|
response.setHeader('X-Content-Type-Options', 'nosniff');
|
|
277
318
|
response.setHeader('X-Frame-Options', 'SAMEORIGIN');
|
|
319
|
+
const release = route.admission?.acquire();
|
|
320
|
+
if (route.admission && !release) {
|
|
321
|
+
response.writeHead(503);
|
|
322
|
+
response.end();
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
if (!release) {
|
|
326
|
+
await route.handler(request, response, url, auth.scope);
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
let released = false;
|
|
330
|
+
const releaseResponse = () => {
|
|
331
|
+
if (released) return;
|
|
332
|
+
released = true;
|
|
333
|
+
response.off('finish', releaseResponse);
|
|
334
|
+
response.off('close', releaseResponse);
|
|
335
|
+
release();
|
|
336
|
+
};
|
|
337
|
+
response.once('finish', releaseResponse);
|
|
338
|
+
response.once('close', releaseResponse);
|
|
278
339
|
await route.handler(request, response, url, auth.scope);
|
|
340
|
+
if (response.writableFinished || response.destroyed) releaseResponse();
|
|
279
341
|
} catch (err) {
|
|
280
342
|
// 统一把请求体错误(400/413)映射回对应状态码,
|
|
281
343
|
// 避免个别端点未捕获时退化成 500 空响应。
|
|
@@ -322,62 +384,96 @@ export function createHttpHost(options: HttpHostOptions = {}): HttpHost {
|
|
|
322
384
|
}
|
|
323
385
|
|
|
324
386
|
function listListedRoutes(): readonly ListedRoute[] {
|
|
325
|
-
return Object.freeze(httpRoutes
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
387
|
+
return Object.freeze(httpRoutes
|
|
388
|
+
.filter((route) => !route.admission || route.admission.active)
|
|
389
|
+
.map((route) => Object.freeze({
|
|
390
|
+
method: route.method,
|
|
391
|
+
pattern: route.pattern,
|
|
392
|
+
meta: route.meta,
|
|
393
|
+
})));
|
|
330
394
|
}
|
|
331
395
|
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
396
|
+
function registerWs(
|
|
397
|
+
pathname: string,
|
|
398
|
+
admission?: GenerationAdmissionGate,
|
|
399
|
+
): WsHandle {
|
|
400
|
+
const normalized = normalizePath(pathname);
|
|
401
|
+
let listeners = wsRoutes.get(normalized);
|
|
402
|
+
if (!listeners) {
|
|
403
|
+
listeners = new Set();
|
|
404
|
+
wsRoutes.set(normalized, listeners);
|
|
405
|
+
}
|
|
406
|
+
const owned = new Set<WsRoute>();
|
|
407
|
+
return {
|
|
408
|
+
onConnection(listener) {
|
|
409
|
+
const entry = Object.freeze({ listener, admission });
|
|
410
|
+
listeners!.add(entry);
|
|
411
|
+
owned.add(entry);
|
|
412
|
+
return () => {
|
|
413
|
+
listeners!.delete(entry);
|
|
414
|
+
owned.delete(entry);
|
|
415
|
+
};
|
|
416
|
+
},
|
|
417
|
+
close() {
|
|
418
|
+
for (const entry of owned) listeners!.delete(entry);
|
|
419
|
+
owned.clear();
|
|
420
|
+
},
|
|
421
|
+
};
|
|
422
|
+
}
|
|
356
423
|
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
424
|
+
function registerRoute(
|
|
425
|
+
method: string,
|
|
426
|
+
path: string,
|
|
427
|
+
handler: HttpHandler,
|
|
428
|
+
meta?: RouteMeta,
|
|
429
|
+
admission?: GenerationAdmissionGate,
|
|
430
|
+
): HttpRouteRegistration {
|
|
431
|
+
const normalizedMethod = method.toUpperCase();
|
|
432
|
+
const prefix = path.endsWith('/*');
|
|
433
|
+
const normalizedPath = normalizePath(prefix ? path.slice(0, -2) : path);
|
|
434
|
+
const pattern = prefix ? `${normalizedPath}/*` : normalizedPath;
|
|
435
|
+
const entry: HttpRoute = Object.freeze({
|
|
436
|
+
method: normalizedMethod,
|
|
437
|
+
path: normalizedPath,
|
|
438
|
+
pattern,
|
|
439
|
+
prefix,
|
|
440
|
+
handler,
|
|
441
|
+
meta,
|
|
442
|
+
admission,
|
|
443
|
+
});
|
|
444
|
+
httpRoutes.push(entry);
|
|
445
|
+
return () => {
|
|
446
|
+
const index = httpRoutes.indexOf(entry);
|
|
447
|
+
if (index >= 0) httpRoutes.splice(index, 1);
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function createBoundHost(
|
|
452
|
+
admission: GenerationAdmissionGate,
|
|
453
|
+
): HttpHost {
|
|
454
|
+
const bound: HttpHost = {
|
|
455
|
+
[generationAdmissionBinder]: (next) => createBoundHost(next),
|
|
456
|
+
ws: (path) => registerWs(path, admission),
|
|
457
|
+
route: (method, path, handler, meta) => registerRoute(
|
|
458
|
+
method,
|
|
459
|
+
path,
|
|
372
460
|
handler,
|
|
373
461
|
meta,
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
462
|
+
admission,
|
|
463
|
+
),
|
|
464
|
+
listRoutes: () => listListedRoutes(),
|
|
465
|
+
get address() { return runtime.address; },
|
|
466
|
+
get tokenRegistry() { return tokenRegistry; },
|
|
467
|
+
};
|
|
468
|
+
return Object.freeze(bound);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
const runtime: ProcessHttpHost = {
|
|
472
|
+
[generationAdmissionBinder]: (admission) => createBoundHost(admission),
|
|
473
|
+
|
|
474
|
+
ws: (pathname) => registerWs(pathname),
|
|
475
|
+
|
|
476
|
+
route: (method, path, handler, meta) => registerRoute(method, path, handler, meta),
|
|
381
477
|
|
|
382
478
|
listRoutes(): readonly ListedRoute[] {
|
|
383
479
|
return listListedRoutes();
|
|
@@ -401,31 +497,34 @@ export function createHttpHost(options: HttpHostOptions = {}): HttpHost {
|
|
|
401
497
|
const listenPort = typeof bound === 'object' && bound ? bound.port : port;
|
|
402
498
|
const publicHost = host === '0.0.0.0' || host === '::' ? '127.0.0.1' : host;
|
|
403
499
|
address = Object.freeze({ host: publicHost, port: listenPort });
|
|
404
|
-
logger.info(
|
|
405
|
-
|
|
406
|
-
|
|
500
|
+
logger.info(
|
|
501
|
+
`listening http://${publicHost}:${listenPort}`
|
|
502
|
+
+ ` | routes: ${httpRoutes.length}`
|
|
503
|
+
+ ` | token: ${tokenRegistry.hasAnyToken()
|
|
407
504
|
? `${tokenRegistry.primaryTokenPrefixForLog()}…`
|
|
408
|
-
: '(none)'
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
505
|
+
: '(none)'}`,
|
|
506
|
+
);
|
|
507
|
+
logger.debug(
|
|
508
|
+
`listen detail | cors: ${corsOrigins.join(',')}`
|
|
509
|
+
+ ` | ws: ${[...wsRoutes.keys()].join(',') || '(none)'}`,
|
|
510
|
+
);
|
|
413
511
|
resolve(address);
|
|
414
512
|
});
|
|
415
513
|
});
|
|
416
514
|
},
|
|
417
515
|
|
|
418
516
|
close(): Promise<void> {
|
|
419
|
-
if (
|
|
517
|
+
if (closeResult) return closeResult;
|
|
420
518
|
closed = true;
|
|
421
519
|
wsRoutes.clear();
|
|
422
520
|
httpRoutes.length = 0;
|
|
423
521
|
// Rollback paths dispose a host whose generation never reached listen().
|
|
424
522
|
if (!server.listening) {
|
|
425
523
|
address = undefined;
|
|
426
|
-
|
|
524
|
+
closeResult = Promise.resolve();
|
|
525
|
+
return closeResult;
|
|
427
526
|
}
|
|
428
|
-
|
|
527
|
+
closeResult = new Promise((resolve, reject) => {
|
|
429
528
|
// Long-lived connections (SSE, keep-alive, WS) never end on their own:
|
|
430
529
|
// destroy tracked sockets and terminate WS clients before close(),
|
|
431
530
|
// otherwise the close callbacks would never fire.
|
|
@@ -448,8 +547,10 @@ export function createHttpHost(options: HttpHostOptions = {}): HttpHost {
|
|
|
448
547
|
});
|
|
449
548
|
});
|
|
450
549
|
});
|
|
550
|
+
return closeResult;
|
|
451
551
|
},
|
|
452
552
|
};
|
|
553
|
+
return runtime;
|
|
453
554
|
}
|
|
454
555
|
|
|
455
556
|
function requiresHttpAuth(
|
|
@@ -505,6 +606,7 @@ function matchHttpRoute(routes: readonly HttpRoute[], method: string, pathname:
|
|
|
505
606
|
let best: HttpRoute | undefined;
|
|
506
607
|
let bestLength = -1;
|
|
507
608
|
for (const route of routes) {
|
|
609
|
+
if (route.admission && !route.admission.active) continue;
|
|
508
610
|
if (route.method !== method && route.method !== 'ALL') continue;
|
|
509
611
|
if (route.prefix) {
|
|
510
612
|
const matches = route.path === '/'
|