@zhin.js/host-http 1.1.17 → 1.2.0
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 +5 -1
- package/lib/console-plugin-rpc.js +5 -40
- 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 -14
- 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 +10 -5
- package/lib/index.js +9 -5
- 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 +5 -51
- 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 -19
- 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 +55 -13
- 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
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gateway ↔ instance control channel protocol (ADR 0054 §3).
|
|
3
|
+
*
|
|
4
|
+
* The channel is the Node child-process IPC channel opened by the gateway with
|
|
5
|
+
* `serialization: 'advanced'` (structured clone: `Uint8Array` travels as is). Every message is
|
|
6
|
+
* `{ type, id?, payload }`. Both sides import this module so the message shapes, limits and the
|
|
7
|
+
* protocol version stay in one place.
|
|
8
|
+
*
|
|
9
|
+
* Streams
|
|
10
|
+
* - HTTP exchanges and WebSocket connections are identified by a gateway-assigned `id`.
|
|
11
|
+
* - Bodies travel in chunks of at most {@link BridgeLimits.maxChunkBytes}. Flow control is
|
|
12
|
+
* credit based: each direction of a stream starts with {@link BridgeLimits.initialCreditBytes}
|
|
13
|
+
* of credit; the receiver grants more (`http.request.credit` / `http.response.credit`) as its
|
|
14
|
+
* consumer reads. A sender never has more than the window in flight, so memory per stream is
|
|
15
|
+
* bounded on both ends. A receiver that gets more than it granted fails the stream.
|
|
16
|
+
* - WebSocket frames are whole messages (at most {@link BridgeLimits.maxWsMessageBytes}); the
|
|
17
|
+
* receiver acknowledges delivered bytes with `ws.drain`, which drives `bufferedAmount`.
|
|
18
|
+
* - Either side cancels: `http.request.abort` (gateway: client went away / timeout),
|
|
19
|
+
* `http.response.error` (instance: handler or body failed), `ws.close` (both).
|
|
20
|
+
*
|
|
21
|
+
* This module must not import `node:http` / `ws`: the instance loads it.
|
|
22
|
+
*/
|
|
23
|
+
import type { ConsolePluginPermissionInfo } from '@zhin.js/console-protocol';
|
|
24
|
+
import type { ConsoleAuditEntry, ConsolePrincipal } from '../access-control.js';
|
|
25
|
+
import type { HttpHostAddress } from '../host-options.js';
|
|
26
|
+
import type { RouteMeta } from '../openapi.js';
|
|
27
|
+
/**
|
|
28
|
+
* Bumped on any incompatible change to the control channel messages.
|
|
29
|
+
* - 1: `ready` / `restart-request` / `shutdown` (B1.1).
|
|
30
|
+
* - 2: route table, HTTP streams, WebSocket frames, forwarded principal (B1.2); interim delegated
|
|
31
|
+
* credential resolution (`auth.*`).
|
|
32
|
+
* - 3: Console accounts live in the gateway (B1.4): `auth.*` removed; `catalog` (plugin permission
|
|
33
|
+
* catalog) and `audit` (instance-side audit entries) from the instance; `principal-revoked`
|
|
34
|
+
* from the gateway.
|
|
35
|
+
*/
|
|
36
|
+
export declare const INSTANCE_BRIDGE_PROTOCOL_VERSION = 3;
|
|
37
|
+
/** Protocol versions this build can talk to. */
|
|
38
|
+
export declare const SUPPORTED_BRIDGE_PROTOCOL_VERSIONS: readonly number[];
|
|
39
|
+
export declare function isBridgeProtocolCompatible(version: number): boolean;
|
|
40
|
+
/**
|
|
41
|
+
* Set by the gateway on the instance child process; marks the process as a supervised instance.
|
|
42
|
+
* Its value is the gateway's {@link INSTANCE_BRIDGE_PROTOCOL_VERSION}, so the instance can
|
|
43
|
+
* refuse an incompatible gateway before it starts anything.
|
|
44
|
+
*/
|
|
45
|
+
export declare const INSTANCE_CHILD_ENV = "ZHIN_RUNTIME_CHILD";
|
|
46
|
+
/**
|
|
47
|
+
* Set by the gateway on the instance child process: the gateway's local control endpoint (socket
|
|
48
|
+
* path — including the `os.tmpdir()` fallback for deep project roots — or named pipe). Whoever
|
|
49
|
+
* connects to it is the local owner (ADR 0054 §6), so the instance keeps the Agent's file and
|
|
50
|
+
* shell tools away from it. Absent when the gateway runs without a local socket.
|
|
51
|
+
*/
|
|
52
|
+
export declare const GATEWAY_SOCKET_ENV = "ZHIN_GATEWAY_SOCKET";
|
|
53
|
+
export declare class BridgeProtocolMismatchError extends Error {
|
|
54
|
+
readonly peerVersion: number;
|
|
55
|
+
readonly side: 'gateway' | 'instance';
|
|
56
|
+
readonly code = "ZHIN_BRIDGE_PROTOCOL_MISMATCH";
|
|
57
|
+
constructor(peerVersion: number, side: 'gateway' | 'instance');
|
|
58
|
+
}
|
|
59
|
+
/** Per-frame size limits and stream windows. Both ends use the same defaults. */
|
|
60
|
+
export interface BridgeLimits {
|
|
61
|
+
/** Largest body chunk in one message. */
|
|
62
|
+
readonly maxChunkBytes: number;
|
|
63
|
+
/** Credit each stream direction starts with (also the most a receiver buffers). */
|
|
64
|
+
readonly initialCreditBytes: number;
|
|
65
|
+
/** Request line + headers (names and values) of one request or response head. */
|
|
66
|
+
readonly maxHeadBytes: number;
|
|
67
|
+
/** Largest WebSocket message (text is measured as UTF-16 length × 3 upper bound). */
|
|
68
|
+
readonly maxWsMessageBytes: number;
|
|
69
|
+
/** Unacknowledged WebSocket bytes a sender queues before closing with 1013. */
|
|
70
|
+
readonly maxWsBufferedBytes: number;
|
|
71
|
+
/** Concurrent HTTP exchanges + WebSocket connections per instance. */
|
|
72
|
+
readonly maxStreams: number;
|
|
73
|
+
/** Entries in a route table. */
|
|
74
|
+
readonly maxRoutes: number;
|
|
75
|
+
}
|
|
76
|
+
export declare const DEFAULT_BRIDGE_LIMITS: BridgeLimits;
|
|
77
|
+
/** Gateway: time to wait for a response head before failing with `timeout`. */
|
|
78
|
+
export declare const DEFAULT_BRIDGE_RESPONSE_TIMEOUT_MS = 120000;
|
|
79
|
+
/** Gateway: time to wait for `ws.accepted` / `ws.rejected`. */
|
|
80
|
+
export declare const DEFAULT_BRIDGE_WS_OPEN_TIMEOUT_MS = 30000;
|
|
81
|
+
export interface BridgeMessage<Type extends string, Payload> {
|
|
82
|
+
readonly type: Type;
|
|
83
|
+
readonly id?: string;
|
|
84
|
+
readonly payload: Payload;
|
|
85
|
+
}
|
|
86
|
+
/** A message that belongs to one stream (`id` is required). */
|
|
87
|
+
export interface BridgeStreamMessage<Type extends string, Payload> extends BridgeMessage<Type, Payload> {
|
|
88
|
+
readonly id: string;
|
|
89
|
+
}
|
|
90
|
+
/** Header list; repeated names (e.g. `set-cookie`) stay separate. */
|
|
91
|
+
export type BridgeHeaders = readonly (readonly [string, string])[];
|
|
92
|
+
export interface BridgeHttpRoute {
|
|
93
|
+
readonly method: string;
|
|
94
|
+
/** `/path` or `/prefix/*`. */
|
|
95
|
+
readonly pattern: string;
|
|
96
|
+
readonly meta?: RouteMeta;
|
|
97
|
+
}
|
|
98
|
+
export interface BridgeWsRoute {
|
|
99
|
+
readonly path: string;
|
|
100
|
+
/** `host`: the gateway authenticates before upgrade; `protocol`: the route authenticates itself. */
|
|
101
|
+
readonly auth: 'host' | 'protocol';
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Routes the instance currently serves. The gateway authenticates `{apiBase}` routes and
|
|
105
|
+
* `auth: 'host'` sockets and forwards the rest unauthenticated (ADR 0054 §3).
|
|
106
|
+
*/
|
|
107
|
+
export interface BridgeRouteTable {
|
|
108
|
+
/** Increases with every table the instance sends. */
|
|
109
|
+
readonly revision: number;
|
|
110
|
+
/** Committed Runtime generation, when known. */
|
|
111
|
+
readonly generation?: number;
|
|
112
|
+
readonly apiBase: string;
|
|
113
|
+
readonly http: readonly BridgeHttpRoute[];
|
|
114
|
+
readonly ws: readonly BridgeWsRoute[];
|
|
115
|
+
}
|
|
116
|
+
export declare const EMPTY_ROUTE_TABLE: BridgeRouteTable;
|
|
117
|
+
export interface InstanceReadyPayload {
|
|
118
|
+
/** {@link INSTANCE_BRIDGE_PROTOCOL_VERSION} the instance was built with. */
|
|
119
|
+
readonly version: number;
|
|
120
|
+
/** Committed Runtime generation at the time the instance became ready. */
|
|
121
|
+
readonly generation?: number;
|
|
122
|
+
readonly pid: number;
|
|
123
|
+
/** Route table at ready time (also sent earlier with `routes`). */
|
|
124
|
+
readonly routes?: BridgeRouteTable;
|
|
125
|
+
}
|
|
126
|
+
export interface InstanceRestartRequestPayload {
|
|
127
|
+
/** Short machine-readable reason, e.g. `console`, `dependencies`, `self-change`. */
|
|
128
|
+
readonly reason: string;
|
|
129
|
+
}
|
|
130
|
+
export interface HttpResponseHeadPayload {
|
|
131
|
+
readonly status: number;
|
|
132
|
+
readonly statusText?: string;
|
|
133
|
+
readonly headers: BridgeHeaders;
|
|
134
|
+
/** False for a bodiless response: no `http.response.body` follows, `http.response.end` does. */
|
|
135
|
+
readonly body: boolean;
|
|
136
|
+
}
|
|
137
|
+
export interface BodyChunkPayload {
|
|
138
|
+
readonly chunk: Uint8Array;
|
|
139
|
+
}
|
|
140
|
+
export interface CreditPayload {
|
|
141
|
+
readonly bytes: number;
|
|
142
|
+
}
|
|
143
|
+
export interface StreamErrorPayload {
|
|
144
|
+
readonly message: string;
|
|
145
|
+
}
|
|
146
|
+
export interface WsRejectedPayload {
|
|
147
|
+
readonly status: 401 | 403 | 404 | 503;
|
|
148
|
+
}
|
|
149
|
+
export interface WsMessagePayload {
|
|
150
|
+
readonly data: string | Uint8Array;
|
|
151
|
+
}
|
|
152
|
+
export interface WsClosePayload {
|
|
153
|
+
readonly code: number;
|
|
154
|
+
readonly reason: string;
|
|
155
|
+
}
|
|
156
|
+
export type InstanceReadyMessage = BridgeMessage<'ready', InstanceReadyPayload>;
|
|
157
|
+
export type InstanceRestartRequestMessage = BridgeMessage<'restart-request', InstanceRestartRequestPayload>;
|
|
158
|
+
export type InstanceRoutesMessage = BridgeMessage<'routes', {
|
|
159
|
+
readonly table: BridgeRouteTable;
|
|
160
|
+
}>;
|
|
161
|
+
export type HttpResponseHeadMessage = BridgeStreamMessage<'http.response.head', HttpResponseHeadPayload>;
|
|
162
|
+
export type HttpResponseBodyMessage = BridgeStreamMessage<'http.response.body', BodyChunkPayload>;
|
|
163
|
+
export type HttpResponseEndMessage = BridgeStreamMessage<'http.response.end', Record<string, never>>;
|
|
164
|
+
export type HttpResponseErrorMessage = BridgeStreamMessage<'http.response.error', StreamErrorPayload>;
|
|
165
|
+
export type HttpRequestCreditMessage = BridgeStreamMessage<'http.request.credit', CreditPayload>;
|
|
166
|
+
export type WsAcceptedMessage = BridgeStreamMessage<'ws.accepted', Record<string, never>>;
|
|
167
|
+
export type WsRejectedMessage = BridgeStreamMessage<'ws.rejected', WsRejectedPayload>;
|
|
168
|
+
export type WsMessageMessage = BridgeStreamMessage<'ws.message', WsMessagePayload>;
|
|
169
|
+
export type WsCloseMessage = BridgeStreamMessage<'ws.close', WsClosePayload>;
|
|
170
|
+
export type WsDrainMessage = BridgeStreamMessage<'ws.drain', CreditPayload>;
|
|
171
|
+
/**
|
|
172
|
+
* Plugin permission catalog of the instance's current generation (ADR 0049 amendment): the
|
|
173
|
+
* gateway uses it for level grants in `/auth/me`, `iam.roles.list` and its own authorization.
|
|
174
|
+
* Sent on listen and whenever it changes (generation commit).
|
|
175
|
+
*/
|
|
176
|
+
export interface InstanceCatalogPayload {
|
|
177
|
+
readonly pluginPermissions: readonly ConsolePluginPermissionInfo[];
|
|
178
|
+
}
|
|
179
|
+
/** An audit entry written by the instance (its RPC dispatcher); the gateway owns the audit log. */
|
|
180
|
+
export interface InstanceAuditPayload {
|
|
181
|
+
readonly entry: ConsoleAuditEntry;
|
|
182
|
+
}
|
|
183
|
+
export type InstanceCatalogMessage = BridgeMessage<'catalog', InstanceCatalogPayload>;
|
|
184
|
+
export type InstanceAuditMessage = BridgeMessage<'audit', InstanceAuditPayload>;
|
|
185
|
+
export type InstanceToGatewayMessage = InstanceReadyMessage | InstanceCatalogMessage | InstanceAuditMessage | InstanceRestartRequestMessage | InstanceRoutesMessage | HttpResponseHeadMessage | HttpResponseBodyMessage | HttpResponseEndMessage | HttpResponseErrorMessage | HttpRequestCreditMessage | WsAcceptedMessage | WsRejectedMessage | WsMessageMessage | WsCloseMessage | WsDrainMessage;
|
|
186
|
+
export interface GatewayShutdownPayload {
|
|
187
|
+
/** Why the gateway stops the instance, e.g. `stop`, `restart`, `protocol-mismatch`. */
|
|
188
|
+
readonly reason: string;
|
|
189
|
+
/** Graceful stop budget; the gateway escalates to SIGTERM / SIGKILL after it. */
|
|
190
|
+
readonly budgetMs: number;
|
|
191
|
+
}
|
|
192
|
+
export interface HttpRequestPayload {
|
|
193
|
+
readonly method: string;
|
|
194
|
+
/** Absolute URL as the gateway received it. */
|
|
195
|
+
readonly url: string;
|
|
196
|
+
readonly headers: BridgeHeaders;
|
|
197
|
+
/** True when `http.request.body` chunks (then `http.request.end`) follow. */
|
|
198
|
+
readonly body: boolean;
|
|
199
|
+
/** Principal the gateway authenticated (ADR 0054 §3); authorization happens in the instance. */
|
|
200
|
+
readonly principal?: ConsolePrincipal;
|
|
201
|
+
readonly remoteAddress?: string;
|
|
202
|
+
}
|
|
203
|
+
export interface WsOpenPayload {
|
|
204
|
+
readonly url: string;
|
|
205
|
+
readonly headers: BridgeHeaders;
|
|
206
|
+
readonly principal?: ConsolePrincipal;
|
|
207
|
+
readonly remoteAddress?: string;
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* A user's credentials were revoked or narrowed (disabled, deleted, password changed, bindings
|
|
211
|
+
* changed): the instance closes that principal's live connections (SSE, WebSocket).
|
|
212
|
+
*/
|
|
213
|
+
export interface PrincipalRevokedPayload {
|
|
214
|
+
/** `ConsoleUserPrincipal.userId`. */
|
|
215
|
+
readonly principalId: string;
|
|
216
|
+
readonly reason?: string;
|
|
217
|
+
}
|
|
218
|
+
export type GatewayShutdownMessage = BridgeMessage<'shutdown', GatewayShutdownPayload>;
|
|
219
|
+
export type GatewayPrincipalRevokedMessage = BridgeMessage<'principal-revoked', PrincipalRevokedPayload>;
|
|
220
|
+
export type GatewayAddressesMessage = BridgeMessage<'host.addresses', {
|
|
221
|
+
readonly addresses: readonly HttpHostAddress[];
|
|
222
|
+
}>;
|
|
223
|
+
export type HttpRequestMessage = BridgeStreamMessage<'http.request', HttpRequestPayload>;
|
|
224
|
+
export type HttpRequestBodyMessage = BridgeStreamMessage<'http.request.body', BodyChunkPayload>;
|
|
225
|
+
export type HttpRequestEndMessage = BridgeStreamMessage<'http.request.end', Record<string, never>>;
|
|
226
|
+
export type HttpRequestAbortMessage = BridgeStreamMessage<'http.request.abort', StreamErrorPayload>;
|
|
227
|
+
export type HttpResponseCreditMessage = BridgeStreamMessage<'http.response.credit', CreditPayload>;
|
|
228
|
+
export type WsOpenMessage = BridgeStreamMessage<'ws.open', WsOpenPayload>;
|
|
229
|
+
export type GatewayToInstanceMessage = GatewayShutdownMessage | GatewayPrincipalRevokedMessage | GatewayAddressesMessage | HttpRequestMessage | HttpRequestBodyMessage | HttpRequestEndMessage | HttpRequestAbortMessage | HttpResponseCreditMessage | WsOpenMessage | WsMessageMessage | WsCloseMessage | WsDrainMessage;
|
|
230
|
+
/** Bytes a head occupies (UTF-16 length; ASCII headers are exact). */
|
|
231
|
+
export declare function headSize(url: string, headers: BridgeHeaders): number;
|
|
232
|
+
/** Upper bound of the UTF-8 size of a text frame. */
|
|
233
|
+
export declare function wsMessageSize(data: string | Uint8Array): number;
|
|
234
|
+
/** Validates a principal forwarded by the gateway; `undefined` when malformed. */
|
|
235
|
+
export declare function parseBridgePrincipal(value: unknown): ConsolePrincipal | undefined;
|
|
236
|
+
/** Validates an untrusted message received from the instance. */
|
|
237
|
+
export declare function parseInstanceMessage(value: unknown, limits?: BridgeLimits): InstanceToGatewayMessage | undefined;
|
|
238
|
+
/** Validates an untrusted message received from the gateway. */
|
|
239
|
+
export declare function parseGatewayMessage(value: unknown, limits?: BridgeLimits): GatewayToInstanceMessage | undefined;
|
|
240
|
+
/** Headers as a bridge header list (repeated `set-cookie` stays separate). */
|
|
241
|
+
export declare function toBridgeHeaders(headers: Headers): BridgeHeaders;
|
|
242
|
+
export declare function fromBridgeHeaders(list: BridgeHeaders): Headers;
|
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bumped on any incompatible change to the control channel messages.
|
|
3
|
+
* - 1: `ready` / `restart-request` / `shutdown` (B1.1).
|
|
4
|
+
* - 2: route table, HTTP streams, WebSocket frames, forwarded principal (B1.2); interim delegated
|
|
5
|
+
* credential resolution (`auth.*`).
|
|
6
|
+
* - 3: Console accounts live in the gateway (B1.4): `auth.*` removed; `catalog` (plugin permission
|
|
7
|
+
* catalog) and `audit` (instance-side audit entries) from the instance; `principal-revoked`
|
|
8
|
+
* from the gateway.
|
|
9
|
+
*/
|
|
10
|
+
export const INSTANCE_BRIDGE_PROTOCOL_VERSION = 3;
|
|
11
|
+
/** Protocol versions this build can talk to. */
|
|
12
|
+
export const SUPPORTED_BRIDGE_PROTOCOL_VERSIONS = Object.freeze([INSTANCE_BRIDGE_PROTOCOL_VERSION]);
|
|
13
|
+
export function isBridgeProtocolCompatible(version) {
|
|
14
|
+
return SUPPORTED_BRIDGE_PROTOCOL_VERSIONS.includes(version);
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Set by the gateway on the instance child process; marks the process as a supervised instance.
|
|
18
|
+
* Its value is the gateway's {@link INSTANCE_BRIDGE_PROTOCOL_VERSION}, so the instance can
|
|
19
|
+
* refuse an incompatible gateway before it starts anything.
|
|
20
|
+
*/
|
|
21
|
+
export const INSTANCE_CHILD_ENV = 'ZHIN_RUNTIME_CHILD';
|
|
22
|
+
/**
|
|
23
|
+
* Set by the gateway on the instance child process: the gateway's local control endpoint (socket
|
|
24
|
+
* path — including the `os.tmpdir()` fallback for deep project roots — or named pipe). Whoever
|
|
25
|
+
* connects to it is the local owner (ADR 0054 §6), so the instance keeps the Agent's file and
|
|
26
|
+
* shell tools away from it. Absent when the gateway runs without a local socket.
|
|
27
|
+
*/
|
|
28
|
+
export const GATEWAY_SOCKET_ENV = 'ZHIN_GATEWAY_SOCKET';
|
|
29
|
+
export class BridgeProtocolMismatchError extends Error {
|
|
30
|
+
peerVersion;
|
|
31
|
+
side;
|
|
32
|
+
code = 'ZHIN_BRIDGE_PROTOCOL_MISMATCH';
|
|
33
|
+
constructor(peerVersion, side) {
|
|
34
|
+
super(`${side === 'gateway' ? 'Gateway' : 'Instance'} control channel protocol ${peerVersion} is not supported `
|
|
35
|
+
+ `(this build speaks ${SUPPORTED_BRIDGE_PROTOCOL_VERSIONS.join(', ')}); upgrade zhin.js and @zhin.js/gateway together`);
|
|
36
|
+
this.peerVersion = peerVersion;
|
|
37
|
+
this.side = side;
|
|
38
|
+
this.name = 'BridgeProtocolMismatchError';
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
export const DEFAULT_BRIDGE_LIMITS = Object.freeze({
|
|
42
|
+
maxChunkBytes: 64 * 1024,
|
|
43
|
+
initialCreditBytes: 256 * 1024,
|
|
44
|
+
maxHeadBytes: 64 * 1024,
|
|
45
|
+
maxWsMessageBytes: 16 * 1024 * 1024,
|
|
46
|
+
maxWsBufferedBytes: 32 * 1024 * 1024,
|
|
47
|
+
maxStreams: 4096,
|
|
48
|
+
maxRoutes: 4096,
|
|
49
|
+
});
|
|
50
|
+
/** Gateway: time to wait for a response head before failing with `timeout`. */
|
|
51
|
+
export const DEFAULT_BRIDGE_RESPONSE_TIMEOUT_MS = 120_000;
|
|
52
|
+
/** Gateway: time to wait for `ws.accepted` / `ws.rejected`. */
|
|
53
|
+
export const DEFAULT_BRIDGE_WS_OPEN_TIMEOUT_MS = 30_000;
|
|
54
|
+
const MAX_ID_LENGTH = 64;
|
|
55
|
+
const MAX_TEXT_LENGTH = 1024;
|
|
56
|
+
export const EMPTY_ROUTE_TABLE = Object.freeze({
|
|
57
|
+
revision: 0,
|
|
58
|
+
apiBase: '/api',
|
|
59
|
+
http: Object.freeze([]),
|
|
60
|
+
ws: Object.freeze([]),
|
|
61
|
+
});
|
|
62
|
+
// ---- validation (messages are untrusted on both ends)
|
|
63
|
+
function isRecord(value) {
|
|
64
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
65
|
+
}
|
|
66
|
+
function isFiniteNonNegative(value) {
|
|
67
|
+
return typeof value === 'number' && Number.isFinite(value) && value >= 0;
|
|
68
|
+
}
|
|
69
|
+
function isText(value, max = MAX_TEXT_LENGTH) {
|
|
70
|
+
return typeof value === 'string' && value.length <= max;
|
|
71
|
+
}
|
|
72
|
+
function hasValidId(message) {
|
|
73
|
+
return message.id === undefined || (typeof message.id === 'string' && message.id.length > 0 && message.id.length <= MAX_ID_LENGTH);
|
|
74
|
+
}
|
|
75
|
+
function hasStreamId(message) {
|
|
76
|
+
return typeof message.id === 'string' && message.id.length > 0 && message.id.length <= MAX_ID_LENGTH;
|
|
77
|
+
}
|
|
78
|
+
/** Bytes a head occupies (UTF-16 length; ASCII headers are exact). */
|
|
79
|
+
export function headSize(url, headers) {
|
|
80
|
+
let size = url.length;
|
|
81
|
+
for (const [name, value] of headers)
|
|
82
|
+
size += name.length + value.length + 4;
|
|
83
|
+
return size;
|
|
84
|
+
}
|
|
85
|
+
function isHeaders(value, limits, extra = 0) {
|
|
86
|
+
if (!Array.isArray(value))
|
|
87
|
+
return false;
|
|
88
|
+
let size = extra;
|
|
89
|
+
for (const entry of value) {
|
|
90
|
+
if (!Array.isArray(entry) || entry.length !== 2)
|
|
91
|
+
return false;
|
|
92
|
+
if (typeof entry[0] !== 'string' || typeof entry[1] !== 'string')
|
|
93
|
+
return false;
|
|
94
|
+
size += entry[0].length + entry[1].length + 4;
|
|
95
|
+
if (size > limits.maxHeadBytes)
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
100
|
+
function isChunk(value, limits) {
|
|
101
|
+
return value instanceof Uint8Array && value.byteLength <= limits.maxChunkBytes;
|
|
102
|
+
}
|
|
103
|
+
function isCredit(payload) {
|
|
104
|
+
return Number.isSafeInteger(payload.bytes) && payload.bytes > 0;
|
|
105
|
+
}
|
|
106
|
+
/** Upper bound of the UTF-8 size of a text frame. */
|
|
107
|
+
export function wsMessageSize(data) {
|
|
108
|
+
return typeof data === 'string' ? data.length * 3 : data.byteLength;
|
|
109
|
+
}
|
|
110
|
+
function isWsData(value, limits) {
|
|
111
|
+
if (typeof value === 'string')
|
|
112
|
+
return value.length <= limits.maxWsMessageBytes;
|
|
113
|
+
return value instanceof Uint8Array && value.byteLength <= limits.maxWsMessageBytes;
|
|
114
|
+
}
|
|
115
|
+
function isWsClose(payload) {
|
|
116
|
+
return Number.isSafeInteger(payload.code) && payload.code >= 1000 && payload.code <= 4999
|
|
117
|
+
&& isText(payload.reason, 123);
|
|
118
|
+
}
|
|
119
|
+
function isAddress(value) {
|
|
120
|
+
if (!isRecord(value))
|
|
121
|
+
return false;
|
|
122
|
+
return isText(value.host) && Number.isSafeInteger(value.port) && (value.protocol === 'http' || value.protocol === 'https')
|
|
123
|
+
&& typeof value.secure === 'boolean' && isText(value.origin);
|
|
124
|
+
}
|
|
125
|
+
/** Validates a principal forwarded by the gateway; `undefined` when malformed. */
|
|
126
|
+
export function parseBridgePrincipal(value) {
|
|
127
|
+
if (!isRecord(value))
|
|
128
|
+
return undefined;
|
|
129
|
+
if (value.kind === 'bootstrap') {
|
|
130
|
+
return typeof value.open === 'boolean' ? Object.freeze({ kind: 'bootstrap', open: value.open }) : undefined;
|
|
131
|
+
}
|
|
132
|
+
if (value.kind !== 'user' && value.kind !== 'api-token')
|
|
133
|
+
return undefined;
|
|
134
|
+
if (!isText(value.userId) || !isText(value.username) || !isText(value.displayName) || !isText(value.credentialId))
|
|
135
|
+
return undefined;
|
|
136
|
+
if (!Array.isArray(value.roles) || !value.roles.every((role) => isText(role)))
|
|
137
|
+
return undefined;
|
|
138
|
+
if (!Array.isArray(value.grants) || value.grants.length > 1024)
|
|
139
|
+
return undefined;
|
|
140
|
+
const grants = [];
|
|
141
|
+
for (const grant of value.grants) {
|
|
142
|
+
if (!isRecord(grant) || !isRecord(grant.scope))
|
|
143
|
+
return undefined;
|
|
144
|
+
if (grant.role !== undefined && !isText(grant.role))
|
|
145
|
+
return undefined;
|
|
146
|
+
const scope = grant.scope;
|
|
147
|
+
let parsedScope;
|
|
148
|
+
if (scope.kind === 'global')
|
|
149
|
+
parsedScope = Object.freeze({ kind: 'global' });
|
|
150
|
+
else if ((scope.kind === 'adapter' || scope.kind === 'endpoint') && isText(scope.key)) {
|
|
151
|
+
parsedScope = Object.freeze({ kind: scope.kind, key: scope.key });
|
|
152
|
+
}
|
|
153
|
+
else
|
|
154
|
+
return undefined;
|
|
155
|
+
if (!Array.isArray(grant.permissions) || !grant.permissions.every((permission) => isText(permission)))
|
|
156
|
+
return undefined;
|
|
157
|
+
grants.push(Object.freeze({
|
|
158
|
+
...(grant.role !== undefined ? { role: grant.role } : {}),
|
|
159
|
+
scope: parsedScope,
|
|
160
|
+
permissions: Object.freeze([...grant.permissions]),
|
|
161
|
+
}));
|
|
162
|
+
}
|
|
163
|
+
return Object.freeze({
|
|
164
|
+
kind: value.kind,
|
|
165
|
+
userId: value.userId,
|
|
166
|
+
username: value.username,
|
|
167
|
+
displayName: value.displayName,
|
|
168
|
+
credentialId: value.credentialId,
|
|
169
|
+
grants: Object.freeze(grants),
|
|
170
|
+
roles: Object.freeze([...value.roles]),
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
function isRouteTable(value, limits) {
|
|
174
|
+
if (!isRecord(value))
|
|
175
|
+
return false;
|
|
176
|
+
if (!Number.isSafeInteger(value.revision) || value.revision < 0)
|
|
177
|
+
return false;
|
|
178
|
+
if (value.generation !== undefined && !isFiniteNonNegative(value.generation))
|
|
179
|
+
return false;
|
|
180
|
+
if (!isText(value.apiBase))
|
|
181
|
+
return false;
|
|
182
|
+
if (!Array.isArray(value.http) || !Array.isArray(value.ws))
|
|
183
|
+
return false;
|
|
184
|
+
if (value.http.length + value.ws.length > limits.maxRoutes)
|
|
185
|
+
return false;
|
|
186
|
+
for (const route of value.http) {
|
|
187
|
+
if (!isRecord(route) || !isText(route.method, 16) || !isText(route.pattern, 2048))
|
|
188
|
+
return false;
|
|
189
|
+
if (route.meta !== undefined && !isRecord(route.meta))
|
|
190
|
+
return false;
|
|
191
|
+
}
|
|
192
|
+
for (const route of value.ws) {
|
|
193
|
+
if (!isRecord(route) || !isText(route.path, 2048) || (route.auth !== 'host' && route.auth !== 'protocol'))
|
|
194
|
+
return false;
|
|
195
|
+
}
|
|
196
|
+
return true;
|
|
197
|
+
}
|
|
198
|
+
const MAX_CATALOG_ENTRIES = 4096;
|
|
199
|
+
const PLUGIN_PERMISSION_LEVELS = new Set(['read', 'operate', 'manage']);
|
|
200
|
+
function isCatalogEntry(value) {
|
|
201
|
+
if (!isRecord(value))
|
|
202
|
+
return false;
|
|
203
|
+
if (!isText(value.id, 256) || !PLUGIN_PERMISSION_LEVELS.has(value.level) || typeof value.declared !== 'boolean')
|
|
204
|
+
return false;
|
|
205
|
+
if (value.description !== undefined && !isText(value.description))
|
|
206
|
+
return false;
|
|
207
|
+
return Array.isArray(value.owners) && value.owners.length <= 256 && value.owners.every((owner) => isText(owner, 256));
|
|
208
|
+
}
|
|
209
|
+
function isAuditEntry(value) {
|
|
210
|
+
if (!isRecord(value))
|
|
211
|
+
return false;
|
|
212
|
+
if (!isText(value.actor, 256) || !isText(value.action, 512))
|
|
213
|
+
return false;
|
|
214
|
+
if (value.outcome !== 'ok' && value.outcome !== 'denied' && value.outcome !== 'error')
|
|
215
|
+
return false;
|
|
216
|
+
if (value.target !== undefined && !isText(value.target, 512))
|
|
217
|
+
return false;
|
|
218
|
+
if (value.ip !== undefined && !isText(value.ip, 256))
|
|
219
|
+
return false;
|
|
220
|
+
if (value.detail !== undefined && !isRecord(value.detail))
|
|
221
|
+
return false;
|
|
222
|
+
return true;
|
|
223
|
+
}
|
|
224
|
+
function isOptionalPrincipal(value) {
|
|
225
|
+
return value === undefined || parseBridgePrincipal(value) !== undefined;
|
|
226
|
+
}
|
|
227
|
+
function isOptionalRemote(value) {
|
|
228
|
+
return value === undefined || isText(value, 256);
|
|
229
|
+
}
|
|
230
|
+
/** Validates an untrusted message received from the instance. */
|
|
231
|
+
export function parseInstanceMessage(value, limits = DEFAULT_BRIDGE_LIMITS) {
|
|
232
|
+
if (!isRecord(value) || !hasValidId(value) || !isRecord(value.payload))
|
|
233
|
+
return undefined;
|
|
234
|
+
const payload = value.payload;
|
|
235
|
+
switch (value.type) {
|
|
236
|
+
case 'ready':
|
|
237
|
+
if (!Number.isSafeInteger(payload.version))
|
|
238
|
+
return undefined;
|
|
239
|
+
if (!Number.isSafeInteger(payload.pid))
|
|
240
|
+
return undefined;
|
|
241
|
+
if (payload.generation !== undefined && !isFiniteNonNegative(payload.generation))
|
|
242
|
+
return undefined;
|
|
243
|
+
if (payload.routes !== undefined && !isRouteTable(payload.routes, limits))
|
|
244
|
+
return undefined;
|
|
245
|
+
return value;
|
|
246
|
+
case 'restart-request':
|
|
247
|
+
if (typeof payload.reason !== 'string')
|
|
248
|
+
return undefined;
|
|
249
|
+
return value;
|
|
250
|
+
case 'routes':
|
|
251
|
+
return isRouteTable(payload.table, limits) ? value : undefined;
|
|
252
|
+
case 'catalog': {
|
|
253
|
+
const list = payload.pluginPermissions;
|
|
254
|
+
if (!Array.isArray(list) || list.length > MAX_CATALOG_ENTRIES || !list.every(isCatalogEntry))
|
|
255
|
+
return undefined;
|
|
256
|
+
return value;
|
|
257
|
+
}
|
|
258
|
+
case 'audit':
|
|
259
|
+
return isAuditEntry(payload.entry) ? value : undefined;
|
|
260
|
+
default:
|
|
261
|
+
break;
|
|
262
|
+
}
|
|
263
|
+
if (!hasStreamId(value))
|
|
264
|
+
return undefined;
|
|
265
|
+
switch (value.type) {
|
|
266
|
+
case 'http.response.head':
|
|
267
|
+
if (!Number.isSafeInteger(payload.status) || payload.status < 200 || payload.status > 599)
|
|
268
|
+
return undefined;
|
|
269
|
+
if (payload.statusText !== undefined && !isText(payload.statusText))
|
|
270
|
+
return undefined;
|
|
271
|
+
if (typeof payload.body !== 'boolean' || !isHeaders(payload.headers, limits))
|
|
272
|
+
return undefined;
|
|
273
|
+
return value;
|
|
274
|
+
case 'http.response.body':
|
|
275
|
+
return isChunk(payload.chunk, limits) ? value : undefined;
|
|
276
|
+
case 'http.response.end':
|
|
277
|
+
case 'ws.accepted':
|
|
278
|
+
return value;
|
|
279
|
+
case 'http.response.error':
|
|
280
|
+
return isText(payload.message) ? value : undefined;
|
|
281
|
+
case 'http.request.credit':
|
|
282
|
+
case 'ws.drain':
|
|
283
|
+
return isCredit(payload) ? value : undefined;
|
|
284
|
+
case 'ws.rejected':
|
|
285
|
+
return [401, 403, 404, 503].includes(payload.status) ? value : undefined;
|
|
286
|
+
case 'ws.message':
|
|
287
|
+
return isWsData(payload.data, limits) ? value : undefined;
|
|
288
|
+
case 'ws.close':
|
|
289
|
+
return isWsClose(payload) ? value : undefined;
|
|
290
|
+
default:
|
|
291
|
+
return undefined;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
/** Validates an untrusted message received from the gateway. */
|
|
295
|
+
export function parseGatewayMessage(value, limits = DEFAULT_BRIDGE_LIMITS) {
|
|
296
|
+
if (!isRecord(value) || !hasValidId(value) || !isRecord(value.payload))
|
|
297
|
+
return undefined;
|
|
298
|
+
const payload = value.payload;
|
|
299
|
+
switch (value.type) {
|
|
300
|
+
case 'shutdown':
|
|
301
|
+
if (typeof payload.reason !== 'string' || !isFiniteNonNegative(payload.budgetMs))
|
|
302
|
+
return undefined;
|
|
303
|
+
return value;
|
|
304
|
+
case 'host.addresses':
|
|
305
|
+
if (!Array.isArray(payload.addresses) || payload.addresses.length > 64 || !payload.addresses.every(isAddress))
|
|
306
|
+
return undefined;
|
|
307
|
+
return value;
|
|
308
|
+
case 'principal-revoked':
|
|
309
|
+
if (!isText(payload.principalId) || payload.principalId.length === 0)
|
|
310
|
+
return undefined;
|
|
311
|
+
if (payload.reason !== undefined && !isText(payload.reason, 64))
|
|
312
|
+
return undefined;
|
|
313
|
+
return value;
|
|
314
|
+
default:
|
|
315
|
+
break;
|
|
316
|
+
}
|
|
317
|
+
if (!hasStreamId(value))
|
|
318
|
+
return undefined;
|
|
319
|
+
switch (value.type) {
|
|
320
|
+
case 'http.request':
|
|
321
|
+
if (!isText(payload.method, 16) || !/^[A-Za-z]+$/u.test(payload.method))
|
|
322
|
+
return undefined;
|
|
323
|
+
if (!isText(payload.url, limits.maxHeadBytes) || typeof payload.body !== 'boolean')
|
|
324
|
+
return undefined;
|
|
325
|
+
if (!isHeaders(payload.headers, limits, payload.url.length))
|
|
326
|
+
return undefined;
|
|
327
|
+
if (!isOptionalPrincipal(payload.principal) || !isOptionalRemote(payload.remoteAddress))
|
|
328
|
+
return undefined;
|
|
329
|
+
return value;
|
|
330
|
+
case 'ws.open':
|
|
331
|
+
if (!isText(payload.url, limits.maxHeadBytes) || !isHeaders(payload.headers, limits, payload.url.length))
|
|
332
|
+
return undefined;
|
|
333
|
+
if (!isOptionalPrincipal(payload.principal) || !isOptionalRemote(payload.remoteAddress))
|
|
334
|
+
return undefined;
|
|
335
|
+
return value;
|
|
336
|
+
case 'http.request.body':
|
|
337
|
+
return isChunk(payload.chunk, limits) ? value : undefined;
|
|
338
|
+
case 'http.request.end':
|
|
339
|
+
return value;
|
|
340
|
+
case 'http.request.abort':
|
|
341
|
+
return isText(payload.message) ? value : undefined;
|
|
342
|
+
case 'http.response.credit':
|
|
343
|
+
case 'ws.drain':
|
|
344
|
+
return isCredit(payload) ? value : undefined;
|
|
345
|
+
case 'ws.message':
|
|
346
|
+
return isWsData(payload.data, limits) ? value : undefined;
|
|
347
|
+
case 'ws.close':
|
|
348
|
+
return isWsClose(payload) ? value : undefined;
|
|
349
|
+
default:
|
|
350
|
+
return undefined;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
/** Headers as a bridge header list (repeated `set-cookie` stays separate). */
|
|
354
|
+
export function toBridgeHeaders(headers) {
|
|
355
|
+
const list = [];
|
|
356
|
+
for (const [name, value] of headers)
|
|
357
|
+
list.push([name, value]);
|
|
358
|
+
return list;
|
|
359
|
+
}
|
|
360
|
+
export function fromBridgeHeaders(list) {
|
|
361
|
+
const headers = new Headers();
|
|
362
|
+
for (const [name, value] of list) {
|
|
363
|
+
try {
|
|
364
|
+
headers.append(name, value);
|
|
365
|
+
}
|
|
366
|
+
catch { /* not a valid Fetch header: skip */ }
|
|
367
|
+
}
|
|
368
|
+
return headers;
|
|
369
|
+
}
|
package/lib/console-events.d.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { HttpResponseStream } from '@zhin.js/host-http-contract';
|
|
2
2
|
import type { ConsoleEventEnvelope, ConsoleEventHistoryPage, ConsoleEventHistoryQuery, ConsoleEventPayloadMap, KnownConsoleEventType } from '@zhin.js/console-protocol';
|
|
3
3
|
/**
|
|
4
4
|
* Console 实时事件枢纽(Plugin Runtime Host 的 `/api/events` SSE 事件源)。
|
|
5
|
-
* 单进程内 fan-out:`publish` 向所有已订阅的 SSE
|
|
5
|
+
* 单进程内 fan-out:`publish` 向所有已订阅的 SSE 响应流写帧;
|
|
6
6
|
* write 失败或连接断开自动摘除订阅。
|
|
7
7
|
*/
|
|
8
|
+
/** The SSE response body a subscriber reads from (`createResponseStream()`). */
|
|
9
|
+
export type ConsoleEventSink = Pick<HttpResponseStream, 'write' | 'onDrain' | 'onClose' | 'error'>;
|
|
8
10
|
export interface ConsoleEventSubscriptionOptions {
|
|
9
11
|
readonly runtimeId?: string;
|
|
10
12
|
readonly after?: number;
|
|
@@ -15,7 +17,7 @@ export interface ConsoleEventHub {
|
|
|
15
17
|
/** In-process delivery for trusted Host bridges such as Device Protocol. */
|
|
16
18
|
listen(listener: (event: ConsoleEventEnvelope) => void): () => void;
|
|
17
19
|
/** Atomically replay the resumable suffix, then join live fan-out. */
|
|
18
|
-
subscribe(
|
|
20
|
+
subscribe(sink: ConsoleEventSink, options?: ConsoleEventSubscriptionOptions): () => void;
|
|
19
21
|
readonly runtimeId: string;
|
|
20
22
|
readonly subscriberCount: number;
|
|
21
23
|
}
|