@zhin.js/host-http 1.1.17 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/README.md +60 -21
  2. package/lib/access-control.d.ts +127 -0
  3. package/lib/access-control.js +148 -0
  4. package/lib/addresses.d.ts +9 -0
  5. package/lib/addresses.js +9 -0
  6. package/lib/bridge/bridged-host.d.ts +43 -0
  7. package/lib/bridge/bridged-host.js +497 -0
  8. package/lib/bridge/control-channel.d.ts +59 -0
  9. package/lib/bridge/control-channel.js +131 -0
  10. package/lib/bridge/flow.d.ts +58 -0
  11. package/lib/bridge/flow.js +188 -0
  12. package/lib/bridge/index.d.ts +6 -0
  13. package/lib/bridge/index.js +6 -0
  14. package/lib/bridge/instance-transport.d.ts +112 -0
  15. package/lib/bridge/instance-transport.js +532 -0
  16. package/lib/bridge/protocol.d.ts +242 -0
  17. package/lib/bridge/protocol.js +369 -0
  18. package/lib/console-events.d.ts +5 -3
  19. package/lib/console-events.js +53 -27
  20. package/lib/console-plugin-rpc.d.ts +12 -1
  21. package/lib/console-plugin-rpc.js +16 -66
  22. package/lib/console-rest-pages-helpers.d.ts +4 -5
  23. package/lib/console-rest-pages-helpers.js +62 -79
  24. package/lib/console-rest-pages.js +80 -65
  25. package/lib/console-rpc-extended/dispatcher.js +1 -6
  26. package/lib/console-rpc-handlers.d.ts +0 -2
  27. package/lib/console-rpc-handlers.js +2 -40
  28. package/lib/console-rpc.d.ts +6 -18
  29. package/lib/console-rpc.js +6 -6
  30. package/lib/host-installer.d.ts +6 -3
  31. package/lib/host-installer.js +10 -40
  32. package/lib/host-options.d.ts +47 -0
  33. package/lib/host-options.js +1 -0
  34. package/lib/http-host.d.ts +124 -48
  35. package/lib/http-host.js +319 -332
  36. package/lib/index.d.ts +11 -6
  37. package/lib/index.js +10 -6
  38. package/lib/json-body.d.ts +1 -2
  39. package/lib/json-body.js +1 -2
  40. package/lib/marketplace-registry.d.ts +17 -0
  41. package/lib/marketplace-registry.js +52 -0
  42. package/lib/node/index.d.ts +10 -0
  43. package/lib/node/index.js +10 -0
  44. package/lib/{listener-group.d.ts → node/listener-group.d.ts} +2 -3
  45. package/lib/{listener-group.js → node/listener-group.js} +6 -10
  46. package/lib/node/listener.d.ts +26 -0
  47. package/lib/node/listener.js +231 -0
  48. package/lib/node/node-http.d.ts +38 -0
  49. package/lib/node/node-http.js +272 -0
  50. package/lib/node/server.d.ts +13 -0
  51. package/lib/node/server.js +64 -0
  52. package/lib/protocol-auth.d.ts +24 -0
  53. package/lib/protocol-auth.js +48 -0
  54. package/lib/public-api.d.ts +1 -1
  55. package/lib/token-registry.d.ts +54 -36
  56. package/lib/token-registry.js +133 -126
  57. package/lib/trust-proxy.d.ts +22 -0
  58. package/lib/trust-proxy.js +168 -0
  59. package/package.json +14 -6
  60. package/src/access-control.ts +255 -0
  61. package/src/addresses.ts +16 -0
  62. package/src/bridge/bridged-host.ts +548 -0
  63. package/src/bridge/control-channel.ts +173 -0
  64. package/src/bridge/flow.ts +208 -0
  65. package/src/bridge/index.ts +31 -0
  66. package/src/bridge/instance-transport.ts +627 -0
  67. package/src/bridge/protocol.ts +574 -0
  68. package/src/console-events.ts +59 -28
  69. package/src/console-plugin-rpc.ts +17 -77
  70. package/src/console-rest-pages-helpers.ts +60 -82
  71. package/src/console-rest-pages.ts +87 -67
  72. package/src/console-rpc-extended/dispatcher.ts +1 -6
  73. package/src/console-rpc-handlers.ts +2 -31
  74. package/src/console-rpc.ts +10 -24
  75. package/src/host-installer.ts +12 -44
  76. package/src/host-options.ts +47 -0
  77. package/src/http-host.ts +467 -421
  78. package/src/index.ts +56 -14
  79. package/src/json-body.ts +7 -2
  80. package/src/marketplace-registry.ts +62 -0
  81. package/src/node/index.ts +21 -0
  82. package/src/{listener-group.ts → node/listener-group.ts} +13 -24
  83. package/src/node/listener.ts +265 -0
  84. package/src/node/node-http.ts +263 -0
  85. package/src/node/server.ts +82 -0
  86. package/src/protocol-auth.ts +52 -0
  87. package/src/public-api.ts +2 -0
  88. package/src/token-registry.ts +143 -146
  89. package/src/trust-proxy.ts +178 -0
