@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.
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 +5 -1
  21. package/lib/console-plugin-rpc.js +5 -40
  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 -14
  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 +10 -5
  37. package/lib/index.js +9 -5
  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 +5 -51
  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 -19
  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 +55 -13
  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
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Credit-based body streaming shared by both ends of the bridge (ADR 0054 §3).
3
+ *
4
+ * The sender ({@link pumpBody}) never has more than its credit in flight; the receiver
5
+ * ({@link InboundBody}) buffers at most the window and grants credit as its consumer reads.
6
+ */
7
+ /**
8
+ * A copy that owns its whole `ArrayBuffer`. Structured clone serializes a view's entire backing
9
+ * buffer, so a pooled `Buffer` or a `subarray` would otherwise ship unrelated bytes.
10
+ */
11
+ export declare function ownBytes(chunk: Uint8Array): Uint8Array;
12
+ export declare class BridgeStreamClosedError extends Error {
13
+ constructor(message?: string);
14
+ }
15
+ /** Sender-side credit: {@link take} waits while the receiver has not granted more. */
16
+ export declare class CreditGate {
17
+ #private;
18
+ constructor(initial: number);
19
+ get available(): number;
20
+ add(bytes: number): void;
21
+ /** Resolves with up to `max` bytes of credit (at least 1); rejects once closed. */
22
+ take(max: number): Promise<number>;
23
+ close(error?: Error): void;
24
+ }
25
+ export interface PumpBodyOptions {
26
+ readonly gate: CreditGate;
27
+ readonly maxChunkBytes: number;
28
+ send(chunk: Uint8Array): void;
29
+ /** Aborting stops the pump and cancels the source. */
30
+ readonly signal?: AbortSignal;
31
+ }
32
+ /**
33
+ * Reads `source` to the end and sends it in chunks that fit both the frame limit and the
34
+ * available credit. Rejects when the source errors, the gate closes or `signal` aborts; the
35
+ * source is cancelled in every failure case.
36
+ */
37
+ export declare function pumpBody(source: ReadableStream<Uint8Array>, options: PumpBodyOptions): Promise<void>;
38
+ export interface InboundBodyOptions {
39
+ /** Credit the sender started with; also the most this body buffers. */
40
+ readonly window: number;
41
+ /** Grants `bytes` more credit to the sender. */
42
+ grant(bytes: number): void;
43
+ /** The consumer cancelled the body; tell the sender to stop. */
44
+ cancel(reason: unknown): void;
45
+ }
46
+ /** Receiver side: a pull-driven `ReadableStream` that grants credit as its consumer reads. */
47
+ export declare class InboundBody {
48
+ #private;
49
+ readonly stream: ReadableStream<Uint8Array>;
50
+ constructor(options: InboundBodyOptions);
51
+ /** Whether the stream reached a terminal state (end, error, cancel). */
52
+ get settled(): boolean;
53
+ get bufferedBytes(): number;
54
+ /** Queues a chunk; false when the sender exceeded its credit (protocol violation). */
55
+ push(chunk: Uint8Array): boolean;
56
+ end(): void;
57
+ fail(error: unknown): void;
58
+ }
@@ -0,0 +1,188 @@
1
+ /**
2
+ * Credit-based body streaming shared by both ends of the bridge (ADR 0054 §3).
3
+ *
4
+ * The sender ({@link pumpBody}) never has more than its credit in flight; the receiver
5
+ * ({@link InboundBody}) buffers at most the window and grants credit as its consumer reads.
6
+ */
7
+ /**
8
+ * A copy that owns its whole `ArrayBuffer`. Structured clone serializes a view's entire backing
9
+ * buffer, so a pooled `Buffer` or a `subarray` would otherwise ship unrelated bytes.
10
+ */
11
+ export function ownBytes(chunk) {
12
+ if (chunk.byteOffset === 0 && chunk.byteLength === chunk.buffer.byteLength && !(chunk.buffer instanceof SharedArrayBuffer)) {
13
+ return Object.getPrototypeOf(chunk) === Uint8Array.prototype ? chunk : new Uint8Array(chunk.buffer, 0, chunk.byteLength);
14
+ }
15
+ return new Uint8Array(chunk);
16
+ }
17
+ export class BridgeStreamClosedError extends Error {
18
+ constructor(message = 'Bridge stream closed') {
19
+ super(message);
20
+ this.name = 'BridgeStreamClosedError';
21
+ }
22
+ }
23
+ /** Sender-side credit: {@link take} waits while the receiver has not granted more. */
24
+ export class CreditGate {
25
+ #credit;
26
+ #waiter;
27
+ #closed;
28
+ constructor(initial) {
29
+ this.#credit = initial;
30
+ }
31
+ get available() { return this.#credit; }
32
+ add(bytes) {
33
+ this.#credit += bytes;
34
+ this.#wake();
35
+ }
36
+ /** Resolves with up to `max` bytes of credit (at least 1); rejects once closed. */
37
+ async take(max) {
38
+ while (this.#credit <= 0) {
39
+ if (this.#closed)
40
+ throw this.#closed;
41
+ await new Promise((resolve) => { this.#waiter = resolve; });
42
+ }
43
+ if (this.#closed)
44
+ throw this.#closed;
45
+ const granted = Math.min(max, this.#credit);
46
+ this.#credit -= granted;
47
+ return granted;
48
+ }
49
+ close(error = new BridgeStreamClosedError()) {
50
+ this.#closed ??= error;
51
+ this.#wake();
52
+ }
53
+ #wake() {
54
+ const waiter = this.#waiter;
55
+ this.#waiter = undefined;
56
+ waiter?.();
57
+ }
58
+ }
59
+ /**
60
+ * Reads `source` to the end and sends it in chunks that fit both the frame limit and the
61
+ * available credit. Rejects when the source errors, the gate closes or `signal` aborts; the
62
+ * source is cancelled in every failure case.
63
+ */
64
+ export async function pumpBody(source, options) {
65
+ const reader = source.getReader();
66
+ const onAbort = () => {
67
+ options.gate.close(new BridgeStreamClosedError('Bridge stream aborted'));
68
+ void reader.cancel(options.signal?.reason).catch(() => undefined);
69
+ };
70
+ if (options.signal?.aborted)
71
+ onAbort();
72
+ else
73
+ options.signal?.addEventListener('abort', onAbort, { once: true });
74
+ try {
75
+ for (;;) {
76
+ const next = await reader.read();
77
+ if (next.done)
78
+ return;
79
+ const value = next.value;
80
+ const bytes = value instanceof Uint8Array
81
+ ? value
82
+ : typeof value === 'string' ? new TextEncoder().encode(value) : undefined;
83
+ if (!bytes)
84
+ throw new TypeError('Body chunks must be Uint8Array');
85
+ let offset = 0;
86
+ while (offset < bytes.byteLength) {
87
+ const size = await options.gate.take(Math.min(options.maxChunkBytes, bytes.byteLength - offset));
88
+ options.send(ownBytes(bytes.subarray(offset, offset + size)));
89
+ offset += size;
90
+ }
91
+ }
92
+ }
93
+ catch (error) {
94
+ await reader.cancel(error).catch(() => undefined);
95
+ throw error;
96
+ }
97
+ finally {
98
+ options.signal?.removeEventListener('abort', onAbort);
99
+ reader.releaseLock();
100
+ }
101
+ }
102
+ /** Receiver side: a pull-driven `ReadableStream` that grants credit as its consumer reads. */
103
+ export class InboundBody {
104
+ stream;
105
+ #options;
106
+ #queue = [];
107
+ #buffered = 0;
108
+ #ungranted = 0;
109
+ #ended = false;
110
+ #error;
111
+ #failed = false;
112
+ #cancelled = false;
113
+ #waiter;
114
+ constructor(options) {
115
+ this.#options = options;
116
+ this.stream = new ReadableStream({
117
+ pull: async (controller) => {
118
+ while (this.#queue.length === 0 && !this.#ended && !this.#failed) {
119
+ await new Promise((resolve) => { this.#waiter = resolve; });
120
+ }
121
+ const chunk = this.#queue.shift();
122
+ if (chunk) {
123
+ this.#buffered -= chunk.byteLength;
124
+ this.#consumed(chunk.byteLength);
125
+ controller.enqueue(chunk);
126
+ return;
127
+ }
128
+ if (this.#failed)
129
+ controller.error(this.#error);
130
+ else
131
+ controller.close();
132
+ },
133
+ cancel: (reason) => {
134
+ this.#cancelled = true;
135
+ this.#queue.length = 0;
136
+ this.#buffered = 0;
137
+ if (!this.#ended && !this.#failed)
138
+ this.#options.cancel(reason);
139
+ this.#ended = true;
140
+ },
141
+ }, { highWaterMark: 0 });
142
+ }
143
+ /** Whether the stream reached a terminal state (end, error, cancel). */
144
+ get settled() { return this.#ended || this.#failed; }
145
+ get bufferedBytes() { return this.#buffered; }
146
+ /** Queues a chunk; false when the sender exceeded its credit (protocol violation). */
147
+ push(chunk) {
148
+ if (this.#cancelled || this.settled)
149
+ return true;
150
+ if (this.#buffered + this.#ungranted + chunk.byteLength > this.#options.window)
151
+ return false;
152
+ this.#queue.push(chunk);
153
+ this.#buffered += chunk.byteLength;
154
+ this.#wake();
155
+ return true;
156
+ }
157
+ end() {
158
+ if (this.settled)
159
+ return;
160
+ this.#ended = true;
161
+ this.#wake();
162
+ }
163
+ fail(error) {
164
+ if (this.settled)
165
+ return;
166
+ this.#failed = true;
167
+ this.#error = error;
168
+ this.#queue.length = 0;
169
+ this.#buffered = 0;
170
+ this.#wake();
171
+ }
172
+ #consumed(bytes) {
173
+ this.#ungranted += bytes;
174
+ // Grant in batches. The sender stalls only with a full window in flight; once the queue is
175
+ // drained all of it is ungranted, so any threshold up to the window can never deadlock.
176
+ if (this.#ungranted >= Math.max(1, Math.floor(this.#options.window / 4))) {
177
+ const granted = this.#ungranted;
178
+ this.#ungranted = 0;
179
+ if (!this.#ended && !this.#failed)
180
+ this.#options.grant(granted);
181
+ }
182
+ }
183
+ #wake() {
184
+ const waiter = this.#waiter;
185
+ this.#waiter = undefined;
186
+ waiter?.();
187
+ }
188
+ }
@@ -0,0 +1,6 @@
1
+ /** `@zhin.js/host-http/bridge` — gateway ↔ instance control channel and HTTP bridge (ADR 0054 §3). */
2
+ export * from './protocol.js';
3
+ export * from './control-channel.js';
4
+ export { BridgeStreamClosedError, CreditGate, InboundBody, ownBytes, pumpBody, type InboundBodyOptions, type PumpBodyOptions, } from './flow.js';
5
+ export { createBridgedHttpHost, PRINCIPAL_REVOKED_CLOSE_CODE, sendableCloseCode, type BridgeChannel, type BridgedHttpHost, type BridgedHttpHostOptions, } from './bridged-host.js';
6
+ export { InstanceTransportError, IpcInstanceTransport, type GatewayInstancePort, type InstanceChildProcessLike, type InstanceRequestContext, type InstanceTransport, type InstanceTransportErrorCode, type InstanceWsOpenInfo, type IpcInstanceTransportOptions, } from './instance-transport.js';
@@ -0,0 +1,6 @@
1
+ /** `@zhin.js/host-http/bridge` — gateway ↔ instance control channel and HTTP bridge (ADR 0054 §3). */
2
+ export * from './protocol.js';
3
+ export * from './control-channel.js';
4
+ export { BridgeStreamClosedError, CreditGate, InboundBody, ownBytes, pumpBody, } from './flow.js';
5
+ export { createBridgedHttpHost, PRINCIPAL_REVOKED_CLOSE_CODE, sendableCloseCode, } from './bridged-host.js';
6
+ export { InstanceTransportError, IpcInstanceTransport, } from './instance-transport.js';
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Gateway side of the HTTP bridge (ADR 0054 §3): {@link InstanceTransport} is what the gateway's
3
+ * HTTP server codes against; {@link IpcInstanceTransport} implements it over the instance's IPC
4
+ * control channel. The gateway authenticates, then forwards the Web `Request` (with the
5
+ * principal) and gets a streamed `Response` back; WebSockets are bridged frame by frame.
6
+ *
7
+ * Nothing here imports `node:http` or `ws`; the gateway converts its Node server objects to
8
+ * `Request` / `WsConnection` itself.
9
+ */
10
+ import type { ConsolePluginPermissionInfo } from '@zhin.js/console-protocol';
11
+ import type { ConsoleAuditEntry, ConsolePrincipal } from '../access-control.js';
12
+ import type { WsConnection } from '../http-host.js';
13
+ import type { HttpHostAddress } from '../host-options.js';
14
+ import { type BridgeLimits, type BridgeRouteTable, type GatewayToInstanceMessage } from './protocol.js';
15
+ /** Per-request data the gateway resolved before forwarding. */
16
+ export interface InstanceRequestContext {
17
+ /**
18
+ * Principal the gateway authenticated. Required for `{apiBase}` routes (else the instance
19
+ * answers 401); for other routes pass it when the caller presented a valid credential.
20
+ */
21
+ readonly principal?: ConsolePrincipal;
22
+ readonly remoteAddress?: string;
23
+ }
24
+ /** An upgrade request the gateway wants the instance to accept. */
25
+ export interface InstanceWsOpenInfo {
26
+ readonly url: URL;
27
+ readonly headers: Headers;
28
+ /** Required for `auth: 'host'` sockets (see {@link BridgeRouteTable.ws}). */
29
+ readonly principal?: ConsolePrincipal;
30
+ readonly remoteAddress?: string;
31
+ }
32
+ /**
33
+ * @public What the gateway's HTTP server talks to (B1.3 codes against this).
34
+ *
35
+ * - `ready`: the instance announced its route table and accepts requests; false before that,
36
+ * after the instance exited, or when it speaks an incompatible protocol.
37
+ * - `routes()` / `onRoutes()`: the current table; listeners run on every new table (hot reload,
38
+ * generation switch) and with {@link EMPTY_ROUTE_TABLE} when the instance goes away.
39
+ * - `fetch()`: forwards one request. The request body is streamed with backpressure; aborting
40
+ * `request.signal` cancels the exchange in the instance. Resolves with the instance's
41
+ * `Response` (body streamed with backpressure; cancelling it cancels the instance side).
42
+ * Rejects with {@link InstanceTransportError}: `instance-offline` (not ready / went away
43
+ * before the head), `timeout` (no head in time), `instance-error` (failed before the head),
44
+ * `overloaded` (too many streams), or with `request.signal.reason` when aborted.
45
+ * - `openWebSocket()`: `peer` is the gateway's accepted client socket. Resolves true once the
46
+ * instance accepted (frames then flow both ways, closes propagate); false when it rejected,
47
+ * timed out or is offline: the caller then closes `peer`. Peer frames sent before acceptance
48
+ * are queued.
49
+ */
50
+ export interface InstanceTransport {
51
+ readonly ready: boolean;
52
+ routes(): BridgeRouteTable;
53
+ onRoutes(listener: (table: BridgeRouteTable) => void): () => void;
54
+ fetch(request: Request, context?: InstanceRequestContext): Promise<Response>;
55
+ openWebSocket(info: InstanceWsOpenInfo, peer: WsConnection): Promise<boolean>;
56
+ }
57
+ export type InstanceTransportErrorCode = 'instance-offline' | 'timeout' | 'instance-error' | 'overloaded';
58
+ export declare class InstanceTransportError extends Error {
59
+ readonly code: InstanceTransportErrorCode;
60
+ constructor(code: InstanceTransportErrorCode, message: string);
61
+ }
62
+ /** The gateway's end of the IPC channel to the current instance child. */
63
+ export interface GatewayInstancePort {
64
+ send(message: GatewayToInstanceMessage): void;
65
+ }
66
+ /** The subset of `ChildProcess` {@link IpcInstanceTransport.attachChildProcess} needs. */
67
+ export interface InstanceChildProcessLike {
68
+ send?(message: unknown, callback?: (error: Error | null) => void): boolean;
69
+ on(event: 'message', listener: (message: unknown) => void): unknown;
70
+ on(event: 'exit' | 'disconnect', listener: () => void): unknown;
71
+ off(event: 'message', listener: (message: unknown) => void): unknown;
72
+ off(event: 'exit' | 'disconnect', listener: () => void): unknown;
73
+ }
74
+ export interface IpcInstanceTransportOptions {
75
+ readonly limits?: Partial<BridgeLimits>;
76
+ /** Time to wait for a response head. Default {@link DEFAULT_BRIDGE_RESPONSE_TIMEOUT_MS}. */
77
+ readonly responseTimeoutMs?: number;
78
+ /** Time to wait for `ws.accepted`. Default {@link DEFAULT_BRIDGE_WS_OPEN_TIMEOUT_MS}. */
79
+ readonly wsOpenTimeoutMs?: number;
80
+ /** Client socket backlog below which instance frames are acknowledged. Default 1 MiB. */
81
+ readonly wsLowWaterBytes?: number;
82
+ }
83
+ export declare class IpcInstanceTransport implements InstanceTransport {
84
+ #private;
85
+ constructor(options?: IpcInstanceTransportOptions);
86
+ /** Plugin permission catalog the instance last reported (kept across restarts). */
87
+ pluginPermissions(): readonly ConsolePluginPermissionInfo[];
88
+ onCatalog(listener: (catalog: readonly ConsolePluginPermissionInfo[]) => void): () => void;
89
+ /** Audit entries the instance wrote (the gateway owns the audit log). */
90
+ onAudit(listener: (entry: ConsoleAuditEntry) => void): () => void;
91
+ /** Tells the instance to close the live connections of `principalId` (a user id). */
92
+ revokePrincipal(principalId: string, reason?: string): void;
93
+ get ready(): boolean;
94
+ get activeStreams(): number;
95
+ routes(): BridgeRouteTable;
96
+ onRoutes(listener: (table: BridgeRouteTable) => void): () => void;
97
+ /** Addresses the gateway listens on; forwarded to the instance (`HttpHost.address`). */
98
+ setAddresses(addresses: readonly HttpHostAddress[]): void;
99
+ /** Binds the transport to a new instance child; an earlier one is detached first. */
100
+ attach(port: GatewayInstancePort): void;
101
+ /**
102
+ * Wires a spawned child (`serialization: 'advanced'`): its messages feed {@link receive},
103
+ * `exit` / `disconnect` detach. Returns the unwiring function.
104
+ */
105
+ attachChildProcess(child: InstanceChildProcessLike): () => void;
106
+ /** The instance is gone: fail every exchange and close every bridged socket. */
107
+ detach(reason?: string): void;
108
+ /** Feeds one raw message from the instance. Unknown / malformed messages are ignored. */
109
+ receive(raw: unknown): void;
110
+ fetch(request: Request, context?: InstanceRequestContext): Promise<Response>;
111
+ openWebSocket(info: InstanceWsOpenInfo, peer: WsConnection): Promise<boolean>;
112
+ }