@zhin.js/host-http 1.0.14 → 1.0.16

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhin.js/host-http",
3
- "version": "1.0.14",
3
+ "version": "1.0.16",
4
4
  "description": "Minimal HTTP and WebSocket host for Plugin Runtime (no Koa)",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",
@@ -7,6 +7,7 @@ import type {
7
7
  ConsoleEventPayloadMap,
8
8
  KnownConsoleEventType,
9
9
  } from '@zhin.js/console-protocol';
10
+ import { createToken } from '@zhin.js/plugin-runtime';
10
11
 
11
12
  /**
12
13
  * Console 实时事件枢纽(Plugin Runtime Host 的 `/api/events` SSE 事件源)。
@@ -27,12 +28,17 @@ export interface ConsoleEventHub {
27
28
  Type extends KnownConsoleEventType ? ConsoleEventPayloadMap[Type] : unknown
28
29
  >;
29
30
  history(query?: ConsoleEventHistoryQuery): ConsoleEventHistoryPage;
31
+ /** In-process delivery for trusted Host bridges such as Device Protocol. */
32
+ listen(listener: (event: ConsoleEventEnvelope) => void): () => void;
30
33
  /** Atomically replay the resumable suffix, then join live fan-out. */
31
34
  subscribe(response: ServerResponse, options?: ConsoleEventSubscriptionOptions): () => void;
32
35
  readonly runtimeId: string;
33
36
  readonly subscriberCount: number;
34
37
  }
35
38
 
39
+ /** Shared Console event authority installed by the Plugin Runtime composition root. */
40
+ export const consoleEventHubToken = createToken<ConsoleEventHub>('zhin.host.console-events');
41
+
36
42
  export interface ConsoleEventHubOptions {
37
43
  readonly runtimeId?: string;
38
44
  readonly historyLimit?: number;
@@ -65,6 +71,7 @@ export function createConsoleEventHub(options: ConsoleEventHubOptions = {}): Con
65
71
  const journal: JournalEntry[] = [];
66
72
  let journalBytes = 0;
67
73
  const subscribers = new Set<ServerResponse>();
74
+ const listeners = new Set<(event: ConsoleEventEnvelope) => void>();
68
75
 
69
76
  const remove = (response: ServerResponse): void => {
70
77
  subscribers.delete(response);
@@ -120,6 +127,9 @@ export function createConsoleEventHub(options: ConsoleEventHubOptions = {}): Con
120
127
  remove(response);
121
128
  }
122
129
  }
130
+ for (const listener of [...listeners]) {
131
+ try { listener(event); } catch { /* isolate trusted bridge failures */ }
132
+ }
123
133
  return event as ConsoleEventEnvelope<
124
134
  Type,
125
135
  Type extends KnownConsoleEventType ? ConsoleEventPayloadMap[Type] : unknown
@@ -158,6 +168,10 @@ export function createConsoleEventHub(options: ConsoleEventHubOptions = {}): Con
158
168
  gap,
159
169
  });
160
170
  },
