@zhin.js/host-http 1.0.8 → 1.0.10

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 CHANGED
@@ -7,6 +7,11 @@ Console can register path-scoped WebSocket / HTTP routes without importing Koa o
7
7
  Root installs the host via `installResources` (see `basic/cli/src/plugin-runtime/http-host-installer.ts`
8
8
  and `console-host-installer.ts`).
9
9
 
10
+ The composition root owns the process listener through `ProcessHttpHost`. Generation scopes receive
11
+ only the `HttpHost` routing port: they may register HTTP/WS routes but cannot listen or close the
12
+ shared server. Registrations are bound to generation admission, so candidate routes stay invisible
13
+ until snapshot commit and retired routes stop matching immediately while their leases drain.
14
+
10
15
  ## Capabilities (this slice)
11
16
 
12
17
  - Path-scoped WebSocket upgrades (`ws(path)`)
@@ -11,8 +11,8 @@
11
11
  * - inbox:`ctx.databaseHost.models`(unified_inbox_message/request/notice 三张表,
12
12
  * 表不存在或查询失败 → 空数组 + inboxEnabled:false;requestConsumed/noticeConsumed
13
13
  * 按行 id update consumed=1,表/model 缺失时报"未接线")。
14
- * - 社交/群管:`ctx.resolveEndpoint` 拿 live endpoint 实例,只消费其
15
- * `management` 语义端口;平台 SDK 兼容与字段归一化由 Adapter 负责。
14
+ * - 社交/群管:`ctx.withEndpointManagement` 在固定 generation lease 内
15
+ * 消费 `management` 语义端口;平台 SDK 字段归一化由 Adapter 负责。
16
16
  */