package/src/index.ts CHANGED
@@ -1,36 +1,68 @@
1
1
  export {
2
- createHttpHost,
2
+ createHttpRouter,
3
3
  httpHostToken,
4
+ normalizePath,
4
5
  REMOTE_CONSOLE_ORIGIN,
5
6
  type HttpHandler,
6
7
  type HttpHost,
7
- type ProcessHttpHost,
8
- type HttpHostAddress,
9
- type HttpHostOptions,
10
- type HttpHostTlsOptions,
8
+ type HttpRequestInfo,
9
+ type HttpRouter,
10
+ type HttpRouterOptions,
11
11
  type HttpRouteRegistration,
12
+ type RouteContext,
12
13
  type WsConnection,
13
14
  type WsHandle,
14
15
  type WsRouteOptions,
16
+ type WsTransport,
17
+ type WsUpgradeDecision,
18
+ type WsUpgradeRequest,
19
+ type ListedWsRoute,
15
20
  } from './http-host.js';
16
- export {
17
- createHttpHostGroup,
18
- listHttpHostAddresses,
19
- } from './listener-group.js';
21
+ export type { HttpHostAddress, HttpHostOptions, HttpHostTlsOptions } from './host-options.js';
22
+ export { listHttpHostAddresses } from './addresses.js';
20
23
  export {
21
24
  TokenRegistry,
22
25
  extractBearerToken,
23
- isDemoWebSocketPath,
24
26
  publicConsoleAccess,
25
27
  unrestrictedConsoleAccess,
26
28
  type AuthenticatedTokenPrincipal,
27
29
  type ConsoleAccess,
28
30
  type AuthScope,
29
- type DynamicTokenConfig,
30
- type ScopedTokenConfig,
31
31
  type TokenRegistryConfig,
32
32
  } from './token-registry.js';
33
+ export {
34
+ auditActor,
35
+ authorizeConsoleHttp,
36
+ authorizeConsoleRpc,
37
+ authorizeConsoleWebSocket,
38
+ globalPermissions,
39
+ isUserPrincipal,
40
+ isExplicitConsolePrincipal,
41
+ pluginRpcWriteAllowed,
42
+ principalAllows,
43
+ requestMeta,
44
+ rpcAuditDetail,
45
+ rpcAuditTarget,
46
+ type ConsoleAccessProvider,
47
+ type ConsoleAuditEntry,
48
+ type ConsoleBootstrapPrincipal,
49
+ type ConsoleHttpDecision,
50
+ type ConsolePrincipal,
51
+ type ConsoleRequestMeta,
52
+ type ConsoleRpcDecision,
53
+ type ConsoleUserPrincipal,
54
+ } from './access-control.js';
33
55
  export { timingSafeEqualString } from './timing-safe-equal.js';
