@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/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 { WebSocketServer, type WebSocket } from 'ws';
8
+ import { WebSocket, WebSocketServer } from 'ws';
9
9
  import { formatCompact, getLogger } from '@zhin.js/logger';
10
- import { createToken } from '@zhin.js/plugin-runtime';
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('HttpHost');
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 = {}): HttpHost {
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<(connection: WsConnection) => void>>();
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
- if (!listeners || listeners.size === 0) {
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 listener of listeners) listener(connection);
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.map((route) => Object.freeze({
326
- method: route.method,
327
- pattern: route.pattern,
328
- meta: route.meta,
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
- return {
333
- ws(pathname: string): WsHandle {
334
- const normalized = normalizePath(pathname);
335
- let listeners = wsRoutes.get(normalized);
336
- if (!listeners) {
337
- listeners = new Set();
338
- wsRoutes.set(normalized, listeners);
339
- }
340
- const owned = new Set<(connection: WsConnection) => void>();
341
- return {
342
- onConnection(listener) {
343
- listeners!.add(listener);
344
- owned.add(listener);
345
- return () => {
346
- listeners!.delete(listener);
347
- owned.delete(listener);
348
- };
349
- },
350
- close() {
351
- for (const listener of owned) listeners!.delete(listener);
352
- owned.clear();
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
- route(
358
- method: string,
359
- path: string,
360
- handler: HttpHandler,
361
- meta?: RouteMeta,
362
- ): HttpRouteRegistration {
363
- const normalizedMethod = method.toUpperCase();
364
- const prefix = path.endsWith('/*');
365
- const normalizedPath = normalizePath(prefix ? path.slice(0, -2) : path);
366
- const pattern = prefix ? `${normalizedPath}/*` : normalizedPath;
367
- const entry: HttpRoute = Object.freeze({
368
- method: normalizedMethod,
369
- path: normalizedPath,
370
- pattern,
371
- prefix,
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
- httpRoutes.push(entry);
376
- return () => {
377
- const index = httpRoutes.indexOf(entry);
378
- if (index >= 0) httpRoutes.splice(index, 1);
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(formatCompact({
405
- http: `http://${publicHost}:${listenPort}`,
406
- token: tokenRegistry.hasAnyToken()
500
+ logger.info(
501
+ `listening http://${publicHost}:${listenPort}`
502
+ + ` | routes: ${httpRoutes.length}`
503
+ + ` | token: ${tokenRegistry.hasAnyToken()
407
504
  ? `${tokenRegistry.primaryTokenPrefixForLog()}…`
408
- : '(none)',
409
- cors: corsOrigins.join(','),
410
- ws_routes: [...wsRoutes.keys()].join(',') || '(none)',
411
- http_routes: httpRoutes.length,
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 (closed) return Promise.resolve();
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
- return Promise.resolve();
524
+ closeResult = Promise.resolve();
525
+ return closeResult;
427
526
  }
428
- return new Promise((resolve, reject) => {
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 === '/'
package/src/index.ts CHANGED
@@ -4,6 +4,7 @@ export {
4
4
  REMOTE_CONSOLE_ORIGIN,
5
5
  type HttpHandler,
6
6
  type HttpHost,
7
+ type ProcessHttpHost,
7
8
  type HttpHostAddress,
8
9
  type HttpHostOptions,
9
10
  type HttpRouteRegistration,