17
17
  export interface ConsoleRpcExtendedCtx {
18
18
  /** full scope 才允许写操作;demo scope 只放行只读 RPC。 */
@@ -20,10 +20,8 @@ export interface ConsoleRpcExtendedCtx {
20
20
  projectRoot: string;
21
21
  /** Plugin Runtime ScheduleHost(basic/cli schedule-host-installer 提供)。 */
22
22
  scheduleHost?: unknown;
23
- /** 经 ImRuntime / AdapterEndpointIndex 解析管理语义端口。 */
24
- resolveEndpointManagement?: (adapter: string, endpointKey: string) => EndpointManagementPort | null | undefined;
25
- /** @deprecated Use `resolveEndpointManagement`; accepted for one compatibility cycle. */
26
- resolveEndpoint?: (adapter: string, endpointKey: string) => unknown;
23
+ /** 在固定 generation lease 内执行一次 Endpoint 管理操作。 */
24
+ withEndpointManagement?: <T>(adapter: string, endpointKey: string, run: (management: EndpointManagementPort) => T | Promise<T>) => Promise<T | null>;
27
25
  /** Plugin Runtime DatabaseHost 的 models 视图。 */
28
26
  databaseHost?: {
29
27
  models: {
@@ -11,8 +11,8 @@
11
11
  * - inbox:`ctx.databaseHost.models`(unified_inbox_message/request/notice 三张表,
12
12
  * 表不存在或查询失败 → 空数组 + inboxEnabled:false;requestConsumed/noticeConsumed
13
13
  * 按行 id update consumed=1,表/model 缺失时报"未接线")。
14
- * - 社交/群管:`ctx.resolveEndpoint` 拿 live endpoint 实例,只消费其
15
- * `management` 语义端口;平台 SDK 兼容与字段归一化由 Adapter 负责。
14
+ * - 社交/群管:`ctx.withEndpointManagement` 在固定 generation lease 内
15
+ * 消费 `management` 语义端口;平台 SDK 字段归一化由 Adapter 负责。
16
16
  */
17
17
  /** 新 Runtime 统一收件箱表名(SSOT:@zhin.js/plugin-runtime inbox.js)。 */
18
18
  import { assertDemoConsoleRpcAllowed, normalizeConsoleRpcType, } from '@zhin.js/console-protocol';
@@ -398,77 +398,58 @@ async function actOnRequest(type, d, ctx) {
398
398
  if (!adapter || !endpointKey || !requestId) {
399
399
  return { error: '$adapter, $endpoint, $id required' };
400
400
  }
401
- const resolved = resolveLiveEndpoint(ctx, adapter, endpointKey);
402
- if ('error' in resolved)
403
- return resolved;
404
- const management = resolved.management;
405
- const approve = type === 'request.approve';
406
- const method = approve ? management.approveRequest : management.rejectRequest;
407
- if (!method) {
408
- return {
409
- error: `请求审批未接线:当前平台(${adapter})endpoint 不支持 ${approve ? 'approveRequest' : 'rejectRequest'},` +
410
- '且新 Runtime 尚未挂载 pending request 注册表',
411
- };
412
- }
413
- try {
401
+ return withLiveEndpoint(ctx, adapter, endpointKey, async (management) => {
402
+ const approve = type === 'request.approve';
403
+ const method = approve ? management.approveRequest : management.rejectRequest;
404
+ if (!method) {
405
+ return {
406
+ error: `请求审批未接线:当前平台(${adapter})endpoint 不支持 ${approve ? 'approveRequest' : 'rejectRequest'},` +
407
+ '且新 Runtime 尚未挂载 pending request 注册表',
408
+ };
409
+ }
414
410
  const extra = approve
415
411
  ? strField(d, '$remark', 'remark')
416
412
  : strField(d, '$reason', 'reason');
417
413
  await method.call(management, requestId, extra || undefined);
418
414
  return { data: { success: true } };
419
- }
420
- catch (error) {
421
- return { error: errorMessage(error) };
422
- }
415
+ });
423
416
  }
424
417
  // ---------------------------------------------------------------- 社交读取
425
418
  async function listFriends(d, ctx) {
426
- const resolved = requireEndpoint(d, ctx);
427
- if ('error' in resolved)
428
- return resolved;
429
- const { management, adapter } = resolved;
430
- try {
419
+ const endpoint = requireEndpointAddress(d);
420
+ if ('error' in endpoint)
421
+ return endpoint;
422
+ return withLiveEndpoint(ctx, endpoint.adapter, endpoint.endpointKey, async (management) => {
431
423
  if (management.listFriends) {
432
424
  const friends = [...await management.listFriends()];
433
425
  return { data: { friends, count: friends.length } };
434
426
  }
435
- return { error: `当前适配器(${adapter})不支持好友列表` };
436
- }
437
- catch (error) {
438
- return { error: errorMessage(error) };
439
- }
427
+ return { error: `当前适配器(${endpoint.adapter})不支持好友列表` };
428
+ });
440
429
  }
441
430
  async function listGroups(d, ctx) {
442
- const resolved = requireEndpoint(d, ctx);
443
- if ('error' in resolved)
444
- return resolved;
445
- const { management, adapter } = resolved;
446
- try {
431
+ const endpoint = requireEndpointAddress(d);
432
+ if ('error' in endpoint)
433
+ return endpoint;
434
+ return withLiveEndpoint(ctx, endpoint.adapter, endpoint.endpointKey, async (management) => {
447
435
  if (management.listGroups) {
448
436
  const groups = [...await management.listGroups()];
449
437
  return { data: { groups, count: groups.length } };
450
438
  }
451
- return { error: `当前适配器(${adapter})不支持群列表` };
452
- }
453
- catch (error) {
454
- return { error: errorMessage(error) };
455
- }
439
+ return { error: `当前适配器(${endpoint.adapter})不支持群列表` };
440
+ });
456
441
  }
457
442
  async function listChannels(d, ctx) {
458
- const resolved = requireEndpoint(d, ctx);
459
- if ('error' in resolved)
460
- return resolved;
461
- const { management, adapter } = resolved;
462
- try {
443
+ const endpoint = requireEndpointAddress(d);
444
+ if ('error' in endpoint)
445
+ return endpoint;
446
+ return withLiveEndpoint(ctx, endpoint.adapter, endpoint.endpointKey, async (management) => {
463
447
  if (management.listChannels) {
464
448
  const channels = [...await management.listChannels()];
465
449
  return { data: { channels, count: channels.length } };
466
450
  }
467
- return { error: `当前适配器(${adapter})不支持频道列表` };
468
- }
469
- catch (error) {
470
- return { error: errorMessage(error) };
471
- }
451
+ return { error: `当前适配器(${endpoint.adapter})不支持频道列表` };
452
+ });
472
453
  }
473
454
  async function listGroupMembers(d, ctx) {
474
455
  const adapter = strField(d, '$adapter', 'adapter');
@@ -477,21 +458,14 @@ async function listGroupMembers(d, ctx) {
477
458
  if (!adapter || !endpointKey || !groupId) {
478
459
  return { error: '$adapter, $endpoint, $group_id required' };
479
460
  }
480
- const resolved = resolveLiveEndpoint(ctx, adapter, endpointKey);
481
- if ('error' in resolved)
482
- return resolved;
483
- const { management } = resolved;
484
- try {
461
+ return withLiveEndpoint(ctx, adapter, endpointKey, async (management) => {
485
462
  const method = management.listGroupMembers;
486
463
  if (!method) {
487
464
  return { error: `当前适配器(${adapter})不支持群成员列表` };
488
465
  }
489
466
  const members = [...await method(groupId)];
490
467
  return { data: { members, count: members.length } };
491
- }
492
- catch (error) {
493
- return { error: errorMessage(error) };
494
- }
468
+ });
495
469
  }
496
470
  async function groupWriteOp(d, ctx, spec) {
497
471
  const adapter = strField(d, '$adapter', 'adapter');
@@ -504,15 +478,11 @@ async function groupWriteOp(d, ctx, spec) {
504
478
  if (spec.requireUser && !userId) {
505
479
  return { error: '$user_id required' };
506
480
  }
507
- const resolved = resolveLiveEndpoint(ctx, adapter, endpointKey);
508
- if ('error' in resolved)
509
- return resolved;
510
- const { management } = resolved;
511
- const method = management[spec.method];
512
- if (!method) {
513
- return { error: `当前适配器(${adapter})不支持${spec.unsupported}` };
514
- }
515
- try {
481
+ return withLiveEndpoint(ctx, adapter, endpointKey, async (management) => {
482
+ const method = management[spec.method];
483
+ if (!method) {
484
+ return { error: `当前适配器(${adapter})不支持${spec.unsupported}` };
485
+ }
516
486
  const durationRaw = d.$duration ?? d.duration;
517
487
  const enableRaw = d.$enable ?? d.enable;
518
488
  const args = spec.buildArgs(groupId, userId, {
@@ -523,10 +493,7 @@ async function groupWriteOp(d, ctx, spec) {
523
493
  });
524
494
  await method.call(management, ...args);
525
495
  return { data: { success: true } };
526
- }
527
- catch (error) {
528
- return { error: errorMessage(error) };
529
- }
496
+ });
530
497
  }
531
498
  async function deleteFriend(d, ctx) {
532
499
  const adapter = strField(d, '$adapter', 'adapter');
@@ -535,21 +502,14 @@ async function deleteFriend(d, ctx) {
535
502
  if (!adapter || !endpointKey || !userId) {
536
503
  return { error: '$adapter, $endpoint, $user_id required' };
537
504
  }
538
- const resolved = resolveLiveEndpoint(ctx, adapter, endpointKey);
539
- if ('error' in resolved)
540
- return resolved;
541
- const { management } = resolved;
542
- const method = management.deleteFriend;
543
- if (!method) {
544
- return { error: '当前适配器暂不支持删除好友' };
545
- }
546
- try {
505
+ return withLiveEndpoint(ctx, adapter, endpointKey, async (management) => {
506
+ const method = management.deleteFriend;
507
+ if (!method) {
508
+ return { error: '当前适配器暂不支持删除好友' };
509
+ }
547
510
  await method(userId);
548
511
  return { data: { success: true } };
549
- }
550
- catch (error) {
551
- return { error: errorMessage(error) };
552
- }
512
+ });
553
513
  }
554
514
  // ---------------------------------------------------------------- helpers
555
515
  function strField(d, ...keys) {
@@ -592,35 +552,24 @@ function numArrayField(d, ...keys) {
592
552
  }
593
553
  return [];
594
554
  }
595
- function resolveLiveEndpoint(ctx, adapter, endpointKey) {
596
- if (ctx.resolveEndpointManagement) {
597
- const management = ctx.resolveEndpointManagement(adapter, endpointKey);
598
- if (!management)
599
- return { error: 'endpoint not found' };
600
- return { management, adapter, endpointKey };
601
- }
602
- if (!ctx.resolveEndpoint) {
555
+ async function withLiveEndpoint(ctx, adapter, endpointKey, run) {
556
+ if (!ctx.withEndpointManagement) {
603
557
  return { error: 'Endpoint registry is not configured' };
604
558
  }
605
- const endpoint = ctx.resolveEndpoint(adapter, endpointKey);
606
- if (!endpoint || typeof endpoint !== 'object') {
607
- return { error: 'endpoint not found' };
559
+ try {
560
+ return await ctx.withEndpointManagement(adapter, endpointKey, run)
561
+ ?? { error: 'endpoint not found' };
562
+ }
563
+ catch (error) {
564
+ return { error: errorMessage(error) };
608
565
  }
609
- const management = endpoint.management;
610
- return {
611
- management: management && typeof management === 'object'
612
- ? management
613
- : {},
614
- adapter,
615
- endpointKey,
616
- };
617
566
  }
618
- function requireEndpoint(d, ctx) {
567
+ function requireEndpointAddress(d) {
619
568
  const adapter = strField(d, '$adapter', 'adapter');
620
569
  const endpointKey = strField(d, '$endpoint', 'endpointKey', 'endpoint');
621
570
  if (!adapter || !endpointKey)
622
571
  return { error: '$adapter and $endpoint required' };
623
- return resolveLiveEndpoint(ctx, adapter, endpointKey);
572
+ return { adapter, endpointKey };
624
573
  }
625
574
  function normalizeParent(raw) {
626
575
  if (!raw || typeof raw !== 'object')
@@ -1,5 +1,6 @@
1
1
  import { type IncomingMessage, type ServerResponse } from 'node:http';
2
- import { type WebSocket } from 'ws';
2
+ import { WebSocket } from 'ws';
3
+ import { type GenerationAdmissionBindable } from '@zhin.js/plugin-runtime';
3
4
  import { TokenRegistry, type AuthScope, type ScopedTokenConfig } from './token-registry.js';
4
5
  import { type ListedRoute, type RouteMeta } from './openapi.js';
5
6
  export declare const REMOTE_CONSOLE_ORIGIN = "https://console.zhin.dev";
@@ -37,14 +38,16 @@ export type HttpHandler = (request: IncomingMessage, response: ServerResponse, u
37
38
  export interface HttpRouteRegistration {
38
39
  (): void;
39
40
  }
40
- export interface HttpHost {
41
+ export interface HttpHost extends GenerationAdmissionBindable<HttpHost> {
41
42
  ws(path: string): WsHandle;
42
43
  route(method: string, path: string, handler: HttpHandler, meta?: RouteMeta): HttpRouteRegistration;
43
44
  listRoutes(): readonly ListedRoute[];
44
- listen(): Promise<HttpHostAddress>;
45
- close(): Promise<void>;
46
45
  get address(): HttpHostAddress | undefined;
47
46
  get tokenRegistry(): TokenRegistry;
48
47
  }
48
+ export interface ProcessHttpHost extends HttpHost {
49
+ listen(): Promise<HttpHostAddress>;
50
+ close(): Promise<void>;
51
+ }
49
52
  export declare const httpHostToken: import("@zhin.js/plugin-runtime").Token<HttpHost>;
50
- export declare function createHttpHost(options?: HttpHostOptions): HttpHost;
53
+ export declare function createHttpHost(options?: HttpHostOptions): ProcessHttpHost;
package/lib/http-host.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { createServer, } from 'node:http';
2
- import { WebSocketServer } from 'ws';
2
+ import { WebSocket, WebSocketServer } from 'ws';
3
3
  import { formatCompact, getLogger } from '@zhin.js/logger';
4
- import { createToken } from '@zhin.js/plugin-runtime';
4
+ import { createToken, generationAdmissionBinder, } from '@zhin.js/plugin-runtime';
5
5
  import { TokenRegistry, extractBearerToken, isDemoWebSocketPath, } from './token-registry.js';
6
6
  import { buildOpenApiDocument, } from './openapi.js';
7
7
  import { isDemoHttpAllowed } from './console-rpc.js';
@@ -26,6 +26,7 @@ export function createHttpHost(options = {}) {
26
26
  const httpRoutes = [];
27
27
  let address;
28
28
  let closed = false;
29
+ let closeResult;
29
30
  const server = createServer((request, response) => {
30
31
  void dispatchHttp(request, response);
31
32
  });
@@ -78,7 +79,13 @@ export function createHttpHost(options = {}) {
78
79
  }
79
80
  const pathname = upgradePath(request.url);
80
81
  const listeners = wsRoutes.get(pathname);
81
- if (!listeners || listeners.size === 0) {
82
+ const admitted = listeners ? [...listeners].flatMap((entry) => {
83
+ const release = entry.admission?.acquire();
84
+ if (entry.admission && !release)
85
+ return [];
86
+ return [{ entry, release }];
87
+ }) : [];
88
+ if (admitted.length === 0) {
82
89
  logger.debug(formatCompact({ op: 'ws_upgrade_unmatched', path: pathname }));
83
90
  socket.destroy();
84
91
  return;
@@ -87,20 +94,46 @@ export function createHttpHost(options = {}) {
87
94
  if (!auth.ok) {
88
95
  socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
89
96
  socket.destroy();
97
+ for (const item of admitted)
98
+ item.release?.();
90
99
  return;
91
100
  }
92
101
  try {
93
102
  wss.handleUpgrade(request, socket, head, (ws) => {
103
+ let settled = false;
104
+ const retireSubscriptions = [];
105
+ const releaseAdmissions = () => {
106
+ if (settled)
107
+ return;
108
+ settled = true;
109
+ for (const dispose of retireSubscriptions)
110
+ dispose();
111
+ for (const item of admitted)
112
+ item.release?.();
113
+ };
114
+ ws.once('close', releaseAdmissions);
115
+ ws.once('error', releaseAdmissions);
116
+ for (const item of admitted) {
117
+ if (item.entry.admission) {
118
+ retireSubscriptions.push(item.entry.admission.onDeactivate(() => {
119
+ if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
120
+ ws.close(1012, 'Generation retired');
121
+ }
122
+ }));
123
+ }
124
+ }
94
125
  const connection = Object.freeze({
95
126
  socket: ws,
96
127
  request,
97
128
  authScope: auth.scope,
98
129
  });
99
- for (const listener of listeners)
100
- listener(connection);
130
+ for (const item of admitted)
131
+ item.entry.listener(connection);
101
132
  });
102
133
  }
103
134
  catch (err) {
135
+ for (const item of admitted)
136
+ item.release?.();
104
137
  logger.warn(formatCompact({
105
138
  op: 'ws_upgrade_failed',
106
139
  path: pathname,
@@ -178,7 +211,30 @@ export function createHttpHost(options = {}) {
178
211
  applyCors(response, origin, corsOrigins);
179
212
  response.setHeader('X-Content-Type-Options', 'nosniff');
180
213
  response.setHeader('X-Frame-Options', 'SAMEORIGIN');
214
+ const release = route.admission?.acquire();
215
+ if (route.admission && !release) {
216
+ response.writeHead(503);
217
+ response.end();
218
+ return;
219
+ }
220
+ if (!release) {
221
+ await route.handler(request, response, url, auth.scope);
222
+ return;
223
+ }
224
+ let released = false;
225
+ const releaseResponse = () => {
226
+ if (released)
227
+ return;
228
+ released = true;
229
+ response.off('finish', releaseResponse);
230
+ response.off('close', releaseResponse);
231
+ release();
232
+ };
233
+ response.once('finish', releaseResponse);
234
+ response.once('close', releaseResponse);
181
235
  await route.handler(request, response, url, auth.scope);
236
+ if (response.writableFinished || response.destroyed)
237
+ releaseResponse();
182
238
  }
183
239
  catch (err) {
184
240
  // 统一把请求体错误(400/413)映射回对应状态码,
@@ -219,57 +275,75 @@ export function createHttpHost(options = {}) {
219
275
  return { ok: true, scope };
220
276
  }
221
277
  function listListedRoutes() {
222
- return Object.freeze(httpRoutes.map((route) => Object.freeze({
278
+ return Object.freeze(httpRoutes
279
+ .filter((route) => !route.admission || route.admission.active)
280
+ .map((route) => Object.freeze({
223
281
  method: route.method,
224
282
  pattern: route.pattern,
225
283
  meta: route.meta,
226
284
  })));
227
285
  }
228
- return {
229
- ws(pathname) {
230
- const normalized = normalizePath(pathname);
231
- let listeners = wsRoutes.get(normalized);
232
- if (!listeners) {
233
- listeners = new Set();
234
- wsRoutes.set(normalized, listeners);
235
- }
236
- const owned = new Set();
237
- return {
238
- onConnection(listener) {
239
- listeners.add(listener);
240
- owned.add(listener);
241
- return () => {
242
- listeners.delete(listener);
243
- owned.delete(listener);
244
- };
245
- },
246
- close() {
247
- for (const listener of owned)
248
- listeners.delete(listener);
249
- owned.clear();
250
- },
251
- };
252
- },
253
- route(method, path, handler, meta) {
254
- const normalizedMethod = method.toUpperCase();
255
- const prefix = path.endsWith('/*');
256
- const normalizedPath = normalizePath(prefix ? path.slice(0, -2) : path);
257
- const pattern = prefix ? `${normalizedPath}/*` : normalizedPath;
258
- const entry = Object.freeze({
259
- method: normalizedMethod,
260
- path: normalizedPath,
261
- pattern,
262
- prefix,
263
- handler,
264
- meta,
265
- });
266
- httpRoutes.push(entry);
267
- return () => {
268
- const index = httpRoutes.indexOf(entry);
269
- if (index >= 0)
270
- httpRoutes.splice(index, 1);
271
- };
272
- },
286
+ function registerWs(pathname, admission) {
287
+ const normalized = normalizePath(pathname);
288
+ let listeners = wsRoutes.get(normalized);
289
+ if (!listeners) {
290
+ listeners = new Set();
291
+ wsRoutes.set(normalized, listeners);
292
+ }
293
+ const owned = new Set();
294
+ return {
295
+ onConnection(listener) {
296
+ const entry = Object.freeze({ listener, admission });
297
+ listeners.add(entry);
298
+ owned.add(entry);
299
+ return () => {
300
+ listeners.delete(entry);
301
+ owned.delete(entry);
302
+ };
303
+ },
304
+ close() {
305
+ for (const entry of owned)
306
+ listeners.delete(entry);
307
+ owned.clear();
308
+ },
309
+ };
310
+ }
311
+ function registerRoute(method, path, handler, meta, admission) {
312
+ const normalizedMethod = method.toUpperCase();
313
+ const prefix = path.endsWith('/*');
314
+ const normalizedPath = normalizePath(prefix ? path.slice(0, -2) : path);
315
+ const pattern = prefix ? `${normalizedPath}/*` : normalizedPath;
316
+ const entry = Object.freeze({
317
+ method: normalizedMethod,
318
+ path: normalizedPath,
319
+ pattern,
320
+ prefix,
321
+ handler,
322
+ meta,
323
+ admission,
324
+ });
325
+ httpRoutes.push(entry);
326
+ return () => {
327
+ const index = httpRoutes.indexOf(entry);
328
+ if (index >= 0)
329
+ httpRoutes.splice(index, 1);
330
+ };
331
+ }
332
+ function createBoundHost(admission) {
333
+ const bound = {
334
+ [generationAdmissionBinder]: (next) => createBoundHost(next),
335
+ ws: (path) => registerWs(path, admission),
336
+ route: (method, path, handler, meta) => registerRoute(method, path, handler, meta, admission),
337
+ listRoutes: () => listListedRoutes(),
338
+ get address() { return runtime.address; },
339
+ get tokenRegistry() { return tokenRegistry; },
340
+ };
341
+ return Object.freeze(bound);
342
+ }
343
+ const runtime = {
344
+ [generationAdmissionBinder]: (admission) => createBoundHost(admission),
345
+ ws: (pathname) => registerWs(pathname),
346
+ route: (method, path, handler, meta) => registerRoute(method, path, handler, meta),
273
347
  listRoutes() {
274
348
  return listListedRoutes();
275
349
  },
@@ -302,17 +376,18 @@ export function createHttpHost(options = {}) {
302
376
  });
303
377
  },
304
378
  close() {
305
- if (closed)
306
- return Promise.resolve();
379
+ if (closeResult)
380
+ return closeResult;
307
381
  closed = true;
308
382
  wsRoutes.clear();
309
383
  httpRoutes.length = 0;
310
384
  // Rollback paths dispose a host whose generation never reached listen().
311
385
  if (!server.listening) {
312
386
  address = undefined;
313
- return Promise.resolve();
387
+ closeResult = Promise.resolve();
388
+ return closeResult;
314
389
  }
315
- return new Promise((resolve, reject) => {
390
+ closeResult = new Promise((resolve, reject) => {
316
391
  // Long-lived connections (SSE, keep-alive, WS) never end on their own:
317
392
  // destroy tracked sockets and terminate WS clients before close(),
318
393
  // otherwise the close callbacks would never fire.
@@ -337,8 +412,10 @@ export function createHttpHost(options = {}) {
337
412
  });
338
413
  });
339
414
  });
415
+ return closeResult;
340
416
  },
341
417
  };
418
+ return runtime;
342
419
  }
343
420
  function requiresHttpAuth(pathname, apiBase, authExempt) {
344
421
  if (pathname === '/pub' || pathname.startsWith('/pub/'))
@@ -378,6 +455,8 @@ function matchHttpRoute(routes, method, pathname) {
378
455
  let best;
379
456
  let bestLength = -1;
380
457
  for (const route of routes) {
458
+ if (route.admission && !route.admission.active)
459
+ continue;
381
460
  if (route.method !== method && route.method !== 'ALL')
382
461
  continue;
383
462
  if (route.prefix) {
package/lib/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export { createHttpHost, httpHostToken, REMOTE_CONSOLE_ORIGIN, type HttpHandler, type HttpHost, type HttpHostAddress, type HttpHostOptions, type HttpRouteRegistration, type WsConnection, type WsHandle, } from './http-host.js';
1
+ export { createHttpHost, httpHostToken, REMOTE_CONSOLE_ORIGIN, type HttpHandler, type HttpHost, type ProcessHttpHost, type HttpHostAddress, type HttpHostOptions, type HttpRouteRegistration, type WsConnection, type WsHandle, } from './http-host.js';
2
2
  export { TokenRegistry, extractBearerToken, isDemoWebSocketPath, type AuthScope, type ScopedTokenConfig, type TokenRegistryConfig, } from './token-registry.js';
3
3
  export { timingSafeEqualString } from './timing-safe-equal.js';
4
4
  export { createConsoleEventHub, type ConsoleEventHub, } from './console-events.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhin.js/host-http",
3
- "version": "1.0.8",
3
+ "version": "1.0.10",
4
4
  "description": "Minimal HTTP and WebSocket host for Plugin Runtime (no Koa)",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",
@@ -18,10 +18,10 @@
18
18
  ],
19
19
  "dependencies": {
20
20
  "ws": "^8.21.1",
21
- "@zhin.js/console-protocol": "1.1.1",
21
+ "@zhin.js/console-protocol": "1.1.2",
22
22
  "@zhin.js/host-http-contract": "1.0.1",
23
23
  "@zhin.js/logger": "1.0.76",
24
- "@zhin.js/plugin-runtime": "1.1.5"
24
+ "@zhin.js/plugin-runtime": "1.1.6"
25
25
  },
26
26
  "devDependencies": {
27
27
  "@types/node": "^26.1.2",
@@ -11,8 +11,8 @@
11
11
  * - inbox:`ctx.databaseHost.models`(unified_inbox_message/request/notice 三张表,
12
12
  * 表不存在或查询失败 → 空数组 + inboxEnabled:false;requestConsumed/noticeConsumed
13
13
  * 按行 id update consumed=1,表/model 缺失时报"未接线")。
14
- * - 社交/群管:`ctx.resolveEndpoint` 拿 live endpoint 实例,只消费其
15
- * `management` 语义端口;平台 SDK 兼容与字段归一化由 Adapter 负责。
14
+ * - 社交/群管:`ctx.withEndpointManagement` 在固定 generation lease 内
15
+ * 消费 `management` 语义端口;平台 SDK 字段归一化由 Adapter 负责。
16
16
  */
17
17
 
18
18
  /** 新 Runtime 统一收件箱表名(SSOT:@zhin.js/plugin-runtime inbox.js)。 */
@@ -31,13 +31,12 @@ export interface ConsoleRpcExtendedCtx {
31
31
  projectRoot: string;
32
32
  /** Plugin Runtime ScheduleHost(basic/cli schedule-host-installer 提供)。 */
33
33
  scheduleHost?: unknown;
34
- /** 经 ImRuntime / AdapterEndpointIndex 解析管理语义端口。 */
35
- resolveEndpointManagement?: (
34
+ /** 在固定 generation lease 内执行一次 Endpoint 管理操作。 */
35
+ withEndpointManagement?: <T>(
36
36
  adapter: string,
37
37
  endpointKey: string,
38
- ) => EndpointManagementPort | null | undefined;
39
- /** @deprecated Use `resolveEndpointManagement`; accepted for one compatibility cycle. */
40
- resolveEndpoint?: (adapter: string, endpointKey: string) => unknown;
38
+ run: (management: EndpointManagementPort) => T | Promise<T>,
39
+ ) => Promise<T | null>;
41
40
  /** Plugin Runtime DatabaseHost 的 models 视图。 */
42
41
  databaseHost?: { models: { get(name: string): unknown } };
43
42
  /** Agent Host 持久化调度引擎(@zhin.js/agent getAssistantRuntime().engine),未 init 时返回 null。 */
@@ -530,26 +529,21 @@ async function actOnRequest(
530
529
  if (!adapter || !endpointKey || !requestId) {
531
530
  return { error: '$adapter, $endpoint, $id required' };
532
531
  }
533
- const resolved = resolveLiveEndpoint(ctx, adapter, endpointKey);
534
- if ('error' in resolved) return resolved;
535
- const management = resolved.management;
536
- const approve = type === 'request.approve';
537
- const method = approve ? management.approveRequest : management.rejectRequest;
538
- if (!method) {
539
- return {
540
- error: `请求审批未接线:当前平台(${adapter})endpoint 不支持 ${approve ? 'approveRequest' : 'rejectRequest'},` +
541
- '且新 Runtime 尚未挂载 pending request 注册表',
542
- };
543
- }
544
- try {
532
+ return withLiveEndpoint(ctx, adapter, endpointKey, async (management) => {
533
+ const approve = type === 'request.approve';
534
+ const method = approve ? management.approveRequest : management.rejectRequest;
535
+ if (!method) {
536
+ return {
537
+ error: `请求审批未接线:当前平台(${adapter})endpoint 不支持 ${approve ? 'approveRequest' : 'rejectRequest'},` +
538
+ '且新 Runtime 尚未挂载 pending request 注册表',
539
+ };
540
+ }
545
541
  const extra = approve
546
542
  ? strField(d, '$remark', 'remark')
547
543
  : strField(d, '$reason', 'reason');
548
544
  await method.call(management, requestId, extra || undefined);
549
545
  return { data: { success: true } };
550
- } catch (error) {
551
- return { error: errorMessage(error) };
552
- }
546
+ });
553
547
  }
554
548
 
555
549
  // ---------------------------------------------------------------- 社交读取
@@ -558,54 +552,45 @@ async function listFriends(
558
552
  d: Record<string, unknown>,
559
553
  ctx: ConsoleRpcExtendedCtx,
560
554
  ): Promise<ExtendedRpcResult> {
561
- const resolved = requireEndpoint(d, ctx);
562
- if ('error' in resolved) return resolved;
563
- const { management, adapter } = resolved;
564
- try {
555
+ const endpoint = requireEndpointAddress(d);
556
+ if ('error' in endpoint) return endpoint;
557
+ return withLiveEndpoint(ctx, endpoint.adapter, endpoint.endpointKey, async (management) => {
565
558
  if (management.listFriends) {
566
559
  const friends = [...await management.listFriends()];
567
560
  return { data: { friends, count: friends.length } };
568
561
  }
569
- return { error: `当前适配器(${adapter})不支持好友列表` };
570
- } catch (error) {
571
- return { error: errorMessage(error) };
572
- }
562
+ return { error: `当前适配器(${endpoint.adapter})不支持好友列表` };
563
+ });
573
564
  }
574
565
 
575
566
  async function listGroups(
576
567
  d: Record<string, unknown>,
577
568
  ctx: ConsoleRpcExtendedCtx,
578
569
  ): Promise<ExtendedRpcResult> {
579
- const resolved = requireEndpoint(d, ctx);
580
- if ('error' in resolved) return resolved;
581
- const { management, adapter } = resolved;
582
- try {
570
+ const endpoint = requireEndpointAddress(d);
571
+ if ('error' in endpoint) return endpoint;
572
+ return withLiveEndpoint(ctx, endpoint.adapter, endpoint.endpointKey, async (management) => {
583
573
  if (management.listGroups) {
584
574
  const groups = [...await management.listGroups()];
585
575
  return { data: { groups, count: groups.length } };
586
576
  }
587
- return { error: `当前适配器(${adapter})不支持群列表` };
588
- } catch (error) {
589
- return { error: errorMessage(error) };
590
- }
577
+ return { error: `当前适配器(${endpoint.adapter})不支持群列表` };
578
+ });
591
579
  }
592
580
 
593
581
  async function listChannels(
594
582
  d: Record<string, unknown>,
595
583
  ctx: ConsoleRpcExtendedCtx,
596
584
  ): Promise<ExtendedRpcResult> {
597
- const resolved = requireEndpoint(d, ctx);
598
- if ('error' in resolved) return resolved;
599
- const { management, adapter } = resolved;
600
- try {
585
+ const endpoint = requireEndpointAddress(d);
586
+ if ('error' in endpoint) return endpoint;
587
+ return withLiveEndpoint(ctx, endpoint.adapter, endpoint.endpointKey, async (management) => {
601
588
  if (management.listChannels) {
602
589
  const channels = [...await management.listChannels()];
603
590
  return { data: { channels, count: channels.length } };
604
591
  }
605
- return { error: `当前适配器(${adapter})不支持频道列表` };
606
- } catch (error) {
607
- return { error: errorMessage(error) };
608
- }
592
+ return { error: `当前适配器(${endpoint.adapter})不支持频道列表` };
593
+ });
609
594
  }
610
595
 
611
596
  async function listGroupMembers(
@@ -618,19 +603,14 @@ async function listGroupMembers(
618
603
  if (!adapter || !endpointKey || !groupId) {
619
604
  return { error: '$adapter, $endpoint, $group_id required' };
620
605
  }
621
- const resolved = resolveLiveEndpoint(ctx, adapter, endpointKey);
622
- if ('error' in resolved) return resolved;
623
- const { management } = resolved;
624
- try {
606
+ return withLiveEndpoint(ctx, adapter, endpointKey, async (management) => {
625
607
  const method = management.listGroupMembers;
626
608
  if (!method) {
627
609
  return { error: `当前适配器(${adapter})不支持群成员列表` };
628
610
  }
629
611
  const members = [...await method(groupId)];
630
612
  return { data: { members, count: members.length } };
631
- } catch (error) {
632
- return { error: errorMessage(error) };
633
- }
613
+ });
634
614
  }
635
615
 
636
616
  // ---------------------------------------------------------------- 群管/好友写操作
@@ -658,14 +638,11 @@ async function groupWriteOp(
658
638
  if (spec.requireUser && !userId) {
659
639
  return { error: '$user_id required' };
660
640
  }
661
- const resolved = resolveLiveEndpoint(ctx, adapter, endpointKey);
662
- if ('error' in resolved) return resolved;
663
- const { management } = resolved;
664
- const method = management[spec.method];
665
- if (!method) {
666
- return { error: `当前适配器(${adapter})不支持${spec.unsupported}` };
667
- }
668
- try {
641
+ return withLiveEndpoint(ctx, adapter, endpointKey, async (management) => {
642
+ const method = management[spec.method];
643
+ if (!method) {
644
+ return { error: `当前适配器(${adapter})不支持${spec.unsupported}` };
645
+ }
669
646
  const durationRaw = d.$duration ?? d.duration;
670
647
  const enableRaw = d.$enable ?? d.enable;
671
648
  const args = spec.buildArgs(groupId, userId, {
@@ -676,9 +653,7 @@ async function groupWriteOp(
676
653
  });
677
654
  await (method as (...args: unknown[]) => Promise<void>).call(management, ...args);
678
655
  return { data: { success: true } };
679
- } catch (error) {
680
- return { error: errorMessage(error) };
681
- }
656
+ });
682
657
  }
683
658
 
684
659
  async function deleteFriend(
@@ -691,19 +666,14 @@ async function deleteFriend(
691
666
  if (!adapter || !endpointKey || !userId) {
692
667
  return { error: '$adapter, $endpoint, $user_id required' };
693
668
  }
694
- const resolved = resolveLiveEndpoint(ctx, adapter, endpointKey);
695
- if ('error' in resolved) return resolved;
696
- const { management } = resolved;
697
- const method = management.deleteFriend;
698
- if (!method) {
699
- return { error: '当前适配器暂不支持删除好友' };
700
- }
701
- try {
669
+ return withLiveEndpoint(ctx, adapter, endpointKey, async (management) => {
670
+ const method = management.deleteFriend;
671
+ if (!method) {
672
+ return { error: '当前适配器暂不支持删除好友' };
673
+ }
702
674
  await method(userId);
703
675
  return { data: { success: true } };
704
- } catch (error) {
705
- return { error: errorMessage(error) };
706
- }
676
+ });
707
677
  }
708
678
 
709
679
  // ---------------------------------------------------------------- helpers
@@ -746,47 +716,30 @@ function numArrayField(d: Record<string, unknown>, ...keys: string[]): number[]
746
716
  return [];
747
717
  }
748
718
 
749
- type ResolveOk = {
750
- management: EndpointManagementPort;
751
- adapter: string;
752
- endpointKey: string;
753
- };
754
-
755
- function resolveLiveEndpoint(
719
+ async function withLiveEndpoint(
756
720
  ctx: ConsoleRpcExtendedCtx,
757
721
  adapter: string,
758
722
  endpointKey: string,
759
- ): ResolveOk | { error: string } {
760
- if (ctx.resolveEndpointManagement) {
761
- const management = ctx.resolveEndpointManagement(adapter, endpointKey);
762
- if (!management) return { error: 'endpoint not found' };
763
- return { management, adapter, endpointKey };
764
- }
765
- if (!ctx.resolveEndpoint) {
723
+ run: (management: EndpointManagementPort) => ExtendedRpcResult | Promise<ExtendedRpcResult>,
724
+ ): Promise<ExtendedRpcResult> {
725
+ if (!ctx.withEndpointManagement) {
766
726
  return { error: 'Endpoint registry is not configured' };
767
727
  }
768
- const endpoint = ctx.resolveEndpoint(adapter, endpointKey);
769
- if (!endpoint || typeof endpoint !== 'object') {
770
- return { error: 'endpoint not found' };
728
+ try {
729
+ return await ctx.withEndpointManagement(adapter, endpointKey, run)
730
+ ?? { error: 'endpoint not found' };
731
+ } catch (error) {
732
+ return { error: errorMessage(error) };
771
733
  }
772
- const management = (endpoint as { management?: unknown }).management;
773
- return {
774
- management: management && typeof management === 'object'
775
- ? management as EndpointManagementPort
776
- : {},
777
- adapter,
778
- endpointKey,
779
- };
780
734
  }
781
735
 
782
- function requireEndpoint(
736
+ function requireEndpointAddress(
783
737
  d: Record<string, unknown>,
784
- ctx: ConsoleRpcExtendedCtx,
785
- ): ResolveOk | { error: string } {
738
+ ): { adapter: string; endpointKey: string } | { error: string } {
786
739
  const adapter = strField(d, '$adapter', 'adapter');
787
740
  const endpointKey = strField(d, '$endpoint', 'endpointKey', 'endpoint');
788
741
  if (!adapter || !endpointKey) return { error: '$adapter and $endpoint required' };
789
- return resolveLiveEndpoint(ctx, adapter, endpointKey);
742
+ return { adapter, endpointKey };
790
743
  }
791
744
 
792
745
  function normalizeParent(raw: unknown): { type: 'group' | 'guild'; id: string; name?: string } | undefined {
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,
@@ -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();
@@ -418,16 +514,17 @@ export function createHttpHost(options: HttpHostOptions = {}): HttpHost {
418
514
  },
419
515
 
420
516
  close(): Promise<void> {
421
- if (closed) return Promise.resolve();
517
+ if (closeResult) return closeResult;
422
518
  closed = true;
423
519
  wsRoutes.clear();
424
520
  httpRoutes.length = 0;
425
521
  // Rollback paths dispose a host whose generation never reached listen().
426
522
  if (!server.listening) {
427
523
  address = undefined;
428
- return Promise.resolve();
524
+ closeResult = Promise.resolve();
525
+ return closeResult;
429
526
  }
430
- return new Promise((resolve, reject) => {
527
+ closeResult = new Promise((resolve, reject) => {
431
528
  // Long-lived connections (SSE, keep-alive, WS) never end on their own:
432
529
  // destroy tracked sockets and terminate WS clients before close(),
433
530
  // otherwise the close callbacks would never fire.
@@ -450,8 +547,10 @@ export function createHttpHost(options: HttpHostOptions = {}): HttpHost {
450
547
  });
451
548
  });
452
549
  });
550
+ return closeResult;
453
551
  },
454
552
  };
553
+ return runtime;
455
554
  }
456
555
 
457
556
  function requiresHttpAuth(
@@ -507,6 +606,7 @@ function matchHttpRoute(routes: readonly HttpRoute[], method: string, pathname:
507
606
  let best: HttpRoute | undefined;
508
607
  let bestLength = -1;
509
608
  for (const route of routes) {
609
+ if (route.admission && !route.admission.active) continue;
510
610
  if (route.method !== method && route.method !== 'ALL') continue;
511
611
  if (route.prefix) {
512
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,