56
+ export { isLoopbackAddress, isLoopbackHost, verifyProtocolAccessToken, type ProtocolAuthRequest } from './protocol-auth.js';
57
+ export {
58
+ compileTrustProxy,
59
+ hasForwardingHeaders,
60
+ parseTrustProxy,
61
+ resolveClientAddress,
62
+ TrustProxyConfigError,
63
+ type TrustProxyPredicate,
64
+ type TrustProxySetting,
65
+ } from './trust-proxy.js';
34
66
  export {
35
67
  createConsoleEventHub,
36
68
  consoleEventHubToken,
@@ -47,11 +79,21 @@ export {
47
79
  } from './openapi.js';
48
80
  export {
49
81
  HttpBodyError,
82
+ readBodyBytes,
50
83
  readJsonBody,
84
+ readTextBody,
85
+ type HttpBodyReadOptions,
51
86
  } from './json-body.js';
87
+ export {
88
+ createResponseStream,
89
+ emptyResponse,
90
+ jsonResponse,
91
+ SSE_HEADERS,
92
+ type HttpResponseStream,
93
+ type HttpResponseStreamOptions,
94
+ } from '@zhin.js/host-http-contract';
52
95
  export {
53
96
  dispatchRuntimeConsoleRpc,
54
- isDemoHttpAllowed,
55
97
  pickRpcReply,
56
98
  type RuntimeConsolePage,
57
99
  type RuntimeConsoleRpcContext,
@@ -67,7 +109,7 @@ export {
67
109
  type PluginVerificationPlan,
68
110
  type PluginConfigValidation,
69
111
  } from './console-rpc.js';
70
- export { dispatchPluginConsoleRpc } from './console-plugin-rpc.js';
112
+ export { dispatchPluginConsoleRpc, PLUGIN_SWITCH_REQUIRES_GATEWAY } from './console-plugin-rpc.js';
71
113
  export {
72
114
  buildProjectFileTree,
73
115
  FILE_MANAGER_ALLOWED,
package/src/json-body.ts CHANGED
@@ -1,2 +1,7 @@
1
- /** @deprecated Import from @zhin.js/host-http-contract in protocol hosts. */
2
- export { HttpBodyError, readJsonBody } from '@zhin.js/host-http-contract';
1
+ export {
2
+ HttpBodyError,
3
+ readBodyBytes,
4
+ readJsonBody,
5
+ readTextBody,
6
+ type HttpBodyReadOptions,
7
+ } from '@zhin.js/host-http-contract';
@@ -56,3 +56,65 @@ export class MarketplaceRegistry {
56
56
  return plugins;
57
57
  }
58
58
  }
59
+
60
+ /** npm package name grammar (optionally scoped, lower-case, URL-safe, at most 214 chars). */
61
+ const NPM_PACKAGE_NAME = /^(?:@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/u;
62
+
63
+ export function isValidNpmPackageName(name: string): boolean {
64
+ return name.length > 0 && name.length <= 214 && NPM_PACKAGE_NAME.test(name);
65
+ }
66
+
67
+ export interface MarketplaceDetailCacheOptions<T> {
68
+ readonly load: (name: string) => Promise<T>;
69
+ readonly ttlMs: number;
70
+ readonly maxEntries: number;
71
+ readonly now?: () => number;
72
+ }
73
+
74
+ /**
75
+ * Per-package detail cache: TTL, bounded size (oldest entry evicted first) and
76
+ * coalescing of concurrent loads. Failures are not cached.
77
+ */
78
+ export class MarketplaceDetailCache<T> {
79
+ readonly #load: (name: string) => Promise<T>;
80
+ readonly #ttlMs: number;
81
+ readonly #maxEntries: number;
82
+ readonly #now: () => number;
83
+ readonly #entries = new Map<string, { readonly value: T; readonly expiresAt: number }>();
84
+ readonly #inflight = new Map<string, Promise<T>>();
85
+
86
+ constructor(options: MarketplaceDetailCacheOptions<T>) {
87
+ this.#load = options.load;
88
+ this.#ttlMs = options.ttlMs;
89
+ this.#maxEntries = Math.max(1, options.maxEntries);
90
+ this.#now = options.now ?? Date.now;
91
+ }
92
+
93
+ get size(): number {
94
+ return this.#entries.size;
95
+ }
96
+
97
+ get(name: string): Promise<T> {
98
+ const cached = this.#entries.get(name);
99
+ if (cached && this.#now() < cached.expiresAt) return Promise.resolve(cached.value);
100
+ const pending = this.#inflight.get(name);
101
+ if (pending) return pending;
102
+
103
+ const request = this.#load(name).then((value) => {
104
+ this.#entries.delete(name);
105
+ this.#entries.set(name, { value, expiresAt: this.#now() + this.#ttlMs });
106
+ while (this.#entries.size > this.#maxEntries) {
107
+ const oldest = this.#entries.keys().next().value;
108
+ if (oldest === undefined) break;
109
+ this.#entries.delete(oldest);
110
+ }
111
+ return value;
112
+ });
113
+ this.#inflight.set(name, request);
114
+ const clear = () => {
115
+ if (this.#inflight.get(name) === request) this.#inflight.delete(name);
116
+ };
117
+ void request.then(clear, clear);
118
+ return request;
119
+ }
120
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Node transport for the HTTP Host (`@zhin.js/host-http/node`): the in-process listener
3
+ * and the Node `http` / `ws` ↔ Web standard conversion. The only entry of this package
4
+ * that loads `node:http`, `node:https` or `ws`.
5
+ * @module @zhin.js/host-http/node
6
+ */
7
+ export { createHttpHost, type ProcessHttpHost } from './server.js';
8
+ export { createHttpHostGroup } from './listener-group.js';
9
+ export {
10
+ createNodeHttpListener,
11
+ type NodeHttpListener,
12
+ type NodeHttpListenerOptions,
13
+ } from './listener.js';
14
+ export {
15
+ createNodeRequest,
16
+ nodeRequestHeaders,
17
+ writeNodeResponse,
18
+ wrapNodeWebSocket,
19
+ type NodeRequestOptions,
20
+ type NodeWebSocketOptions,
21
+ } from './node-http.js';
@@ -4,22 +4,16 @@ import {
4
4
  type GenerationAdmissionBindable,
5
5
  type GenerationAdmissionGate,
6
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
- }
7
+ import type {
8
+ HttpHost,
9
+ HttpRouteRegistration,
10
+ WsHandle,
11
+ WsRouteOptions,
12
+ } from '../http-host.js';
13
+ import type { HttpHostAddress, HttpHostOptions } from '../host-options.js';
14
+ import { TokenRegistry } from '../token-registry.js';
15
+ import { httpHostAddressProvider as addressProvider, type AddressAwareHttpHost } from '../addresses.js';
16
+ import { createHttpHost, type ProcessHttpHost } from './server.js';
23
17
 
24
18
  type BoundHttpHost = HttpHost & GenerationAdmissionBindable<HttpHost> & AddressAwareHttpHost;
25
19
 
@@ -32,7 +26,9 @@ export function createHttpHostGroup(
32
26
  options: readonly HttpHostOptions[],
33
27
  ): ProcessHttpHost {
34
28
  if (options.length === 0) throw new Error('HttpHostGroup requires at least one listener');
35
- const listeners = options.map((entry) => createHttpHost(entry));
29
+ // One authentication state for the whole surface: every listener sees the same sessions.
30
+ const tokenRegistry = options[0]!.tokenRegistry ?? new TokenRegistry({ primaryToken: options[0]!.token });
31
+ const listeners = options.map((entry) => createHttpHost({ ...entry, tokenRegistry }));
36
32
 
37
33
  const createSurface = (hosts: readonly HttpHost[]): BoundHttpHost => {
38
34
  const primary = hosts[0]!;
@@ -93,13 +89,6 @@ export function createHttpHostGroup(
93
89
  return Object.freeze(runtime);
94
90
  }
95
91
 
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
92
  async function closeListeners(listeners: readonly ProcessHttpHost[]): Promise<void> {
104
93
  const results = await Promise.allSettled([...listeners].reverse().map((listener) => listener.close()));
105
94
  const errors = results.flatMap((result) => result.status === 'rejected' ? [result.reason] : []);
@@ -0,0 +1,265 @@
1
+ /**
2
+ * One Node `http` / `https` listener that feeds a transport-independent request handler and
3
+ * WebSocket upgrade handler (ADR 0054 §2). The in-process Host and the management gateway
4
+ * both run on it.
5
+ */
6
+ import {
7
+ createServer as createHttpServer,
8
+ STATUS_CODES,
9
+ type IncomingMessage,
10
+ type Server as HttpServer,
11
+ type ServerResponse,
12
+ } from 'node:http';
13
+ import {
14
+ createServer as createHttpsServer,
15
+ type Server as HttpsServer,
16
+ type ServerOptions as HttpsServerOptions,
17
+ } from 'node:https';
18
+ import type { Socket } from 'node:net';
19
+ import type { Duplex } from 'node:stream';
20
+ import { WebSocketServer } from 'ws';
21
+ import { formatCompact, getLogger } from '@zhin.js/logger';
22
+ import type { HttpRequestInfo, WsUpgradeDecision, WsUpgradeRequest } from '../http-host.js';
23
+ import type { HttpHostAddress, HttpHostTlsOptions } from '../host-options.js';
24
+ import { compileTrustProxy, resolveClientAddress, type TrustProxySetting } from '../trust-proxy.js';
25
+ import { createNodeRequest, nodeRequestHeaders, writeNodeResponse, wrapNodeWebSocket } from './node-http.js';
26
+
27
+ const logger = getLogger('http');
28
+
29
+ export interface NodeHttpListenerOptions {
30
+ readonly host?: string;
31
+ readonly port?: number;
32
+ /** Enables HTTPS / WSS on this listener. */
33
+ readonly tls?: HttpHostTlsOptions;
34
+ /** `http.trustProxy`: derive `remoteAddress` from forwarding headers sent by trusted peers. */
35
+ readonly trustProxy?: TrustProxySetting;
36
+ handle(request: Request, info: HttpRequestInfo): Promise<Response>;
37
+ upgrade(request: WsUpgradeRequest): Promise<WsUpgradeDecision>;
38
+ /** Called once the listener is bound, before `listen()` resolves. */
39
+ onListening?(address: HttpHostAddress): void;
40
+ /** Called after the listener closed. */
41
+ onClosed?(): void;
42
+ }
43
+
44
+ export interface NodeHttpListener {
45
+ listen(): Promise<HttpHostAddress>;
46
+ /** Destroys open connections (SSE, keep-alive, WebSocket) and stops listening. */
47
+ close(): Promise<void>;
48
+ readonly address: HttpHostAddress | undefined;
49
+ readonly secure: boolean;
50
+ }
51
+
52
+ /** A Node listener over {@link NodeHttpListenerOptions.handle} / `upgrade`. */
53
+ export function createNodeHttpListener(options: NodeHttpListenerOptions): NodeHttpListener {
54
+ const host = options.host ?? '127.0.0.1';
55
+ const port = options.port ?? 8086;
56
+ const secure = options.tls !== undefined;
57
+ let address: HttpHostAddress | undefined;
58
+ let closed = false;
59
+ let closeResult: Promise<void> | undefined;
60
+ let listenResult: Promise<HttpHostAddress> | undefined;
61
+ const trust = compileTrustProxy(options.trustProxy);
62
+ const clientAddress = (request: IncomingMessage): string | undefined =>
63
+ resolveClientAddress(request.socket?.remoteAddress, request.headers, trust);
64
+
65
+ const handleRequest = (request: IncomingMessage, response: ServerResponse): void => {
66
+ void dispatch(request, response).catch((error: unknown) => {
67
+ logger.warn(formatCompact({
68
+ op: 'http_dispatch_failed',
69
+ error: error instanceof Error ? error.message : String(error),
70
+ }));
71
+ if (!response.headersSent) {
72
+ response.writeHead(500);
73
+ response.end();
74
+ } else {
75
+ response.destroy();
76
+ }
77
+ });
78
+ };
79
+ const server: HttpServer | HttpsServer = secure
80
+ ? createHttpsServer(options.tls as HttpsServerOptions, handleRequest)
81
+ : createHttpServer(handleRequest);
82
+
83
+ // Track live TCP sockets so close() can destroy long-lived connections
84
+ // (SSE, keep-alive) that would otherwise block server.close().
85
+ const sockets = new Set<Socket>();
86
+ server.on('connection', (socket) => {
87
+ sockets.add(socket);
88
+ socket.once('close', () => {
89
+ sockets.delete(socket);
90
+ });
91
+ });
92
+
93
+ const wss = new WebSocketServer({ noServer: true });
94
+
95
+ async function dispatch(request: IncomingMessage, response: ServerResponse): Promise<void> {
96
+ if (closed) {
97
+ response.writeHead(503);
98
+ response.end();
99
+ return;
100
+ }
101
+ let url: URL;
102
+ try {
103
+ url = new URL(request.url ?? '/', 'http://localhost');
104
+ } catch {
105
+ response.writeHead(400);
106
+ response.end();
107
+ return;
108
+ }
109
+ const abort = new AbortController();
110
+ response.once('close', () => {
111
+ if (!response.writableFinished) abort.abort(new Error('Client disconnected'));
112
+ });
113
+ let webRequest: Request;
114
+ try {
115
+ webRequest = createNodeRequest(request, { url, signal: abort.signal });
116
+ } catch {
117
+ response.writeHead(400);
118
+ response.end();
119
+ return;
120
+ }
121
+ const remoteAddress = clientAddress(request);
122
+ const webResponse = await options.handle(webRequest, {
123
+ ...(remoteAddress ? { remoteAddress } : {}),
124
+ });
125
+ await writeNodeResponse(response, webResponse);
126
+ }
127
+
128
+ server.on('upgrade', (request: IncomingMessage, socket: Duplex, head: Buffer) => {
129
+ if (closed) {
130
+ socket.destroy();
131
+ return;
132
+ }
133
+ let url: URL;
134
+ try {
135
+ url = new URL(request.url ?? '/', 'http://localhost');
136
+ } catch {
137
+ socket.destroy();
138
+ return;
139
+ }
140
+ const remoteAddress = clientAddress(request);
141
+ void options.upgrade({
142
+ url,
143
+ headers: nodeRequestHeaders(request),
144
+ ...(remoteAddress ? { remoteAddress } : {}),
145
+ }).then((decision) => {
146
+ if (!decision.accepted) {
147
+ if (decision.status !== 404) writeUpgradeRefusal(socket, decision.status, decision.headers);
148
+ socket.destroy();
149
+ return;
150
+ }
151
+ if (closed || socket.destroyed) {
152
+ decision.reject();
153
+ writeUpgradeRefusal(socket, 401);
154
+ socket.destroy();
155
+ return;
156
+ }
157
+ let accepted = false;
158
+ socket.once('close', () => { if (!accepted) decision.reject(); });
159
+ try {
160
+ wss.handleUpgrade(request, socket, head, (ws) => {
161
+ accepted = true;
162
+ decision.accept(wrapNodeWebSocket(ws));
163
+ });
164
+ } catch (err) {
165
+ decision.reject();
166
+ logger.warn(formatCompact({
167
+ op: 'ws_upgrade_failed',
168
+ path: url.pathname,
169
+ error: err instanceof Error ? err.message : String(err),
170
+ }));
171
+ try {
172
+ socket.destroy();
173
+ } catch {
174
+ /* already destroyed */
175
+ }
176
+ }
177
+ }, () => socket.destroy());
178
+ });
179
+
180
+ return {
181
+ get address() { return address; },
182
+ secure,
183
+ listen(): Promise<HttpHostAddress> {
184
+ if (closed) return Promise.reject(new Error('HTTP Host is closed'));
185
+ if (listenResult) return listenResult;
186
+ if (address) return Promise.resolve(address);
187
+ listenResult = new Promise((resolve, reject) => {
188
+ const onError = (error: Error): void => {
189
+ listenResult = undefined;
190
+ reject(error);
191
+ };
192
+ server.once('error', onError);
193
+ server.listen({ host, port }, () => {
194
+ server.off('error', onError);
195
+ if (closed) {
196
+ reject(new Error('HTTP Host closed before listen completed'));
197
+ return;
198
+ }
199
+ const bound = server.address();
200
+ const listenPort = typeof bound === 'object' && bound ? bound.port : port;
201
+ const publicHost = host === '0.0.0.0' || host === '::' ? '127.0.0.1' : host;
202
+ const protocol = secure ? 'https' : 'http';
203
+ const originHost = publicHost.includes(':') ? `[${publicHost}]` : publicHost;
204
+ address = Object.freeze({
205
+ host: publicHost, port: listenPort, protocol, secure,
206
+ origin: `${protocol}://${originHost}:${listenPort}`,
207
+ });
208
+ options.onListening?.(address);
209
+ resolve(address);
210
+ });
211
+ });
212
+ return listenResult;
213
+ },
214
+ close(): Promise<void> {
215
+ if (closeResult) return closeResult;
216
+ closed = true;
217
+ closeResult = (async () => {
218
+ // A pending listen must settle before deciding whether there is a
219
+ // listener to close. Otherwise it can bind after close has resolved.
220
+ await listenResult?.catch(() => undefined);
221
+ if (!server.listening) {
222
+ address = undefined;
223
+ return;
224
+ }
225
+ await new Promise<void>((resolve, reject) => {
226
+ // Long-lived connections (SSE, keep-alive, WS) never end on their own:
227
+ // destroy tracked sockets and terminate WS clients before close(),
228
+ // otherwise the close callbacks would never fire.
229
+ for (const socket of sockets) socket.destroy();
230
+ sockets.clear();
231
+ for (const client of wss.clients) client.terminate();
232
+ wss.close((wssError) => {
233
+ if (wssError) {
234
+ reject(wssError);
235
+ return;
236
+ }
237
+ server.close((serverError) => {
238
+ if (serverError) {
239
+ reject(serverError);
240
+ return;
241
+ }
242
+ address = undefined;
243
+ options.onClosed?.();
244
+ resolve();
245
+ });
246
+ });
247
+ });
248
+ })();
249
+ return closeResult;
250
+ },
251
+ };
252
+ }
253
+
254
+ function writeUpgradeRefusal(
255
+ socket: Duplex,
256
+ status: number,
257
+ headers?: Readonly<Record<string, string>>,
258
+ ): void {
259
+ if (socket.destroyed) return;
260
+ const lines = [`HTTP/1.1 ${status} ${STATUS_CODES[status] ?? ''}`.trimEnd()];
261
+ for (const [name, value] of Object.entries(headers ?? {})) {
262
+ if (/^[!#$%&'*+.^_`|~0-9a-z-]+$/iu.test(name) && !/[\r\n]/u.test(value)) lines.push(`${name}: ${value}`);
263
+ }
264
+ socket.write(`${lines.join('\r\n')}\r\n\r\n`);
265
+ }