@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.
- package/README.md +60 -21
- package/lib/access-control.d.ts +127 -0
- package/lib/access-control.js +148 -0
- package/lib/addresses.d.ts +9 -0
- package/lib/addresses.js +9 -0
- package/lib/bridge/bridged-host.d.ts +43 -0
- package/lib/bridge/bridged-host.js +497 -0
- package/lib/bridge/control-channel.d.ts +59 -0
- package/lib/bridge/control-channel.js +131 -0
- package/lib/bridge/flow.d.ts +58 -0
- package/lib/bridge/flow.js +188 -0
- package/lib/bridge/index.d.ts +6 -0
- package/lib/bridge/index.js +6 -0
- package/lib/bridge/instance-transport.d.ts +112 -0
- package/lib/bridge/instance-transport.js +532 -0
- package/lib/bridge/protocol.d.ts +242 -0
- package/lib/bridge/protocol.js +369 -0
- package/lib/console-events.d.ts +5 -3
- package/lib/console-events.js +53 -27
- package/lib/console-plugin-rpc.d.ts +12 -1
- package/lib/console-plugin-rpc.js +16 -66
- package/lib/console-rest-pages-helpers.d.ts +4 -5
- package/lib/console-rest-pages-helpers.js +62 -79
- package/lib/console-rest-pages.js +80 -65
- package/lib/console-rpc-extended/dispatcher.js +1 -6
- package/lib/console-rpc-handlers.d.ts +0 -2
- package/lib/console-rpc-handlers.js +2 -40
- package/lib/console-rpc.d.ts +6 -18
- package/lib/console-rpc.js +6 -6
- package/lib/host-installer.d.ts +6 -3
- package/lib/host-installer.js +10 -40
- package/lib/host-options.d.ts +47 -0
- package/lib/host-options.js +1 -0
- package/lib/http-host.d.ts +124 -48
- package/lib/http-host.js +319 -332
- package/lib/index.d.ts +11 -6
- package/lib/index.js +10 -6
- package/lib/json-body.d.ts +1 -2
- package/lib/json-body.js +1 -2
- package/lib/marketplace-registry.d.ts +17 -0
- package/lib/marketplace-registry.js +52 -0
- package/lib/node/index.d.ts +10 -0
- package/lib/node/index.js +10 -0
- package/lib/{listener-group.d.ts → node/listener-group.d.ts} +2 -3
- package/lib/{listener-group.js → node/listener-group.js} +6 -10
- package/lib/node/listener.d.ts +26 -0
- package/lib/node/listener.js +231 -0
- package/lib/node/node-http.d.ts +38 -0
- package/lib/node/node-http.js +272 -0
- package/lib/node/server.d.ts +13 -0
- package/lib/node/server.js +64 -0
- package/lib/protocol-auth.d.ts +24 -0
- package/lib/protocol-auth.js +48 -0
- package/lib/public-api.d.ts +1 -1
- package/lib/token-registry.d.ts +54 -36
- package/lib/token-registry.js +133 -126
- package/lib/trust-proxy.d.ts +22 -0
- package/lib/trust-proxy.js +168 -0
- package/package.json +14 -6
- package/src/access-control.ts +255 -0
- package/src/addresses.ts +16 -0
- package/src/bridge/bridged-host.ts +548 -0
- package/src/bridge/control-channel.ts +173 -0
- package/src/bridge/flow.ts +208 -0
- package/src/bridge/index.ts +31 -0
- package/src/bridge/instance-transport.ts +627 -0
- package/src/bridge/protocol.ts +574 -0
- package/src/console-events.ts +59 -28
- package/src/console-plugin-rpc.ts +17 -77
- package/src/console-rest-pages-helpers.ts +60 -82
- package/src/console-rest-pages.ts +87 -67
- package/src/console-rpc-extended/dispatcher.ts +1 -6
- package/src/console-rpc-handlers.ts +2 -31
- package/src/console-rpc.ts +10 -24
- package/src/host-installer.ts +12 -44
- package/src/host-options.ts +47 -0
- package/src/http-host.ts +467 -421
- package/src/index.ts +56 -14
- package/src/json-body.ts +7 -2
- package/src/marketplace-registry.ts +62 -0
- package/src/node/index.ts +21 -0
- package/src/{listener-group.ts → node/listener-group.ts} +13 -24
- package/src/node/listener.ts +265 -0
- package/src/node/node-http.ts +263 -0
- package/src/node/server.ts +82 -0
- package/src/protocol-auth.ts +52 -0
- package/src/public-api.ts +2 -0
- package/src/token-registry.ts +143 -146
- package/src/trust-proxy.ts +178 -0
package/lib/index.d.ts
CHANGED
|
@@ -1,12 +1,17 @@
|
|
|
1
|
-
export {
|
|
2
|
-
export {
|
|
3
|
-
export {
|
|
1
|
+
export { createHttpRouter, httpHostToken, normalizePath, REMOTE_CONSOLE_ORIGIN, type HttpHandler, type HttpHost, type HttpRequestInfo, type HttpRouter, type HttpRouterOptions, type HttpRouteRegistration, type RouteContext, type WsConnection, type WsHandle, type WsRouteOptions, type WsTransport, type WsUpgradeDecision, type WsUpgradeRequest, type ListedWsRoute, } from './http-host.js';
|
|
2
|
+
export type { HttpHostAddress, HttpHostOptions, HttpHostTlsOptions } from './host-options.js';
|
|
3
|
+
export { listHttpHostAddresses } from './addresses.js';
|
|
4
|
+
export { TokenRegistry, extractBearerToken, publicConsoleAccess, unrestrictedConsoleAccess, type AuthenticatedTokenPrincipal, type ConsoleAccess, type AuthScope, type TokenRegistryConfig, } from './token-registry.js';
|
|
5
|
+
export { auditActor, authorizeConsoleHttp, authorizeConsoleRpc, authorizeConsoleWebSocket, globalPermissions, isUserPrincipal, isExplicitConsolePrincipal, pluginRpcWriteAllowed, principalAllows, requestMeta, rpcAuditDetail, rpcAuditTarget, type ConsoleAccessProvider, type ConsoleAuditEntry, type ConsoleBootstrapPrincipal, type ConsoleHttpDecision, type ConsolePrincipal, type ConsoleRequestMeta, type ConsoleRpcDecision, type ConsoleUserPrincipal, } from './access-control.js';
|
|
4
6
|
export { timingSafeEqualString } from './timing-safe-equal.js';
|
|
7
|
+
export { isLoopbackAddress, isLoopbackHost, verifyProtocolAccessToken, type ProtocolAuthRequest } from './protocol-auth.js';
|
|
8
|
+
export { compileTrustProxy, hasForwardingHeaders, parseTrustProxy, resolveClientAddress, TrustProxyConfigError, type TrustProxyPredicate, type TrustProxySetting, } from './trust-proxy.js';
|
|
5
9
|
export { createConsoleEventHub, consoleEventHubToken, type ConsoleEventHub, } from './console-events.js';
|
|
6
10
|
export { buildOpenApiDocument, patternToOpenApiPath, routeRequiresBearerAuth, type BuildOpenApiOptions, type ListedRoute, type OpenApiParameter, type RouteMeta, } from './openapi.js';
|
|
7
|
-
export { HttpBodyError, readJsonBody, } from './json-body.js';
|
|
8
|
-
export {
|
|
9
|
-
export {
|
|
11
|
+
export { HttpBodyError, readBodyBytes, readJsonBody, readTextBody, type HttpBodyReadOptions, } from './json-body.js';
|
|
12
|
+
export { createResponseStream, emptyResponse, jsonResponse, SSE_HEADERS, type HttpResponseStream, type HttpResponseStreamOptions, } from '@zhin.js/host-http-contract';
|
|
13
|
+
export { dispatchRuntimeConsoleRpc, pickRpcReply, type RuntimeConsolePage, type RuntimeConsoleRpcContext, type RuntimeConsoleRpcMessage, type RuntimeConsoleRpcReply, type RuntimeEndpointSendInput, type PluginInstallPlan, type PluginUninstallPlan, type PluginUpdatePlan, type PluginManagementPort, type PluginManagementState, type PluginVerificationResult, type PluginVerificationPlan, type PluginConfigValidation, } from './console-rpc.js';
|
|
14
|
+
export { dispatchPluginConsoleRpc, PLUGIN_SWITCH_REQUIRES_GATEWAY } from './console-plugin-rpc.js';
|
|
10
15
|
export { buildProjectFileTree, FILE_MANAGER_ALLOWED, FILE_MANAGER_BLOCKED, isProjectPathAllowed, listEnvFiles, readProjectFile, saveProjectFile, type FileTreeNode, } from './project-files.js';
|
|
11
16
|
export { registerConsoleRestPages, type ConsoleAgentIntrospection, type ConsoleAgentRuntime, type ConsoleAgentSessionTree, type ConsoleRestCtx, type ConsoleRestPagesOptions, } from './console-rest-pages.js';
|
|
12
17
|
export { dispatchExtendedConsoleRpc, type ConsoleRpcExtendedCtx, type ConsoleScheduleEngine, type ConsoleWorkroomProfileControlPort, type ConsoleWorkroomKnowledgeControlPort, type ExtendedRpcResult, } from './console-rpc-extended/index.js';
|
package/lib/index.js
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
1
|
-
export {
|
|
2
|
-
export {
|
|
3
|
-
export { TokenRegistry, extractBearerToken,
|
|
1
|
+
export { createHttpRouter, httpHostToken, normalizePath, REMOTE_CONSOLE_ORIGIN, } from './http-host.js';
|
|
2
|
+
export { listHttpHostAddresses } from './addresses.js';
|
|
3
|
+
export { TokenRegistry, extractBearerToken, publicConsoleAccess, unrestrictedConsoleAccess, } from './token-registry.js';
|
|
4
|
+
export { auditActor, authorizeConsoleHttp, authorizeConsoleRpc, authorizeConsoleWebSocket, globalPermissions, isUserPrincipal, isExplicitConsolePrincipal, pluginRpcWriteAllowed, principalAllows, requestMeta, rpcAuditDetail, rpcAuditTarget, } from './access-control.js';
|
|
4
5
|
export { timingSafeEqualString } from './timing-safe-equal.js';
|
|
6
|
+
export { isLoopbackAddress, isLoopbackHost, verifyProtocolAccessToken } from './protocol-auth.js';
|
|
7
|
+
export { compileTrustProxy, hasForwardingHeaders, parseTrustProxy, resolveClientAddress, TrustProxyConfigError, } from './trust-proxy.js';
|
|
5
8
|
export { createConsoleEventHub, consoleEventHubToken, } from './console-events.js';
|
|
6
9
|
export { buildOpenApiDocument, patternToOpenApiPath, routeRequiresBearerAuth, } from './openapi.js';
|
|
7
|
-
export { HttpBodyError, readJsonBody, } from './json-body.js';
|
|
8
|
-
export {
|
|
9
|
-
export {
|
|
10
|
+
export { HttpBodyError, readBodyBytes, readJsonBody, readTextBody, } from './json-body.js';
|
|
11
|
+
export { createResponseStream, emptyResponse, jsonResponse, SSE_HEADERS, } from '@zhin.js/host-http-contract';
|
|
12
|
+
export { dispatchRuntimeConsoleRpc, pickRpcReply, } from './console-rpc.js';
|
|
13
|
+
export { dispatchPluginConsoleRpc, PLUGIN_SWITCH_REQUIRES_GATEWAY } from './console-plugin-rpc.js';
|
|
10
14
|
export { buildProjectFileTree, FILE_MANAGER_ALLOWED, FILE_MANAGER_BLOCKED, isProjectPathAllowed, listEnvFiles, readProjectFile, saveProjectFile, } from './project-files.js';
|
|
11
15
|
export { registerConsoleRestPages, } from './console-rest-pages.js';
|
|
12
16
|
export { dispatchExtendedConsoleRpc, } from './console-rpc-extended/index.js';
|
package/lib/json-body.d.ts
CHANGED
|
@@ -1,2 +1 @@
|
|
|
1
|
-
|
|
2
|
-
export { HttpBodyError, readJsonBody } from '@zhin.js/host-http-contract';
|
|
1
|
+
export { HttpBodyError, readBodyBytes, readJsonBody, readTextBody, type HttpBodyReadOptions, } from '@zhin.js/host-http-contract';
|
package/lib/json-body.js
CHANGED
|
@@ -1,2 +1 @@
|
|
|
1
|
-
|
|
2
|
-
export { HttpBodyError, readJsonBody } from '@zhin.js/host-http-contract';
|
|
1
|
+
export { HttpBodyError, readBodyBytes, readJsonBody, readTextBody, } from '@zhin.js/host-http-contract';
|
|
@@ -10,3 +10,20 @@ export declare class MarketplaceRegistry {
|
|
|
10
10
|
constructor(options: MarketplaceRegistryOptions);
|
|
11
11
|
list(): Promise<readonly unknown[]>;
|
|
12
12
|
}
|
|
13
|
+
export declare function isValidNpmPackageName(name: string): boolean;
|
|
14
|
+
export interface MarketplaceDetailCacheOptions<T> {
|
|
15
|
+
readonly load: (name: string) => Promise<T>;
|
|
16
|
+
readonly ttlMs: number;
|
|
17
|
+
readonly maxEntries: number;
|
|
18
|
+
readonly now?: () => number;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Per-package detail cache: TTL, bounded size (oldest entry evicted first) and
|
|
22
|
+
* coalescing of concurrent loads. Failures are not cached.
|
|
23
|
+
*/
|
|
24
|
+
export declare class MarketplaceDetailCache<T> {
|
|
25
|
+
#private;
|
|
26
|
+
constructor(options: MarketplaceDetailCacheOptions<T>);
|
|
27
|
+
get size(): number;
|
|
28
|
+
get(name: string): Promise<T>;
|
|
29
|
+
}
|
|
@@ -43,3 +43,55 @@ export class MarketplaceRegistry {
|
|
|
43
43
|
return plugins;
|
|
44
44
|
}
|
|
45
45
|
}
|
|
46
|
+
/** npm package name grammar (optionally scoped, lower-case, URL-safe, at most 214 chars). */
|
|
47
|
+
const NPM_PACKAGE_NAME = /^(?:@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/u;
|
|
48
|
+
export function isValidNpmPackageName(name) {
|
|
49
|
+
return name.length > 0 && name.length <= 214 && NPM_PACKAGE_NAME.test(name);
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Per-package detail cache: TTL, bounded size (oldest entry evicted first) and
|
|
53
|
+
* coalescing of concurrent loads. Failures are not cached.
|
|
54
|
+
*/
|
|
55
|
+
export class MarketplaceDetailCache {
|
|
56
|
+
#load;
|
|
57
|
+
#ttlMs;
|
|
58
|
+
#maxEntries;
|
|
59
|
+
#now;
|
|
60
|
+
#entries = new Map();
|
|
61
|
+
#inflight = new Map();
|
|
62
|
+
constructor(options) {
|
|
63
|
+
this.#load = options.load;
|
|
64
|
+
this.#ttlMs = options.ttlMs;
|
|
65
|
+
this.#maxEntries = Math.max(1, options.maxEntries);
|
|
66
|
+
this.#now = options.now ?? Date.now;
|
|
67
|
+
}
|
|
68
|
+
get size() {
|
|
69
|
+
return this.#entries.size;
|
|
70
|
+
}
|
|
71
|
+
get(name) {
|
|
72
|
+
const cached = this.#entries.get(name);
|
|
73
|
+
if (cached && this.#now() < cached.expiresAt)
|
|
74
|
+
return Promise.resolve(cached.value);
|
|
75
|
+
const pending = this.#inflight.get(name);
|
|
76
|
+
if (pending)
|
|
77
|
+
return pending;
|
|
78
|
+
const request = this.#load(name).then((value) => {
|
|
79
|
+
this.#entries.delete(name);
|
|
80
|
+
this.#entries.set(name, { value, expiresAt: this.#now() + this.#ttlMs });
|
|
81
|
+
while (this.#entries.size > this.#maxEntries) {
|
|
82
|
+
const oldest = this.#entries.keys().next().value;
|
|
83
|
+
if (oldest === undefined)
|
|
84
|
+
break;
|
|
85
|
+
this.#entries.delete(oldest);
|
|
86
|
+
}
|
|
87
|
+
return value;
|
|
88
|
+
});
|
|
89
|
+
this.#inflight.set(name, request);
|
|
90
|
+
const clear = () => {
|
|
91
|
+
if (this.#inflight.get(name) === request)
|
|
92
|
+
this.#inflight.delete(name);
|
|
93
|
+
};
|
|
94
|
+
void request.then(clear, clear);
|
|
95
|
+
return request;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
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 { createNodeHttpListener, type NodeHttpListener, type NodeHttpListenerOptions, } from './listener.js';
|
|
10
|
+
export { createNodeRequest, nodeRequestHeaders, writeNodeResponse, wrapNodeWebSocket, type NodeRequestOptions, type NodeWebSocketOptions, } from './node-http.js';
|
|
@@ -0,0 +1,10 @@
|
|
|
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 } from './server.js';
|
|
8
|
+
export { createHttpHostGroup } from './listener-group.js';
|
|
9
|
+
export { createNodeHttpListener, } from './listener.js';
|
|
10
|
+
export { createNodeRequest, nodeRequestHeaders, writeNodeResponse, wrapNodeWebSocket, } from './node-http.js';
|
|
@@ -1,9 +1,8 @@
|
|
|
1
|
-
import
|
|
1
|
+
import type { HttpHostOptions } from '../host-options.js';
|
|
2
|
+
import { type ProcessHttpHost } from './server.js';
|
|
2
3
|
/**
|
|
3
4
|
* Runs one logical Host surface on multiple network listeners. Route and WS
|
|
4
5
|
* registration stays generation-owned and is mirrored to every listener,
|
|
5
6
|
* while `address` intentionally remains the primary/local listener.
|
|
6
7
|
*/
|
|
7
8
|
export declare function createHttpHostGroup(options: readonly HttpHostOptions[]): ProcessHttpHost;
|
|
8
|
-
/** Returns every bound listener, ordered primary/local first. */
|
|
9
|
-
export declare function listHttpHostAddresses(host: HttpHost): readonly HttpHostAddress[];
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { bindGenerationAdmission, generationAdmissionBinder, } from '@zhin.js/plugin-runtime';
|
|
2
|
-
import {
|
|
3
|
-
|
|
2
|
+
import { TokenRegistry } from '../token-registry.js';
|
|
3
|
+
import { httpHostAddressProvider as addressProvider } from '../addresses.js';
|
|
4
|
+
import { createHttpHost } from './server.js';
|
|
4
5
|
/**
|
|
5
6
|
* Runs one logical Host surface on multiple network listeners. Route and WS
|
|
6
7
|
* registration stays generation-owned and is mirrored to every listener,
|
|
@@ -9,7 +10,9 @@ const addressProvider = Symbol('HttpHostAddressProvider');
|
|
|
9
10
|
export function createHttpHostGroup(options) {
|
|
10
11
|
if (options.length === 0)
|
|
11
12
|
throw new Error('HttpHostGroup requires at least one listener');
|
|
12
|
-
|
|
13
|
+
// One authentication state for the whole surface: every listener sees the same sessions.
|
|
14
|
+
const tokenRegistry = options[0].tokenRegistry ?? new TokenRegistry({ primaryToken: options[0].token });
|
|
15
|
+
const listeners = options.map((entry) => createHttpHost({ ...entry, tokenRegistry }));
|
|
13
16
|
const createSurface = (hosts) => {
|
|
14
17
|
const primary = hosts[0];
|
|
15
18
|
const surface = {
|
|
@@ -71,13 +74,6 @@ export function createHttpHostGroup(options) {
|
|
|
71
74
|
};
|
|
72
75
|
return Object.freeze(runtime);
|
|
73
76
|
}
|
|
74
|
-
/** Returns every bound listener, ordered primary/local first. */
|
|
75
|
-
export function listHttpHostAddresses(host) {
|
|
76
|
-
const provider = host[addressProvider];
|
|
77
|
-
if (typeof provider === 'function')
|
|
78
|
-
return Object.freeze([...provider.call(host)]);
|
|
79
|
-
return Object.freeze(host.address ? [host.address] : []);
|
|
80
|
-
}
|
|
81
77
|
async function closeListeners(listeners) {
|
|
82
78
|
const results = await Promise.allSettled([...listeners].reverse().map((listener) => listener.close()));
|
|
83
79
|
const errors = results.flatMap((result) => result.status === 'rejected' ? [result.reason] : []);
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { HttpRequestInfo, WsUpgradeDecision, WsUpgradeRequest } from '../http-host.js';
|
|
2
|
+
import type { HttpHostAddress, HttpHostTlsOptions } from '../host-options.js';
|
|
3
|
+
import { type TrustProxySetting } from '../trust-proxy.js';
|
|
4
|
+
export interface NodeHttpListenerOptions {
|
|
5
|
+
readonly host?: string;
|
|
6
|
+
readonly port?: number;
|
|
7
|
+
/** Enables HTTPS / WSS on this listener. */
|
|
8
|
+
readonly tls?: HttpHostTlsOptions;
|
|
9
|
+
/** `http.trustProxy`: derive `remoteAddress` from forwarding headers sent by trusted peers. */
|
|
10
|
+
readonly trustProxy?: TrustProxySetting;
|
|
11
|
+
handle(request: Request, info: HttpRequestInfo): Promise<Response>;
|
|
12
|
+
upgrade(request: WsUpgradeRequest): Promise<WsUpgradeDecision>;
|
|
13
|
+
/** Called once the listener is bound, before `listen()` resolves. */
|
|
14
|
+
onListening?(address: HttpHostAddress): void;
|
|
15
|
+
/** Called after the listener closed. */
|
|
16
|
+
onClosed?(): void;
|
|
17
|
+
}
|
|
18
|
+
export interface NodeHttpListener {
|
|
19
|
+
listen(): Promise<HttpHostAddress>;
|
|
20
|
+
/** Destroys open connections (SSE, keep-alive, WebSocket) and stops listening. */
|
|
21
|
+
close(): Promise<void>;
|
|
22
|
+
readonly address: HttpHostAddress | undefined;
|
|
23
|
+
readonly secure: boolean;
|
|
24
|
+
}
|
|
25
|
+
/** A Node listener over {@link NodeHttpListenerOptions.handle} / `upgrade`. */
|
|
26
|
+
export declare function createNodeHttpListener(options: NodeHttpListenerOptions): NodeHttpListener;
|
|
@@ -0,0 +1,231 @@
|
|
|
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 { createServer as createHttpServer, STATUS_CODES, } from 'node:http';
|
|
7
|
+
import { createServer as createHttpsServer, } from 'node:https';
|
|
8
|
+
import { WebSocketServer } from 'ws';
|
|
9
|
+
import { formatCompact, getLogger } from '@zhin.js/logger';
|
|
10
|
+
import { compileTrustProxy, resolveClientAddress } from '../trust-proxy.js';
|
|
11
|
+
import { createNodeRequest, nodeRequestHeaders, writeNodeResponse, wrapNodeWebSocket } from './node-http.js';
|
|
12
|
+
const logger = getLogger('http');
|
|
13
|
+
/** A Node listener over {@link NodeHttpListenerOptions.handle} / `upgrade`. */
|
|
14
|
+
export function createNodeHttpListener(options) {
|
|
15
|
+
const host = options.host ?? '127.0.0.1';
|
|
16
|
+
const port = options.port ?? 8086;
|
|
17
|
+
const secure = options.tls !== undefined;
|
|
18
|
+
let address;
|
|
19
|
+
let closed = false;
|
|
20
|
+
let closeResult;
|
|
21
|
+
let listenResult;
|
|
22
|
+
const trust = compileTrustProxy(options.trustProxy);
|
|
23
|
+
const clientAddress = (request) => resolveClientAddress(request.socket?.remoteAddress, request.headers, trust);
|
|
24
|
+
const handleRequest = (request, response) => {
|
|
25
|
+
void dispatch(request, response).catch((error) => {
|
|
26
|
+
logger.warn(formatCompact({
|
|
27
|
+
op: 'http_dispatch_failed',
|
|
28
|
+
error: error instanceof Error ? error.message : String(error),
|
|
29
|
+
}));
|
|
30
|
+
if (!response.headersSent) {
|
|
31
|
+
response.writeHead(500);
|
|
32
|
+
response.end();
|
|
33
|
+
}
|
|
34
|
+
else {
|
|
35
|
+
response.destroy();
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
};
|
|
39
|
+
const server = secure
|
|
40
|
+
? createHttpsServer(options.tls, handleRequest)
|
|
41
|
+
: createHttpServer(handleRequest);
|
|
42
|
+
// Track live TCP sockets so close() can destroy long-lived connections
|
|
43
|
+
// (SSE, keep-alive) that would otherwise block server.close().
|
|
44
|
+
const sockets = new Set();
|
|
45
|
+
server.on('connection', (socket) => {
|
|
46
|
+
sockets.add(socket);
|
|
47
|
+
socket.once('close', () => {
|
|
48
|
+
sockets.delete(socket);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
const wss = new WebSocketServer({ noServer: true });
|
|
52
|
+
async function dispatch(request, response) {
|
|
53
|
+
if (closed) {
|
|
54
|
+
response.writeHead(503);
|
|
55
|
+
response.end();
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
let url;
|
|
59
|
+
try {
|
|
60
|
+
url = new URL(request.url ?? '/', 'http://localhost');
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
response.writeHead(400);
|
|
64
|
+
response.end();
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
const abort = new AbortController();
|
|
68
|
+
response.once('close', () => {
|
|
69
|
+
if (!response.writableFinished)
|
|
70
|
+
abort.abort(new Error('Client disconnected'));
|
|
71
|
+
});
|
|
72
|
+
let webRequest;
|
|
73
|
+
try {
|
|
74
|
+
webRequest = createNodeRequest(request, { url, signal: abort.signal });
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
response.writeHead(400);
|
|
78
|
+
response.end();
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
const remoteAddress = clientAddress(request);
|
|
82
|
+
const webResponse = await options.handle(webRequest, {
|
|
83
|
+
...(remoteAddress ? { remoteAddress } : {}),
|
|
84
|
+
});
|
|
85
|
+
await writeNodeResponse(response, webResponse);
|
|
86
|
+
}
|
|
87
|
+
server.on('upgrade', (request, socket, head) => {
|
|
88
|
+
if (closed) {
|
|
89
|
+
socket.destroy();
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
let url;
|
|
93
|
+
try {
|
|
94
|
+
url = new URL(request.url ?? '/', 'http://localhost');
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
socket.destroy();
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
const remoteAddress = clientAddress(request);
|
|
101
|
+
void options.upgrade({
|
|
102
|
+
url,
|
|
103
|
+
headers: nodeRequestHeaders(request),
|
|
104
|
+
...(remoteAddress ? { remoteAddress } : {}),
|
|
105
|
+
}).then((decision) => {
|
|
106
|
+
if (!decision.accepted) {
|
|
107
|
+
if (decision.status !== 404)
|
|
108
|
+
writeUpgradeRefusal(socket, decision.status, decision.headers);
|
|
109
|
+
socket.destroy();
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
if (closed || socket.destroyed) {
|
|
113
|
+
decision.reject();
|
|
114
|
+
writeUpgradeRefusal(socket, 401);
|
|
115
|
+
socket.destroy();
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
let accepted = false;
|
|
119
|
+
socket.once('close', () => { if (!accepted)
|
|
120
|
+
decision.reject(); });
|
|
121
|
+
try {
|
|
122
|
+
wss.handleUpgrade(request, socket, head, (ws) => {
|
|
123
|
+
accepted = true;
|
|
124
|
+
decision.accept(wrapNodeWebSocket(ws));
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
catch (err) {
|
|
128
|
+
decision.reject();
|
|
129
|
+
logger.warn(formatCompact({
|
|
130
|
+
op: 'ws_upgrade_failed',
|
|
131
|
+
path: url.pathname,
|
|
132
|
+
error: err instanceof Error ? err.message : String(err),
|
|
133
|
+
}));
|
|
134
|
+
try {
|
|
135
|
+
socket.destroy();
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
/* already destroyed */
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}, () => socket.destroy());
|
|
142
|
+
});
|
|
143
|
+
return {
|
|
144
|
+
get address() { return address; },
|
|
145
|
+
secure,
|
|
146
|
+
listen() {
|
|
147
|
+
if (closed)
|
|
148
|
+
return Promise.reject(new Error('HTTP Host is closed'));
|
|
149
|
+
if (listenResult)
|
|
150
|
+
return listenResult;
|
|
151
|
+
if (address)
|
|
152
|
+
return Promise.resolve(address);
|
|
153
|
+
listenResult = new Promise((resolve, reject) => {
|
|
154
|
+
const onError = (error) => {
|
|
155
|
+
listenResult = undefined;
|
|
156
|
+
reject(error);
|
|
157
|
+
};
|
|
158
|
+
server.once('error', onError);
|
|
159
|
+
server.listen({ host, port }, () => {
|
|
160
|
+
server.off('error', onError);
|
|
161
|
+
if (closed) {
|
|
162
|
+
reject(new Error('HTTP Host closed before listen completed'));
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
const bound = server.address();
|
|
166
|
+
const listenPort = typeof bound === 'object' && bound ? bound.port : port;
|
|
167
|
+
const publicHost = host === '0.0.0.0' || host === '::' ? '127.0.0.1' : host;
|
|
168
|
+
const protocol = secure ? 'https' : 'http';
|
|
169
|
+
const originHost = publicHost.includes(':') ? `[${publicHost}]` : publicHost;
|
|
170
|
+
address = Object.freeze({
|
|
171
|
+
host: publicHost, port: listenPort, protocol, secure,
|
|
172
|
+
origin: `${protocol}://${originHost}:${listenPort}`,
|
|
173
|
+
});
|
|
174
|
+
options.onListening?.(address);
|
|
175
|
+
resolve(address);
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
return listenResult;
|
|
179
|
+
},
|
|
180
|
+
close() {
|
|
181
|
+
if (closeResult)
|
|
182
|
+
return closeResult;
|
|
183
|
+
closed = true;
|
|
184
|
+
closeResult = (async () => {
|
|
185
|
+
// A pending listen must settle before deciding whether there is a
|
|
186
|
+
// listener to close. Otherwise it can bind after close has resolved.
|
|
187
|
+
await listenResult?.catch(() => undefined);
|
|
188
|
+
if (!server.listening) {
|
|
189
|
+
address = undefined;
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
await new Promise((resolve, reject) => {
|
|
193
|
+
// Long-lived connections (SSE, keep-alive, WS) never end on their own:
|
|
194
|
+
// destroy tracked sockets and terminate WS clients before close(),
|
|
195
|
+
// otherwise the close callbacks would never fire.
|
|
196
|
+
for (const socket of sockets)
|
|
197
|
+
socket.destroy();
|
|
198
|
+
sockets.clear();
|
|
199
|
+
for (const client of wss.clients)
|
|
200
|
+
client.terminate();
|
|
201
|
+
wss.close((wssError) => {
|
|
202
|
+
if (wssError) {
|
|
203
|
+
reject(wssError);
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
server.close((serverError) => {
|
|
207
|
+
if (serverError) {
|
|
208
|
+
reject(serverError);
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
address = undefined;
|
|
212
|
+
options.onClosed?.();
|
|
213
|
+
resolve();
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
})();
|
|
218
|
+
return closeResult;
|
|
219
|
+
},
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
function writeUpgradeRefusal(socket, status, headers) {
|
|
223
|
+
if (socket.destroyed)
|
|
224
|
+
return;
|
|
225
|
+
const lines = [`HTTP/1.1 ${status} ${STATUS_CODES[status] ?? ''}`.trimEnd()];
|
|
226
|
+
for (const [name, value] of Object.entries(headers ?? {})) {
|
|
227
|
+
if (/^[!#$%&'*+.^_`|~0-9a-z-]+$/iu.test(name) && !/[\r\n]/u.test(value))
|
|
228
|
+
lines.push(`${name}: ${value}`);
|
|
229
|
+
}
|
|
230
|
+
socket.write(`${lines.join('\r\n')}\r\n\r\n`);
|
|
231
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Node `http` / `ws` ↔ Web standard conversion (ADR 0054 §2). This module and the
|
|
3
|
+
* server built on it are the only places in `@zhin.js/host-http` that load
|
|
4
|
+
* `node:http` or `ws`; the gateway reuses them for its listener.
|
|
5
|
+
*/
|
|
6
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
7
|
+
import { WebSocket } from 'ws';
|
|
8
|
+
import type { WsTransport } from '../http-host.js';
|
|
9
|
+
export interface NodeRequestOptions {
|
|
10
|
+
/** Absolute request URL. */
|
|
11
|
+
readonly url: URL;
|
|
12
|
+
/** Aborted when the client goes away before the response finished. */
|
|
13
|
+
readonly signal?: AbortSignal;
|
|
14
|
+
/** Bytes buffered from the socket before reading pauses (default 64 KiB). */
|
|
15
|
+
readonly highWaterMark?: number;
|
|
16
|
+
}
|
|
17
|
+
/** Headers of a Node request as a Web `Headers` (repeated headers preserved). */
|
|
18
|
+
export declare function nodeRequestHeaders(request: IncomingMessage): Headers;
|
|
19
|
+
/**
|
|
20
|
+
* A Web `Request` over a Node request. The body streams from the socket with
|
|
21
|
+
* backpressure; cancelling it (or reading it to the end) never destroys the
|
|
22
|
+
* socket, so the response can still be written on the same connection.
|
|
23
|
+
*/
|
|
24
|
+
export declare function createNodeRequest(request: IncomingMessage, options: NodeRequestOptions): Request;
|
|
25
|
+
/**
|
|
26
|
+
* Writes a Web `Response` to a Node response: status, headers (`set-cookie`
|
|
27
|
+
* kept separate) and the body, honouring `drain` backpressure. A body that is
|
|
28
|
+
* immediately complete is sent with `Content-Length`; otherwise headers are
|
|
29
|
+
* flushed at once (SSE) and the body is chunked. A body stream error destroys
|
|
30
|
+
* the connection; a client disconnect cancels the body.
|
|
31
|
+
*/
|
|
32
|
+
export declare function writeNodeResponse(target: ServerResponse, response: Response): Promise<void>;
|
|
33
|
+
export interface NodeWebSocketOptions {
|
|
34
|
+
/** Protocol ping interval; a peer that misses one pong is terminated. 0 disables. Default 30 s. */
|
|
35
|
+
readonly keepAliveMs?: number;
|
|
36
|
+
}
|
|
37
|
+
/** A {@link WsTransport} over a `ws` server socket. */
|
|
38
|
+
export declare function wrapNodeWebSocket(socket: WebSocket, options?: NodeWebSocketOptions): WsTransport;
|