171
+ listen(listener) {
172
+ listeners.add(listener);
173
+ return () => listeners.delete(listener);
174
+ },
161
175
  subscribe(response, options = {}) {
162
176
  // Replay and subscription are synchronous relative to publish(), closing
163
177
  // the race between HTTP recovery and the live stream hand-off.
@@ -66,6 +66,15 @@ export interface ConsoleRpcExtendedCtx {
66
66
  }
67
67
 
68
68
  export interface ConsoleWorkroomProfileControlPort {
69
+ getPlanningStatus(projectId: string,
70
+ authenticatedPrincipal?: Readonly<{ principalId: string }>): Promise<unknown>;
71
+ bootstrapPlanning(command: Readonly<{
72
+ operationId: string;
73
+ projectId: string;
74
+ expectedRegistryRevision: number;
75
+ includeTools?: readonly string[];
76
+ includeSkills?: readonly string[];
77
+ }>, authenticatedPrincipal: Readonly<{ principalId: string }>): Promise<unknown>;
69
78
  publishPack(command: Readonly<{ operationId: string; pack: unknown }>,
70
79
  authenticatedPrincipal: Readonly<{ principalId: string }>): Promise<unknown>;
71
80
  publishProfile(command: Readonly<{
@@ -208,6 +217,8 @@ export async function dispatchExtendedConsoleRpc(
208
217
  return listInbox(d, ctx, TABLE_NOTICE, 'notices', mapNoticeRow);
209
218
  case 'inbox.messages':
210
219
  return listInboxMessages(d, ctx);
220
+ case 'inbox.recent':
221
+ return listRecentInboxMessages(d, ctx);
211
222
 
212
223
  case 'request.approve':
213
224
  case 'request.reject':
@@ -258,6 +269,8 @@ export async function dispatchExtendedConsoleRpc(
258
269
  case 'endpoint.delete_friend':
259
270
  return deleteFriend(d, ctx);
260
271
 
272
+ case 'workroom.profile.status':
273
+ case 'workroom.profile.bootstrap':
261
274
  case 'workroom.profile.pack.publish':
262
275
  case 'workroom.profile.publish':
263
276
  case 'workroom.profile.rollback':
@@ -275,7 +288,8 @@ export async function dispatchExtendedConsoleRpc(
275
288
  }
276
289
 
277
290
  async function mutateWorkroomProfile(
278
- type: 'workroom.profile.pack.publish' | 'workroom.profile.publish'
291
+ type: 'workroom.profile.status' | 'workroom.profile.bootstrap'
292
+ | 'workroom.profile.pack.publish' | 'workroom.profile.publish'
279
293
  | 'workroom.profile.rollback' | 'workroom.profile.policy.publish',
280
294
  data: Record<string, unknown>,
281
295
  ctx: ConsoleRpcExtendedCtx,
@@ -287,10 +301,36 @@ async function mutateWorkroomProfile(
287
301
  ]) {
288
302
  if (forbidden in data) return { error: `Console Workroom Profile request cannot carry ${forbidden}` };
289
303
  }
304
+ if (type === 'workroom.profile.status') {
305
+ try {
306
+ return { data: await control.getPlanningStatus(
307
+ requiredRpcText(data.projectId, 'projectId'),
308
+ ctx.authenticatedPrincipal,
309
+ ) };
310
+ } catch (error) {
311
+ return { error: errorMessage(error) };
312
+ }
313
+ }
290
314
  const principal = ctx.authenticatedPrincipal;
291
315
  if (!principal) return { error: 'Workroom Profile control requires an authenticated principal' };
292
316
  try {
293
317
  const operationId = requiredRpcText(data.operationId, 'operationId');
318
+ if (type === 'workroom.profile.bootstrap') {
319
+ const projectId = requiredRpcText(data.projectId, 'projectId');
320
+ const includeTools = optionalRpcTextArray(data.includeTools, 'includeTools');
321
+ const includeSkills = optionalRpcTextArray(data.includeSkills, 'includeSkills');
322
+ return { data: await control.bootstrapPlanning(Object.freeze({
323
+ operationId,
324
+ projectId,
325
+ expectedRegistryRevision: requiredRpcInteger(
326
+ data.expectedRegistryRevision,
327
+ 'expectedRegistryRevision',
328
+ -1,
329
+ ),
330
+ ...(includeTools ? { includeTools } : {}),
331
+ ...(includeSkills ? { includeSkills } : {}),
332
+ }), principal) };
333
+ }
294
334
  if (type === 'workroom.profile.pack.publish') {
295
335
  return { data: await control.publishPack(Object.freeze({ operationId, pack: requiredRpcValue(data.pack, 'pack') }), principal) };
296
336
  }
@@ -374,6 +414,14 @@ function optionalRpcText(value: unknown, name: string): string | undefined {
374
414
  return value === undefined ? undefined : requiredRpcText(value, name);
375
415
  }
376
416
 
417
+ function optionalRpcTextArray(value: unknown, name: string): readonly string[] | undefined {
418
+ if (value === undefined) return undefined;
419
+ if (!Array.isArray(value)) throw new Error(`${name} is invalid`);
420
+ const result = value.map((item, index) => requiredRpcText(item, `${name}.${index}`));
421
+ if (new Set(result).size !== result.length) throw new Error(`${name} contains duplicates`);
422
+ return Object.freeze(result);
423
+ }
424
+
377
425
  function requiredRpcInteger(value: unknown, name: string, minimum = 1): number {
378
426
  if (!Number.isSafeInteger(value) || Number(value) < minimum) throw new Error(`${name} is invalid`);
379
427
  return Number(value);
@@ -673,6 +721,51 @@ async function listInboxMessages(
673
721
  return { data: { messages, inboxEnabled: enabled } };
674
722
  }
675
723
 
724
+ /**
725
+ * Cross-endpoint recent inbox used by paired Mobile Console clients.
726
+ * Unlike endpoint-detail `inbox.messages`, every row carries its complete
727
+ * routable address so selecting a conversation can safely call
728
+ * `endpoint.send_message` through the Device Protocol write allowlist.
729
+ */
730
+ async function listRecentInboxMessages(
731
+ d: Record<string, unknown>,
732
+ ctx: ConsoleRpcExtendedCtx,
733
+ ): Promise<ExtendedRpcResult> {
734
+ const limit = boundedIntegerField(d, 50, 1, 100, '$limit', 'limit');
735
+ const [messagePage, requestPage] = await Promise.all([
736
+ readInboxRows(ctx, TABLE_MESSAGE, {}, {
737
+ orderBy: {field: 'created_at', direction: 'DESC'}, limit,
738
+ }),
739
+ readInboxRows(ctx, TABLE_REQUEST, {resolved: 0}, {
740
+ orderBy: {field: 'created_at', direction: 'DESC'}, limit,
741
+ }),
742
+ ]);
743
+ const messages = messagePage.rows
744
+ .slice()
745
+ .sort((left, right) => Number(right.created_at ?? 0) - Number(left.created_at ?? 0))
746
+ .slice(0, limit)
747
+ .map(row => ({
748
+ id: row.id,
749
+ adapter: row.adapter,
750
+ endpoint_id: row.endpoint_id,
751
+ platform_message_id: row.platform_message_id,
752
+ sender_id: row.sender_id,
753
+ sender_name: row.sender_name,
754
+ content: row.content,
755
+ raw: row.raw,
756
+ created_at: row.created_at,
757
+ channel: channelFromStoredRow(row),
758
+ parent: parentFromStoredRow(row),
759
+ }));
760
+ const requests = requestPage.rows
761
+ .filter(row => Number(row.resolved ?? 0) === 0)
762
+ .slice()
763
+ .sort((left, right) => Number(right.created_at ?? 0) - Number(left.created_at ?? 0))
764
+ .slice(0, limit)
765
+ .map(row => ({...mapRequestRow(row), adapter: row.adapter, endpoint_id: row.endpoint_id}));
766
+ return {data: {messages, requests, inboxEnabled: messagePage.enabled || requestPage.enabled}};
767
+ }
768
+
676
769
  function mapRequestRow(row: Record<string, unknown>): Record<string, unknown> {
677
770
  const actorId = row.actor_id;
678
771
  const actorName = row.actor_name ?? undefined;
@@ -790,6 +883,12 @@ async function actOnRequest(
790
883
  ? strField(d, '$remark', 'remark')
791
884
  : strField(d, '$reason', 'reason');
792
885
  await method.call(management, requestId, extra || undefined);
886
+ const model = getInboxModel(ctx, TABLE_REQUEST);
887
+ if (model && typeof model.update === 'function') {
888
+ await model.update({resolved: 1, resolved_at: Date.now()}).where({
889
+ adapter, endpoint_id: endpointKey, platform_request_id: requestId,
890
+ });
891
+ }
793
892
  return { data: { success: true } };
794
893
  });
795
894
  }
@@ -81,6 +81,11 @@ export type RuntimeConsoleRpcContext = {
81
81
  * Returns whether a process restart is required (always true without ConfigFeature).
82
82
  */
83
83
  setConfigKey?(pluginName: string, data: unknown): Promise<{ restartRequired: boolean }>;
84
+ /** Persist a declared child Plugin lifecycle state. Takes effect after restart. */
85
+ setPluginEnabled?(
86
+ instanceKey: string,
87
+ enabled: boolean,
88
+ ): Promise<Readonly<{ disabled: readonly string[] }>>;
84
89
  /** Atomic, revision-checked runtime Workroom Catalog read/write. */
85
90
  readWorkroomCatalog?(): Promise<Readonly<{
86
91
  agents: Readonly<Record<string, unknown>>;
@@ -754,6 +759,41 @@ export async function dispatchRuntimeConsoleRpc(
754
759
  }
755
760
  return payloads;
756
761
  }
762
+ case 'plugin:set-enabled': {
763
+ try {
764
+ const instanceKey = typeof message.instanceKey === 'string' ? message.instanceKey : '';
765
+ const enabled = message.enabled;
766
+ if (!instanceKey || typeof enabled !== 'boolean') {
767
+ emit({ requestId, error: 'instanceKey and boolean enabled are required' });
768
+ return payloads;
769
+ }
770
+ if (!ctx.setPluginEnabled) {
771
+ emit({ requestId, error: 'Plugin lifecycle management is not configured' });
772
+ return payloads;
773
+ }
774
+ const lifecycle = await ctx.setPluginEnabled(instanceKey, enabled);
775
+ ctx.publishEvent?.('plugin:lifecycle-updated', { instanceKey, enabled });
776
+ emit({
777
+ requestId,
778
+ data: {
779
+ success: true,
780
+ instanceKey,
781
+ enabled,
782
+ disabled: lifecycle.disabled,
783
+ restartRequired: true,
784
+ message: `${instanceKey} 已${enabled ? '启用' : '停用'},Host 正在重启`,
785
+ },
786
+ });
787
+ if (ctx.requestRestart) {
788
+ setTimeout(() => {
789
+ void Promise.resolve(ctx.requestRestart?.()).catch(() => undefined);
790
+ }, 500);
791
+ }
792
+ } catch (error) {
793
+ emit({ requestId, error: error instanceof Error ? error.message : String(error) });
794
+ }
795
+ return payloads;
796
+ }
757
797
  default:
758
798
  if (type.startsWith('db:')) {
759
799
  emit({
package/src/http-host.ts CHANGED
@@ -1,9 +1,14 @@
1
1
  import {
2
- createServer,
2
+ createServer as createHttpServer,
3
3
  type IncomingMessage,
4
- type Server,
4
+ type Server as HttpServer,
5
5
  type ServerResponse,
6
6
  } from 'node:http';
7
+ import {
8
+ createServer as createHttpsServer,
9
+ type Server as HttpsServer,
10
+ type ServerOptions as HttpsServerOptions,
11
+ } from 'node:https';
7
12
  import type { Socket } from 'node:net';
8
13
  import { WebSocket, WebSocketServer } from 'ws';
9
14
  import { formatCompact, getLogger } from '@zhin.js/logger';
@@ -46,9 +51,29 @@ export interface WsHandle {
46
51
  close(): void;
47
52
  }
48
53
 
54
+ export interface WsRouteOptions {
55
+ /**
56
+ * `host` applies the Host Bearer-token registry before upgrade. `protocol`
57
+ * leaves upgrade authentication to the registered application protocol.
58
+ */
59
+ readonly auth?: 'host' | 'protocol';
60
+ }
61
+
49
62
  export interface HttpHostAddress {
50
63
  readonly host: string;
51
64
  readonly port: number;
65
+ readonly protocol: 'http' | 'https';
66
+ readonly secure: boolean;
67
+ readonly origin: string;
68
+ }
69
+
70
+ export interface HttpHostTlsOptions {
71
+ readonly key: NonNullable<HttpsServerOptions['key']>;
72
+ readonly cert: NonNullable<HttpsServerOptions['cert']>;
73
+ readonly ca?: HttpsServerOptions['ca'];
74
+ readonly passphrase?: string;
75
+ readonly minVersion?: HttpsServerOptions['minVersion'];
76
+ readonly ciphers?: string;
52
77
  }
53
78
 
54
79
  export interface HttpHostOptions {
@@ -67,6 +92,8 @@ export interface HttpHostOptions {
67
92
  readonly apiBase?: string;
68
93
  /** Extra HTTP path prefixes that skip auth even under `apiBase`. */
69
94
  readonly authExemptPaths?: readonly string[];
95
+ /** Enables HTTPS and WSS on this listener. Certificate lifecycle belongs to the process composition root. */
96
+ readonly tls?: HttpHostTlsOptions;
70
97
  }
71
98
 
72
99
  export type HttpHandler = (
@@ -83,7 +110,7 @@ export interface HttpRouteRegistration {
83
110
 
84
111
  /** @public Stable HTTP and WebSocket Host contract resolved through `httpHostToken`. */
85
112
  export interface HttpHost {
86
- ws(path: string): WsHandle;
113
+ ws(path: string, options?: WsRouteOptions): WsHandle;
87
114
  route(
88
115
  method: string,
89
116
  path: string,
@@ -117,6 +144,7 @@ interface AdmissionBoundHttpHost extends HttpHost, GenerationAdmissionBindable<H
117
144
  interface WsRoute {
118
145
  readonly listener: (connection: WsConnection) => void;
119
146
  readonly admission?: GenerationAdmissionGate;
147
+ readonly options: WsRouteOptions;
120
148
  }
121
149
 
122
150
  /** @public Stable Plugin Runtime HTTP Host token. */
@@ -143,9 +171,13 @@ export function createHttpHost(options: HttpHostOptions = {}): ProcessHttpHost {
143
171
  let closed = false;
144
172
  let closeResult: Promise<void> | undefined;
145
173
 
146
- const server: Server = createServer((request, response) => {
174
+ const secure = options.tls !== undefined;
175
+ const handleRequest = (request: IncomingMessage, response: ServerResponse): void => {
147
176
  void dispatchHttp(request, response);
148
- });
177
+ };
178
+ const server: HttpServer | HttpsServer = secure
179
+ ? createHttpsServer(options.tls, handleRequest)
180
+ : createHttpServer(handleRequest);
149
181
 
150
182
  // Track live TCP sockets so close() can destroy long-lived connections
151
183
  // (SSE /api/events, keep-alive) that would otherwise block server.close().
@@ -184,7 +216,7 @@ export function createHttpHost(options: HttpHostOptions = {}): ProcessHttpHost {
184
216
  meta: Object.freeze({ summary: 'OpenAPI 3.1 document', tags: ['pub'] }),
185
217
  handler: (request: IncomingMessage, response: ServerResponse) => {
186
218
  const hostHeader = headerValue(request.headers.host) ?? 'localhost';
187
- const proto = headerValue(request.headers['x-forwarded-proto']) ?? 'http';
219
+ const proto = headerValue(request.headers['x-forwarded-proto']) ?? (secure ? 'https' : 'http');
188
220
  writeJson(response, 200, buildOpenApiDocument(listListedRoutes(), {
189
221
  apiBase,
190
222
  serverUrl: `${proto}://${hostHeader}`,
@@ -210,7 +242,10 @@ export function createHttpHost(options: HttpHostOptions = {}): ProcessHttpHost {
210
242
  socket.destroy();
211
243
  return;
212
244
  }
213
- const auth = authenticateUpgrade(request, pathname);
245
+ const protocolAuthenticated = admitted.every(({ entry }) => entry.options.auth === 'protocol');
246
+ const auth = protocolAuthenticated
247
+ ? { ok: true as const, scope: 'full' as const }
248
+ : authenticateUpgrade(request, pathname);
214
249
  if (!auth.ok) {
215
250
  socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
216
251
  socket.destroy();
@@ -406,6 +441,7 @@ export function createHttpHost(options: HttpHostOptions = {}): ProcessHttpHost {
406
441
  function registerWs(
407
442
  pathname: string,
408
443
  admission?: GenerationAdmissionGate,
444
+ options: WsRouteOptions = {},
409
445
  ): WsHandle {
410
446
  const normalized = normalizePath(pathname);
411
447
  let listeners = wsRoutes.get(normalized);
@@ -416,7 +452,7 @@ export function createHttpHost(options: HttpHostOptions = {}): ProcessHttpHost {
416
452
  const owned = new Set<WsRoute>();
417
453
  return {
418
454
  onConnection(listener) {
419
- const entry = Object.freeze({ listener, admission });
455
+ const entry = Object.freeze({ listener, admission, options: Object.freeze({ ...options }) });
420
456
  listeners!.add(entry);
421
457
  owned.add(entry);
422
458
  return () => {
@@ -463,7 +499,7 @@ export function createHttpHost(options: HttpHostOptions = {}): ProcessHttpHost {
463
499
  ): AdmissionBoundHttpHost {
464
500
  const bound: AdmissionBoundHttpHost = {
465
501
  [generationAdmissionBinder]: (next) => createBoundHost(next),
466
- ws: (path) => registerWs(path, admission),
502
+ ws: (path, options) => registerWs(path, admission, options),
467
503
  route: (method, path, handler, meta) => registerRoute(
468
504
  method,
469
505
  path,
@@ -481,7 +517,7 @@ export function createHttpHost(options: HttpHostOptions = {}): ProcessHttpHost {
481
517
  const runtime: ProcessHttpHost = {
482
518
  [generationAdmissionBinder]: (admission) => createBoundHost(admission),
483
519
 
484
- ws: (pathname) => registerWs(pathname),
520
+ ws: (pathname, options) => registerWs(pathname, undefined, options),
485
521
 
486
522
  route: (method, path, handler, meta) => registerRoute(method, path, handler, meta),
487
523
 
@@ -506,9 +542,14 @@ export function createHttpHost(options: HttpHostOptions = {}): ProcessHttpHost {
506
542
  const bound = server.address();
507
543
  const listenPort = typeof bound === 'object' && bound ? bound.port : port;
508
544
  const publicHost = host === '0.0.0.0' || host === '::' ? '127.0.0.1' : host;
509
- address = Object.freeze({ host: publicHost, port: listenPort });
545
+ const protocol = secure ? 'https' : 'http';
546
+ const originHost = publicHost.includes(':') ? `[${publicHost}]` : publicHost;
547
+ address = Object.freeze({
548
+ host: publicHost, port: listenPort, protocol, secure,
549
+ origin: `${protocol}://${originHost}:${listenPort}`,
550
+ });
510
551
  logger.info(
511
- `listening http://${publicHost}:${listenPort}`
552
+ `listening ${address.origin}`
512
553
  + ` | routes: ${httpRoutes.length}`
513
554
  + ` | token: ${tokenRegistry.hasAnyToken()
514
555
  ? `${tokenRegistry.primaryTokenPrefixForLog()}…`
package/src/index.ts CHANGED
@@ -7,22 +7,30 @@ export {
7
7
  type ProcessHttpHost,
8
8
  type HttpHostAddress,
9
9
  type HttpHostOptions,
10
+ type HttpHostTlsOptions,
10
11
  type HttpRouteRegistration,
11
12
  type WsConnection,
12
13
  type WsHandle,
14
+ type WsRouteOptions,
13
15
  } from './http-host.js';
16
+ export {
17
+ createHttpHostGroup,
18
+ listHttpHostAddresses,
19
+ } from './listener-group.js';
14
20
  export {
15
21
  TokenRegistry,
16
22
  extractBearerToken,
17
23
  isDemoWebSocketPath,
18
24
  type AuthenticatedTokenPrincipal,
19
25
  type AuthScope,
26
+ type DynamicTokenConfig,
20
27
  type ScopedTokenConfig,
21
28
  type TokenRegistryConfig,
22
29
  } from './token-registry.js';
23
30
  export { timingSafeEqualString } from './timing-safe-equal.js';
24
31
  export {
25
32
  createConsoleEventHub,
33
+ consoleEventHubToken,
26
34
  type ConsoleEventHub,
27
35
  } from './console-events.js';
28
36
  export {
@@ -0,0 +1,108 @@
1
+ import {
2
+ bindGenerationAdmission,
3
+ generationAdmissionBinder,
4
+ type GenerationAdmissionBindable,
5
+ type GenerationAdmissionGate,
6
+ } from '@zhin.js/plugin-runtime';
7
+ import {
8
+ createHttpHost,
9
+ type HttpHost,
10
+ type HttpHostAddress,
11
+ type HttpHostOptions,
12
+ type HttpRouteRegistration,
13
+ type ProcessHttpHost,
14
+ type WsHandle,
15
+ type WsRouteOptions,
16
+ } from './http-host.js';
17
+
18
+ const addressProvider = Symbol('HttpHostAddressProvider');
19
+
20
+ interface AddressAwareHttpHost {
21
+ readonly [addressProvider]: () => readonly HttpHostAddress[];
22
+ }
23
+
24
+ type BoundHttpHost = HttpHost & GenerationAdmissionBindable<HttpHost> & AddressAwareHttpHost;
25
+
26
+ /**
27
+ * Runs one logical Host surface on multiple network listeners. Route and WS
28
+ * registration stays generation-owned and is mirrored to every listener,
29
+ * while `address` intentionally remains the primary/local listener.
30
+ */
31
+ export function createHttpHostGroup(
32
+ options: readonly HttpHostOptions[],
33
+ ): ProcessHttpHost {
34
+ if (options.length === 0) throw new Error('HttpHostGroup requires at least one listener');
35
+ const listeners = options.map((entry) => createHttpHost(entry));
36
+
37
+ const createSurface = (hosts: readonly HttpHost[]): BoundHttpHost => {
38
+ const primary = hosts[0]!;
39
+ const surface: BoundHttpHost = {
40
+ [generationAdmissionBinder](admission: GenerationAdmissionGate): HttpHost {
41
+ return createSurface(hosts.map((host) => bindGenerationAdmission(host, admission)));
42
+ },
43
+ [addressProvider]: () => listeners.flatMap((host) => host.address ? [host.address] : []),
44
+ ws(path: string, wsOptions?: WsRouteOptions): WsHandle {
45
+ const handles = hosts.map((host) => host.ws(path, wsOptions));
46
+ return {
47
+ onConnection(listener) {
48
+ const disposers = handles.map((handle) => handle.onConnection(listener));
49
+ return () => { for (const dispose of disposers.reverse()) dispose(); };
50
+ },
51
+ close() { for (const handle of handles) handle.close(); },
52
+ };
53
+ },
54
+ route(method, path, handler, meta): HttpRouteRegistration {
55
+ const disposers = hosts.map((host) => host.route(method, path, handler, meta));
56
+ return () => { for (const dispose of disposers.reverse()) dispose(); };
57
+ },
58
+ listRoutes: () => primary.listRoutes(),
59
+ get address() { return primary.address; },
60
+ get tokenRegistry() { return primary.tokenRegistry; },
61
+ };
62
+ return Object.freeze(surface);
63
+ };
64
+
65
+ const surface = createSurface(listeners);
66
+ let closeResult: Promise<void> | undefined;
67
+ const runtime: ProcessHttpHost & AddressAwareHttpHost = {
68
+ [generationAdmissionBinder](admission: GenerationAdmissionGate): HttpHost {
69
+ return surface[generationAdmissionBinder](admission);
70
+ },
71
+ [addressProvider]: surface[addressProvider],
72
+ ws: (path, wsOptions) => surface.ws(path, wsOptions),
73
+ route: (method, path, handler, meta) => surface.route(method, path, handler, meta),
74
+ listRoutes: () => surface.listRoutes(),
75
+ get address() { return surface.address; },
76
+ get tokenRegistry() { return surface.tokenRegistry; },
77
+ async listen(): Promise<HttpHostAddress> {
78
+ try {
79
+ for (const listener of listeners) {
80
+ await listener.listen();
81
+ }
82
+ } catch (error) {
83
+ await Promise.allSettled([...listeners].reverse().map((listener) => listener.close()));
84
+ throw error;
85
+ }
86
+ return listeners[0]!.address!;
87
+ },
88
+ close(): Promise<void> {
89
+ closeResult ??= closeListeners(listeners);
90
+ return closeResult;
91
+ },
92
+ };
93
+ return Object.freeze(runtime);
94
+ }
95
+
96
+ /** Returns every bound listener, ordered primary/local first. */
97
+ export function listHttpHostAddresses(host: HttpHost): readonly HttpHostAddress[] {
98
+ const provider = (host as Partial<AddressAwareHttpHost>)[addressProvider];
99
+ if (typeof provider === 'function') return Object.freeze([...provider.call(host)]);
100
+ return Object.freeze(host.address ? [host.address] : []);
101
+ }
102
+
103
+ async function closeListeners(listeners: readonly ProcessHttpHost[]): Promise<void> {
104
+ const results = await Promise.allSettled([...listeners].reverse().map((listener) => listener.close()));
105
+ const errors = results.flatMap((result) => result.status === 'rejected' ? [result.reason] : []);
106
+ if (errors.length === 1) throw errors[0];
107
+ if (errors.length > 1) throw new AggregateError(errors, 'multiple HTTP listeners failed to close');
108
+ }