@cleocode/runtime 2026.10.4 → 2026.10.5
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/dist/{chunk-L5BLVQDA.js → chunk-4PUSKGDO.js} +2 -2
- package/dist/chunk-4PUSKGDO.js.map +1 -0
- package/dist/{chunk-KQBVXGOL.js → chunk-GULKX44R.js} +5 -3
- package/dist/chunk-GULKX44R.js.map +1 -0
- package/dist/{chunk-7Q7BEWM7.js → chunk-HXTRXEFI.js} +7 -9
- package/dist/chunk-HXTRXEFI.js.map +1 -0
- package/dist/{chunk-U2ESNIJ5.js → chunk-NOPLC5WS.js} +4 -2
- package/dist/chunk-NOPLC5WS.js.map +1 -0
- package/dist/chunk-PDZ5T4LO.js +597 -0
- package/dist/chunk-PDZ5T4LO.js.map +1 -0
- package/dist/{chunk-XJOMQIXL.js → chunk-WO6LJMDC.js} +4 -2
- package/dist/chunk-WO6LJMDC.js.map +1 -0
- package/dist/daemon/index.js +1 -1
- package/dist/dispatch-BWjVeeMg.d.ts +290 -0
- package/dist/gateway/dispatch.d.ts +5 -0
- package/dist/gateway/dispatch.js +53 -0
- package/dist/gateway/dispatch.js.map +1 -0
- package/dist/gateway/http/index.d.ts +897 -11
- package/dist/gateway/http/index.js +2 -2
- package/dist/gateway/index.d.ts +549 -4
- package/dist/gateway/index.js +26 -581
- package/dist/gateway/index.js.map +1 -1
- package/dist/gateway/mcp/index.d.ts +232 -10
- package/dist/gateway/mcp/index.js +2 -2
- package/dist/gateway/rpc/index.d.ts +5 -2036
- package/dist/gateway/rpc/index.js +1 -1
- package/dist/gateway-handler-DCU6E0a2.d.ts +80 -0
- package/dist/index.d.ts +4 -3
- package/dist/index.js +9 -6
- package/dist/index.js.map +1 -1
- package/package.json +7 -3
- package/dist/chunk-7Q7BEWM7.js.map +0 -1
- package/dist/chunk-KQBVXGOL.js.map +0 -1
- package/dist/chunk-L5BLVQDA.js.map +0 -1
- package/dist/chunk-U2ESNIJ5.js.map +0 -1
- package/dist/chunk-XJOMQIXL.js.map +0 -1
|
@@ -1,11 +1,897 @@
|
|
|
1
|
-
|
|
2
|
-
import '@cleocode/contracts/gateway';
|
|
3
|
-
import '
|
|
4
|
-
import '
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
1
|
+
import { IncomingMessage, Server, ServerResponse } from 'node:http';
|
|
2
|
+
import { Gateway, DispatchResponse, GatewayStreamEvent } from '@cleocode/contracts/gateway';
|
|
3
|
+
import { Duplex } from 'node:stream';
|
|
4
|
+
import { G as GatewayHandler } from '../../gateway-handler-DCU6E0a2.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* WebSocket terminal/PTY endpoint on the `/v1` gateway HTTP listener (T11922 ·
|
|
8
|
+
* M5 realtime transport, WS half).
|
|
9
|
+
*
|
|
10
|
+
* The SSE half (T11921) gives the gateway a SERVER→CLIENT push stream; this
|
|
11
|
+
* module adds the BIDIRECTIONAL realtime transport an interactive TUI / terminal
|
|
12
|
+
* session needs: a WebSocket upgrade on the SAME `node:http` listener
|
|
13
|
+
* ({@link import('./listen.js').startHttpServer}) that bridges a PTY both
|
|
14
|
+
* directions over WS frames.
|
|
15
|
+
*
|
|
16
|
+
* Design constraints (all four ACs of T11922):
|
|
17
|
+
*
|
|
18
|
+
* - **AC1 — upgrade on the existing listener.** The WS endpoint is NOT a second
|
|
19
|
+
* server: it attaches to the daemon's existing HTTP server via its
|
|
20
|
+
* `'upgrade'` event ({@link attachWsPtyEndpoint}), so it inherits the loopback
|
|
21
|
+
* bind + the daemon's lifecycle. The endpoint path is `/v1/terminal/pty`.
|
|
22
|
+
* - **AC2 — bidirectional PTY bridge with backpressure.** Inbound WS frames are
|
|
23
|
+
* written to the PTY stdin; PTY output is framed back to the client. A control
|
|
24
|
+
* frame (`{type:'resize',cols,rows}`) resizes the PTY. Backpressure is honored
|
|
25
|
+
* on the socket write path (pause-on-`false`-write, resume on `'drain'`).
|
|
26
|
+
* - **AC3 — leak-free, deterministic teardown.** Every open/close/error/abort
|
|
27
|
+
* path routes through ONE idempotent `teardown()` that kills the PTY, removes
|
|
28
|
+
* its listeners, and ends the socket exactly once. A server `close()` tears
|
|
29
|
+
* down every live session.
|
|
30
|
+
* - **AC4 — auth/origin enforced at the upgrade edge; no secrets on the wire.**
|
|
31
|
+
* The upgrade is rejected with a `401`/`403` BEFORE any PTY is spawned unless
|
|
32
|
+
* it comes from loopback AND (when a token is configured) presents the bearer
|
|
33
|
+
* token AND (when an origin allowlist is configured) presents an allowed
|
|
34
|
+
* `Origin`. The spawned PTY runs under a MINIMAL, explicitly-constructed env
|
|
35
|
+
* ({@link scrubPtyEnv}) — the daemon's secret-bearing `process.env` is NEVER
|
|
36
|
+
* inherited, so a secret cannot be exfiltrated through the terminal stream.
|
|
37
|
+
*
|
|
38
|
+
* To keep the **publish surface clean** (D11142 dependency-discipline):
|
|
39
|
+
* - There is NO `ws` dependency. The minimal RFC 6455 server framing needed
|
|
40
|
+
* (accept handshake, decode masked client frames, encode unmasked server
|
|
41
|
+
* frames, close/ping/pong) is implemented over the raw `node:net` socket the
|
|
42
|
+
* `'upgrade'` event hands us. This adds ZERO weight to `@cleocode/runtime`.
|
|
43
|
+
* - `node-pty` is an OPTIONAL, dynamically-loaded dependency (same pattern as
|
|
44
|
+
* {@link import('../../../../core/src/llm/pi/gondolin-loader.js')} and
|
|
45
|
+
* `packages/core/src/tools/pty.ts`): the npm specifier is held in a variable,
|
|
46
|
+
* its shape is declared LOCALLY (no `import type`), and absence is a graceful
|
|
47
|
+
* close frame ("PTY backend unavailable") — NOT a crash. The endpoint always
|
|
48
|
+
* upgrades; it bridges a PTY only when the backend is present.
|
|
49
|
+
*
|
|
50
|
+
* @packageDocumentation
|
|
51
|
+
* @module @cleocode/runtime/gateway/http/ws-pty
|
|
52
|
+
*
|
|
53
|
+
* @task T11922
|
|
54
|
+
* @epic T11769
|
|
55
|
+
* @saga T10400
|
|
56
|
+
* @see ./sse.ts — the SSE half (server→client push) on the same listener
|
|
57
|
+
*/
|
|
58
|
+
|
|
59
|
+
/** The canonical WS terminal/PTY endpoint path on the `/v1` facade. */
|
|
60
|
+
declare const WS_PTY_PATH = "/v1/terminal/pty";
|
|
61
|
+
/**
|
|
62
|
+
* The install hint surfaced to the client (as a WS close reason) when the PTY
|
|
63
|
+
* backend is unavailable. `node-pty` is OPTIONAL — the endpoint still upgrades
|
|
64
|
+
* and then closes cleanly with this reason rather than crashing the daemon.
|
|
65
|
+
*/
|
|
66
|
+
declare const NODE_PTY_INSTALL_HINT: string;
|
|
67
|
+
/** A disposable subscription handle returned by `node-pty`'s event registrars. */
|
|
68
|
+
interface PtyDisposable {
|
|
69
|
+
dispose(): void;
|
|
70
|
+
}
|
|
71
|
+
/** The minimal structural shape of a spawned `node-pty` process the bridge uses. */
|
|
72
|
+
interface NodePtyProcess {
|
|
73
|
+
/** Subscribe to interleaved stdout/stderr output. */
|
|
74
|
+
onData(cb: (data: string) => void): PtyDisposable | undefined;
|
|
75
|
+
/** Subscribe to process exit. */
|
|
76
|
+
onExit(cb: (e: {
|
|
77
|
+
exitCode: number;
|
|
78
|
+
signal?: number;
|
|
79
|
+
}) => void): PtyDisposable | undefined;
|
|
80
|
+
/** Write to the PTY stdin. */
|
|
81
|
+
write(data: string): void;
|
|
82
|
+
/** Resize the PTY. */
|
|
83
|
+
resize(cols: number, rows: number): void;
|
|
84
|
+
/** Kill the underlying process (idempotent host-side; signal optional). */
|
|
85
|
+
kill(signal?: string): void;
|
|
86
|
+
}
|
|
87
|
+
/** The minimal structural shape of the `node-pty` module the bridge uses. */
|
|
88
|
+
interface NodePtyModule {
|
|
89
|
+
spawn(file: string, args: readonly string[], options: {
|
|
90
|
+
readonly name?: string;
|
|
91
|
+
readonly cols?: number;
|
|
92
|
+
readonly rows?: number;
|
|
93
|
+
readonly cwd?: string;
|
|
94
|
+
readonly env?: Record<string, string>;
|
|
95
|
+
}): NodePtyProcess;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Attempt to lazily load `node-pty`. Returns `null` when the optional dep is not
|
|
99
|
+
* installed, fails to load, or does not expose the `spawn` surface — so the
|
|
100
|
+
* caller closes the WS cleanly with {@link NODE_PTY_INSTALL_HINT} instead of
|
|
101
|
+
* crashing. NEVER throws.
|
|
102
|
+
*
|
|
103
|
+
* The import specifier is held in a variable (passed to the {@link importer}
|
|
104
|
+
* seam) so bundlers / TS do not treat the missing optional dep as a hard,
|
|
105
|
+
* statically-resolved dependency (same technique as the gondolin loader).
|
|
106
|
+
*
|
|
107
|
+
* @returns The shape-checked module, or `null` when unavailable.
|
|
108
|
+
*/
|
|
109
|
+
declare function loadNodePty(): Promise<NodePtyModule | null>;
|
|
110
|
+
/**
|
|
111
|
+
* Build the MINIMAL environment the bridged PTY runs under (AC4).
|
|
112
|
+
*
|
|
113
|
+
* Starts EMPTY, copies only the small {@link PASSTHROUGH_KEYS} allowlist from the
|
|
114
|
+
* parent, pins `PATH` to {@link TRUSTED_PATH}, and forces `TERM=xterm-256color`
|
|
115
|
+
* for a sane terminal. The daemon's secrets are NEVER forwarded.
|
|
116
|
+
*
|
|
117
|
+
* @param parentEnv - The parent process environment (defaults to `process.env`).
|
|
118
|
+
* @returns The scrubbed, secret-free env for the PTY.
|
|
119
|
+
*/
|
|
120
|
+
declare function scrubPtyEnv(parentEnv?: NodeJS.ProcessEnv): Record<string, string>;
|
|
121
|
+
/**
|
|
122
|
+
* Derive the `Sec-WebSocket-Accept` response header value from a client
|
|
123
|
+
* `Sec-WebSocket-Key` per RFC 6455 §4.2.2 (SHA-1 of key + GUID, base64).
|
|
124
|
+
*
|
|
125
|
+
* @param key - The client's `Sec-WebSocket-Key` header value.
|
|
126
|
+
* @returns The base64 accept hash.
|
|
127
|
+
*/
|
|
128
|
+
declare function computeAcceptKey(key: string): string;
|
|
129
|
+
/**
|
|
130
|
+
* Encode a WS data frame (server→client, UNMASKED per RFC 6455 §5.1). Used for
|
|
131
|
+
* `text` (PTY output) and `close` control frames.
|
|
132
|
+
*
|
|
133
|
+
* @param opcode - The WS opcode (e.g. {@link OPCODE_TEXT}).
|
|
134
|
+
* @param payload - The frame payload bytes.
|
|
135
|
+
* @returns The framed bytes ready to write to the socket.
|
|
136
|
+
*/
|
|
137
|
+
declare function encodeWsFrame(opcode: number, payload: Buffer): Buffer;
|
|
138
|
+
/** One decoded inbound WS frame. */
|
|
139
|
+
interface DecodedFrame {
|
|
140
|
+
/** The frame opcode. */
|
|
141
|
+
opcode: number;
|
|
142
|
+
/** The unmasked payload bytes. */
|
|
143
|
+
payload: Buffer;
|
|
144
|
+
/** Total bytes consumed from the input buffer (header + payload). */
|
|
145
|
+
consumed: number;
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Decode a single inbound (client→server, MASKED) WS frame from a buffer.
|
|
149
|
+
*
|
|
150
|
+
* Returns `null` when the buffer does not yet hold a complete frame (the caller
|
|
151
|
+
* waits for more bytes). Client frames MUST be masked (RFC 6455 §5.3); the mask
|
|
152
|
+
* is applied to recover the payload. Fragmentation is not expected from a
|
|
153
|
+
* terminal client, so a non-FIN frame is decoded as-is (its opcode handled by
|
|
154
|
+
* the caller). NEVER throws on a partial buffer.
|
|
155
|
+
*
|
|
156
|
+
* @param buf - The accumulated inbound bytes.
|
|
157
|
+
* @returns The decoded frame, or `null` when more bytes are needed.
|
|
158
|
+
*/
|
|
159
|
+
declare function decodeWsFrame(buf: Buffer): DecodedFrame | null;
|
|
160
|
+
/** Configuration for the WS terminal/PTY endpoint. */
|
|
161
|
+
interface WsPtyOptions {
|
|
162
|
+
/**
|
|
163
|
+
* Optional bearer token an upgrade MUST present (as `Authorization: Bearer
|
|
164
|
+
* <token>` or `?token=<token>`). When omitted, no token is required (loopback
|
|
165
|
+
* is still enforced unconditionally).
|
|
166
|
+
*/
|
|
167
|
+
token?: string;
|
|
168
|
+
/**
|
|
169
|
+
* Optional `Origin` allowlist. When non-empty, an upgrade MUST present an
|
|
170
|
+
* `Origin` header whose value is in the set, else it is rejected `403`. When
|
|
171
|
+
* omitted/empty, the `Origin` header is not checked (loopback already bounds
|
|
172
|
+
* the surface).
|
|
173
|
+
*/
|
|
174
|
+
allowedOrigins?: readonly string[];
|
|
175
|
+
/** The shell launched for the PTY (default `process.env.SHELL` or `/bin/sh`). */
|
|
176
|
+
shell?: string;
|
|
177
|
+
/** Working directory for the PTY (default `process.cwd()`). */
|
|
178
|
+
cwd?: string;
|
|
179
|
+
}
|
|
180
|
+
/** The decision of the upgrade-edge gate. */
|
|
181
|
+
type GateDecision = {
|
|
182
|
+
ok: true;
|
|
183
|
+
} | {
|
|
184
|
+
ok: false;
|
|
185
|
+
status: number;
|
|
186
|
+
reason: string;
|
|
187
|
+
};
|
|
188
|
+
/**
|
|
189
|
+
* Authorize a WS upgrade at the edge (AC4) — BEFORE any PTY is spawned.
|
|
190
|
+
*
|
|
191
|
+
* Enforced unconditionally:
|
|
192
|
+
* - the connection MUST originate from loopback (`127.0.0.1` / `::1`); a
|
|
193
|
+
* non-loopback peer is rejected `403` (the gateway is local-process-facing).
|
|
194
|
+
*
|
|
195
|
+
* Enforced when configured:
|
|
196
|
+
* - a `token` (matched against `Authorization: Bearer` or `?token=`); a missing
|
|
197
|
+
* or mismatched token is rejected `401`.
|
|
198
|
+
* - an `Origin` allowlist; a missing or disallowed `Origin` is rejected `403`.
|
|
199
|
+
*
|
|
200
|
+
* @param req - The inbound upgrade request.
|
|
201
|
+
* @param remoteAddress - The socket's remote address.
|
|
202
|
+
* @param opts - The endpoint auth configuration.
|
|
203
|
+
* @returns A {@link GateDecision} — `{ok:true}` to proceed, else a typed reject.
|
|
204
|
+
*/
|
|
205
|
+
declare function authorizeUpgrade(req: IncomingMessage, remoteAddress: string | undefined, opts: WsPtyOptions): GateDecision;
|
|
206
|
+
/** Whether a request path is the WS terminal/PTY endpoint. */
|
|
207
|
+
declare function isWsPtyPath(url: string | undefined): boolean;
|
|
208
|
+
/**
|
|
209
|
+
* A handle returned by {@link attachWsPtyEndpoint} so the caller can detach the
|
|
210
|
+
* `'upgrade'` listener and tear down every live session on server close (AC3).
|
|
211
|
+
*/
|
|
212
|
+
interface WsPtyEndpointHandle {
|
|
213
|
+
/** The number of live WS-PTY sessions (for tests / health). */
|
|
214
|
+
readonly sessionCount: number;
|
|
215
|
+
/** Detach the listener and tear down every live session. Idempotent. */
|
|
216
|
+
close(): void;
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* The shape of the HTTP server's `'upgrade'` event surface this module needs.
|
|
220
|
+
* Declared structurally so the module does not couple to a concrete `Server`.
|
|
221
|
+
*/
|
|
222
|
+
interface UpgradableServer {
|
|
223
|
+
on(event: 'upgrade', listener: (req: IncomingMessage, socket: Duplex, head: Buffer) => void): void;
|
|
224
|
+
removeListener(event: 'upgrade', listener: (req: IncomingMessage, socket: Duplex, head: Buffer) => void): void;
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Attach the WS terminal/PTY endpoint (AC1) to an existing HTTP server's
|
|
228
|
+
* `'upgrade'` event.
|
|
229
|
+
*
|
|
230
|
+
* Every upgrade to {@link WS_PTY_PATH} is gated ({@link authorizeUpgrade}, AC4)
|
|
231
|
+
* BEFORE any PTY is spawned; a rejected upgrade gets a raw `401`/`403` and the
|
|
232
|
+
* socket is destroyed. An accepted upgrade completes the RFC 6455 handshake and
|
|
233
|
+
* bridges a PTY bidirectionally ({@link bridgePty}, AC2). An upgrade to any OTHER
|
|
234
|
+
* path is left for other upgrade handlers (this listener only claims its own
|
|
235
|
+
* path and never destroys a foreign socket).
|
|
236
|
+
*
|
|
237
|
+
* Each live session is tracked so {@link WsPtyEndpointHandle.close} tears every
|
|
238
|
+
* one down deterministically on server close (AC3).
|
|
239
|
+
*
|
|
240
|
+
* @param server - The HTTP server whose `'upgrade'` event to attach to.
|
|
241
|
+
* @param opts - The endpoint auth + PTY configuration.
|
|
242
|
+
* @returns A handle to detach + tear down on server close.
|
|
243
|
+
*/
|
|
244
|
+
declare function attachWsPtyEndpoint(server: UpgradableServer, opts?: WsPtyOptions): WsPtyEndpointHandle;
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* `node:http` server binding for the framework-agnostic HTTP gateway adapter.
|
|
248
|
+
*
|
|
249
|
+
* The HTTP adapter ({@link routeUnary} + the SSE primitives) is deliberately
|
|
250
|
+
* framework-agnostic — it owns NO port binding and NO `Request`/`Response`
|
|
251
|
+
* lifecycle, so an embedder (a SvelteKit route, a test harness) can drive it
|
|
252
|
+
* directly. The **daemon**, however, is itself the embedder: it must expose the
|
|
253
|
+
* gateway to external HTTP clients over a real listening socket. This module is
|
|
254
|
+
* that thin embedder — a minimal `node:http` server that parses the request URL
|
|
255
|
+
* into a `(gateway, domain, operation)` triple, reads the JSON body, and routes
|
|
256
|
+
* it through {@link routeUnary}. It deliberately stays in the runtime (no
|
|
257
|
+
* SvelteKit / no `@cleocode/cleo` / no drizzle dependency) so the daemon can
|
|
258
|
+
* host the gateway without reaching across a package boundary.
|
|
259
|
+
*
|
|
260
|
+
* Routing contract (intentionally minimal — the wire vocabulary is the same as
|
|
261
|
+
* every other transport, only the framing differs):
|
|
262
|
+
*
|
|
263
|
+
* `POST /<gateway>/<domain>/<operation>` with a JSON body → operation params
|
|
264
|
+
*
|
|
265
|
+
* where `<gateway>` is `query` | `mutate`. A non-POST method, an unparseable
|
|
266
|
+
* path, or a body that is not a JSON object is rejected at the wire edge with a
|
|
267
|
+
* LAFS-shaped error envelope — the injected {@link GatewayHandler} is never
|
|
268
|
+
* invoked with a malformed request.
|
|
269
|
+
*
|
|
270
|
+
* @packageDocumentation
|
|
271
|
+
* @module @cleocode/runtime/gateway/http
|
|
272
|
+
*
|
|
273
|
+
* @task T11451
|
|
274
|
+
* @epic T11254
|
|
275
|
+
* @saga T11243 SG-RUNTIME-UNIFICATION
|
|
276
|
+
*/
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Options for {@link startHttpServer}.
|
|
280
|
+
*
|
|
281
|
+
* Mirrors the connection-oriented {@link RpcServerOptions} shape: the caller
|
|
282
|
+
* resolves the bind coordinates so the runtime stays free of any
|
|
283
|
+
* `@cleocode/paths` dependency.
|
|
284
|
+
*/
|
|
285
|
+
interface HttpServerOptions {
|
|
286
|
+
/**
|
|
287
|
+
* TCP port to bind. `0` selects an ephemeral port (the bound port is then
|
|
288
|
+
* readable from {@link HttpServerHandle.port}).
|
|
289
|
+
*/
|
|
290
|
+
port: number;
|
|
291
|
+
/**
|
|
292
|
+
* Host/interface to bind. Defaults to `127.0.0.1` (loopback only) — the
|
|
293
|
+
* gateway is local-process-facing; exposing it on a public interface is an
|
|
294
|
+
* explicit caller decision.
|
|
295
|
+
*/
|
|
296
|
+
host?: string;
|
|
297
|
+
/**
|
|
298
|
+
* WebSocket terminal/PTY endpoint configuration (T11922 · M5 WS half). When
|
|
299
|
+
* provided, a WS upgrade handler for {@link WS_PTY_PATH} (`/v1/terminal/pty`)
|
|
300
|
+
* is attached to this server's `'upgrade'` event — gated at the edge (loopback
|
|
301
|
+
* + optional token/origin) and bridging a PTY bidirectionally over WS frames.
|
|
302
|
+
* When OMITTED, no upgrade handler is attached and the server stays a pure
|
|
303
|
+
* unary+SSE HTTP listener (the capability is opt-in — the daemon enables it
|
|
304
|
+
* explicitly). The optional `node-pty` backend is loaded dynamically; if it is
|
|
305
|
+
* absent, the endpoint still upgrades and reports a clean "PTY backend
|
|
306
|
+
* unavailable" close rather than crashing.
|
|
307
|
+
*/
|
|
308
|
+
wsPty?: WsPtyOptions;
|
|
309
|
+
/**
|
|
310
|
+
* Studio static asset directory for the bundled Web Studio (T11979).
|
|
311
|
+
*
|
|
312
|
+
* When provided, requests whose path starts with `/studio` are served as
|
|
313
|
+
* static files from this directory (the `build/client/` output from an
|
|
314
|
+
* `adapter-node` Studio build), with SPA fallback to `index.html` for
|
|
315
|
+
* sub-routes. When `undefined` or the path does not exist on disk, a clean
|
|
316
|
+
* JSON 503 envelope is returned instead of a stack trace — allowing the
|
|
317
|
+
* gateway to start without a Studio bundle present (dev checkouts).
|
|
318
|
+
*
|
|
319
|
+
* The value is resolved and injected by the caller (`cleo daemon serve`)
|
|
320
|
+
* using {@link resolveStudioStaticDir} from `@cleocode/cleo` so the runtime
|
|
321
|
+
* layer stays free of any path-resolution logic.
|
|
322
|
+
*/
|
|
323
|
+
studioStaticDir?: string;
|
|
324
|
+
}
|
|
325
|
+
/**
|
|
326
|
+
* A live HTTP server handle returned by {@link startHttpServer}.
|
|
327
|
+
*
|
|
328
|
+
* Exposes the bound {@link Server}, the actually-bound `port` (resolved even
|
|
329
|
+
* when `0` was requested), and an idempotent {@link close}. The adapter never
|
|
330
|
+
* calls `process.exit` — lifecycle stays with the caller (R3 contract).
|
|
331
|
+
*/
|
|
332
|
+
interface HttpServerHandle {
|
|
333
|
+
/** The bound `node:http` server. */
|
|
334
|
+
server: Server;
|
|
335
|
+
/** The actually-bound TCP port (resolved when `0` was requested). */
|
|
336
|
+
port: number;
|
|
337
|
+
/** Host/interface the server is bound to. */
|
|
338
|
+
host: string;
|
|
339
|
+
/** Close the server + all live connections; resolves once fully closed. */
|
|
340
|
+
close(): Promise<void>;
|
|
341
|
+
}
|
|
342
|
+
/**
|
|
343
|
+
* The result of parsing a request line into routing coordinates: a resolved
|
|
344
|
+
* unary triple, a recognized health probe, or a rejection for the wire edge.
|
|
345
|
+
*/
|
|
346
|
+
type ParsedRoute = {
|
|
347
|
+
ok: true;
|
|
348
|
+
kind: 'unary';
|
|
349
|
+
gateway: Gateway;
|
|
350
|
+
domain: string;
|
|
351
|
+
operation: string;
|
|
352
|
+
} | {
|
|
353
|
+
ok: true;
|
|
354
|
+
kind: 'stream';
|
|
355
|
+
gateway: Gateway;
|
|
356
|
+
domain: string;
|
|
357
|
+
operation: string;
|
|
358
|
+
} | {
|
|
359
|
+
ok: true;
|
|
360
|
+
kind: 'health';
|
|
361
|
+
} | {
|
|
362
|
+
ok: false;
|
|
363
|
+
status: number;
|
|
364
|
+
code: string;
|
|
365
|
+
message: string;
|
|
366
|
+
};
|
|
367
|
+
/**
|
|
368
|
+
* Parse a gateway request line into a routing decision.
|
|
369
|
+
*
|
|
370
|
+
* Two POST routing vocabularies are accepted (the wire payload is identical to
|
|
371
|
+
* every other transport — only the framing differs):
|
|
372
|
+
*
|
|
373
|
+
* - `POST /v1/<domain>/<operation>` — the **versioned REST facade** (T11919).
|
|
374
|
+
* The gateway (`query` | `mutate`) is INFERRED from the registry, so the
|
|
375
|
+
* client need not name it. An unregistered `(domain, operation)` pair is a
|
|
376
|
+
* `404`.
|
|
377
|
+
* - `POST /<gateway>/<domain>/<operation>` — the **legacy** form, where the
|
|
378
|
+
* client names the gateway segment explicitly (validated against the two
|
|
379
|
+
* CQRS gateways so a client cannot smuggle an arbitrary value).
|
|
380
|
+
*
|
|
381
|
+
* Two GET routing vocabularies are accepted:
|
|
382
|
+
*
|
|
383
|
+
* - `GET /v1/health` — the liveness probe.
|
|
384
|
+
* - `GET /v1/<domain>/<operation>` — the **SSE streaming** endpoint (T11921),
|
|
385
|
+
* recognized only when the `(domain, operation)` pair is registered AND
|
|
386
|
+
* flagged `streaming: true` in {@link OPERATIONS} (resolved via
|
|
387
|
+
* {@link resolveStreamRoute}). The SAME pair is dispatched UNARY over POST,
|
|
388
|
+
* so the streaming GET co-exists with the unary POST route. A GET to a
|
|
389
|
+
* unary-only op is a `405` (method-not-allowed), distinguishing "route
|
|
390
|
+
* exists, this method is unsupported" from "no such route" (`404`).
|
|
391
|
+
*
|
|
392
|
+
* A non-POST/GET method is rejected at the edge (`405`); a path that resolves to
|
|
393
|
+
* neither vocabulary is a `404`. The injected {@link GatewayHandler} is never
|
|
394
|
+
* invoked with a malformed request.
|
|
395
|
+
*
|
|
396
|
+
* @param method - The HTTP request method.
|
|
397
|
+
* @param url - The HTTP request URL (path + query).
|
|
398
|
+
* @returns The parsed route, or a typed rejection for the wire edge.
|
|
399
|
+
*/
|
|
400
|
+
declare function parseHttpRoute(method: string | undefined, url: string | undefined): ParsedRoute;
|
|
401
|
+
/**
|
|
402
|
+
* Start a `node:http` server that hosts the gateway over HTTP for external
|
|
403
|
+
* clients.
|
|
404
|
+
*
|
|
405
|
+
* Binds {@link HttpServerOptions.port} on {@link HttpServerOptions.host}
|
|
406
|
+
* (default loopback), routes every `POST /<gateway>/<domain>/<operation>`
|
|
407
|
+
* through the injected {@link GatewayHandler} via {@link routeUnary}, and
|
|
408
|
+
* resolves once listening. The returned {@link HttpServerHandle} exposes an
|
|
409
|
+
* idempotent `close()` for deterministic teardown (daemon shutdown, tests). The
|
|
410
|
+
* adapter never calls `process.exit`.
|
|
411
|
+
*
|
|
412
|
+
* The caller assembles the handler (via `createGatewayHandler` with its domain
|
|
413
|
+
* handlers + middleware) and injects it here — this server never builds
|
|
414
|
+
* handlers itself, keeping the runtime free of any `@cleocode/cleo` dependency.
|
|
415
|
+
*
|
|
416
|
+
* @param handler - The transport-neutral gateway handler to route through.
|
|
417
|
+
* @param opts - Bind coordinates.
|
|
418
|
+
* @returns A promise resolving to the live {@link HttpServerHandle}.
|
|
419
|
+
*/
|
|
420
|
+
declare function startHttpServer(handler: GatewayHandler, opts: HttpServerOptions): Promise<HttpServerHandle>;
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* Wire + lifecycle types for the `@cleocode/runtime/gateway/http` adapter.
|
|
424
|
+
*
|
|
425
|
+
* The HTTP transport carries the SAME gateway payloads as every other transport
|
|
426
|
+
* — request/response shapes are owned by `@cleocode/contracts/gateway` (R3-T2 ·
|
|
427
|
+
* T11446) and the streaming frame by `GatewayStreamEvent`. This module holds
|
|
428
|
+
* only the runtime-adapter-local option/handle shapes (mirroring the MCP and RPC
|
|
429
|
+
* adapters' `types.ts`), so the runtime stays framework-agnostic and free of any
|
|
430
|
+
* `@cleocode/cleo` / SvelteKit / drizzle dependency.
|
|
431
|
+
*
|
|
432
|
+
* @task T11450
|
|
433
|
+
* @epic T11254
|
|
434
|
+
* @saga T11243
|
|
435
|
+
*/
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* Where on the wire the `(gateway, domain, operation)` triple of an HTTP unary
|
|
439
|
+
* request is sourced from. The adapter is framework-agnostic, so the embedder
|
|
440
|
+
* (SvelteKit route, daemon HTTP server, test harness) supplies the resolved
|
|
441
|
+
* coordinates explicitly rather than the adapter parsing a URL itself.
|
|
442
|
+
*/
|
|
443
|
+
interface HttpUnaryRequest {
|
|
444
|
+
/** CQRS gateway — `'mutate'` (write) or `'query'` (read). */
|
|
445
|
+
gateway: Gateway;
|
|
446
|
+
/** Target canonical domain (e.g. `'tasks'`). */
|
|
447
|
+
domain: string;
|
|
448
|
+
/** Domain operation name (e.g. `'show'`). */
|
|
449
|
+
operation: string;
|
|
450
|
+
/** Decoded JSON request body → operation params. */
|
|
451
|
+
params?: Record<string, unknown>;
|
|
452
|
+
/**
|
|
453
|
+
* Optional caller-supplied request id for tracing. When omitted the adapter
|
|
454
|
+
* mints a fresh UUID.
|
|
455
|
+
*/
|
|
456
|
+
requestId?: string;
|
|
457
|
+
/** Bound session id, if any (forwarded into the dispatch request). */
|
|
458
|
+
sessionId?: string;
|
|
459
|
+
}
|
|
460
|
+
/**
|
|
461
|
+
* The result of routing one HTTP unary request through the gateway: the LAFS
|
|
462
|
+
* {@link DispatchResponse} JSON body plus the HTTP status the embedder should
|
|
463
|
+
* set (200 on success, 4xx/5xx mapped from the dispatch error).
|
|
464
|
+
*
|
|
465
|
+
* The adapter never writes the response itself (no framework coupling) — it
|
|
466
|
+
* returns this so the embedder serializes it onto its own `Response`.
|
|
467
|
+
*/
|
|
468
|
+
interface HttpUnaryResult {
|
|
469
|
+
/** HTTP status code derived from the dispatch outcome. */
|
|
470
|
+
status: number;
|
|
471
|
+
/** The LAFS envelope to serialize as the JSON response body. */
|
|
472
|
+
body: DispatchResponse;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* HTTP transport adapter — serves gateway unary + SSE over HTTP.
|
|
477
|
+
*
|
|
478
|
+
* The fourth and final gateway transport (after CLI, MCP, RPC). Unlike the
|
|
479
|
+
* connection-oriented RPC/MCP adapters, the HTTP adapter is framework-agnostic
|
|
480
|
+
* by design: it does NOT bind a port or own a `Request`/`Response` lifecycle.
|
|
481
|
+
* Instead it exposes two pure routing primitives the embedder (a SvelteKit
|
|
482
|
+
* route handler, the daemon's HTTP server, a test harness) calls directly:
|
|
483
|
+
*
|
|
484
|
+
* - {@link routeUnary} — one POST → `source: 'http'` {@link DispatchRequest}
|
|
485
|
+
* → injected {@link GatewayHandler} → `{ status, body }` (LAFS envelope).
|
|
486
|
+
* - the SSE primitives in `./sse.js` (`createSseStream`) for streaming ops.
|
|
487
|
+
*
|
|
488
|
+
* This keeps the runtime free of any HTTP-server / SvelteKit / `@cleocode/cleo`
|
|
489
|
+
* dependency: the embedder owns wire concerns (parsing the URL into a
|
|
490
|
+
* `(gateway, domain, operation)` triple, setting headers, `process.exit`,
|
|
491
|
+
* error-render at the edge), exactly mirroring how the CLI adapter wraps the
|
|
492
|
+
* same handler. There is NO `process.exit` and NO error-render in the handlers
|
|
493
|
+
* (R3 contract); the adapter never calls `process.exit` at all.
|
|
494
|
+
*
|
|
495
|
+
* The request `source` is forced to `'http'` regardless of what the embedder
|
|
496
|
+
* passes, so a client cannot impersonate another transport.
|
|
497
|
+
*
|
|
498
|
+
* @packageDocumentation
|
|
499
|
+
* @module @cleocode/runtime/gateway/http
|
|
500
|
+
*
|
|
501
|
+
* @task T11450
|
|
502
|
+
* @epic T11254
|
|
503
|
+
* @saga T11243
|
|
504
|
+
*/
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* Map a LAFS dispatch error code to an HTTP status code.
|
|
508
|
+
*
|
|
509
|
+
* The canonical machine-readable error code (`E_NOT_FOUND`, `E_VALIDATION_…`,
|
|
510
|
+
* `E_FORBIDDEN`, …) is the source of truth; the numeric LAFS exit code is a
|
|
511
|
+
* secondary hint. Unknown error codes fall back to `500`. A successful envelope
|
|
512
|
+
* is always `200`.
|
|
513
|
+
*
|
|
514
|
+
* @param response - The dispatch response.
|
|
515
|
+
* @returns The HTTP status the embedder should set.
|
|
516
|
+
*/
|
|
517
|
+
declare function statusForResponse(response: DispatchResponse): number;
|
|
518
|
+
/**
|
|
519
|
+
* Route a single HTTP unary request through the gateway handler.
|
|
520
|
+
*
|
|
521
|
+
* Builds a `source: 'http'` {@link DispatchRequest} from the embedder-resolved
|
|
522
|
+
* `(gateway, domain, operation, params)` triple and delegates to the injected
|
|
523
|
+
* {@link GatewayHandler}. A thrown handler error is trapped and rendered as a
|
|
524
|
+
* `500` LAFS error envelope — never propagated as an unhandled rejection or
|
|
525
|
+
* `process.exit`. The `source` is always forced to `'http'`.
|
|
526
|
+
*
|
|
527
|
+
* @param handler - The injected transport-neutral gateway handler.
|
|
528
|
+
* @param req - The embedder-resolved unary request coordinates + body params.
|
|
529
|
+
* @returns The HTTP status + LAFS response body for the embedder to serialize.
|
|
530
|
+
*/
|
|
531
|
+
declare function routeUnary(handler: GatewayHandler, req: HttpUnaryRequest): Promise<HttpUnaryResult>;
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* Server-Sent Events (SSE) wire primitives for the `@cleocode/runtime/gateway/http`
|
|
535
|
+
* adapter.
|
|
536
|
+
*
|
|
537
|
+
* The HTTP transport is the only gateway transport that streams: long-running
|
|
538
|
+
* and subscription operations emit a sequence of {@link GatewayStreamEvent}
|
|
539
|
+
* frames terminated by a `done` or `error` frame. This module owns ONLY the SSE
|
|
540
|
+
* wire encoding + the `ReadableStream` lifecycle plumbing; the actual frame
|
|
541
|
+
* production (polling a DB, tailing a job, etc.) lives in the caller's async
|
|
542
|
+
* source. Keeping the encode/stream concern here lets BOTH the gateway-routed
|
|
543
|
+
* streaming ops AND the existing Studio SSE endpoints (which carry their own
|
|
544
|
+
* domain event shapes) share one tested, leak-free stream builder.
|
|
545
|
+
*
|
|
546
|
+
* Two encoders are provided so an existing stream can adopt this plumbing
|
|
547
|
+
* WITHOUT changing its observed bytes:
|
|
548
|
+
* - {@link encodeStreamEvent} — the canonical `data: <json>\n\n` form for
|
|
549
|
+
* {@link GatewayStreamEvent} frames (gateway-routed streaming ops).
|
|
550
|
+
* - {@link encodeSseFrame} — a generic `event:`/`id:`/`data:` writer for any
|
|
551
|
+
* payload, so endpoints with a bespoke wire (a named `event:` field, a
|
|
552
|
+
* `data:`-only frame) keep an IDENTICAL byte stream while still routing their
|
|
553
|
+
* lifecycle through the shared, abort-safe builder.
|
|
554
|
+
*
|
|
555
|
+
* @packageDocumentation
|
|
556
|
+
* @module @cleocode/runtime/gateway/http/sse
|
|
557
|
+
*
|
|
558
|
+
* @task T11450
|
|
559
|
+
* @epic T11254
|
|
560
|
+
* @saga T11243
|
|
561
|
+
*/
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* One generic SSE frame: an optional named `event`, an optional `id`, and a
|
|
565
|
+
* `data` payload that is JSON-serialized.
|
|
566
|
+
*
|
|
567
|
+
* This is intentionally NOT {@link GatewayStreamEvent} — it is the raw wire
|
|
568
|
+
* shape so a bespoke endpoint can reproduce its exact existing bytes (e.g. a
|
|
569
|
+
* named `event: task-updated` line, or a `data:`-only frame with no `event`).
|
|
570
|
+
*/
|
|
571
|
+
interface SseFrame {
|
|
572
|
+
/** Optional SSE `event:` field name (omitted → a `data:`-only frame). */
|
|
573
|
+
event?: string;
|
|
574
|
+
/** Optional SSE `id:` field for client-side last-event-id resumption. */
|
|
575
|
+
id?: string;
|
|
576
|
+
/** The frame payload; JSON-serialized into the `data:` field. */
|
|
577
|
+
data: unknown;
|
|
578
|
+
}
|
|
579
|
+
/**
|
|
580
|
+
* Encode a generic {@link SseFrame} to its SSE wire bytes.
|
|
581
|
+
*
|
|
582
|
+
* Field order is `event:` (if present) → `id:` (if present) → `data:`, matching
|
|
583
|
+
* the SSE spec record layout, then a blank line. When `event` is omitted the
|
|
584
|
+
* frame is a `data:`-only record — byte-identical to a handwritten
|
|
585
|
+
* `data: <json>\n\n`.
|
|
586
|
+
*
|
|
587
|
+
* @param frame - The frame to serialize.
|
|
588
|
+
* @returns The SSE record as a string (UTF-8 caller-encoded).
|
|
589
|
+
*/
|
|
590
|
+
declare function encodeSseFrame(frame: SseFrame): string;
|
|
591
|
+
/**
|
|
592
|
+
* Encode a canonical {@link GatewayStreamEvent} as a `data:`-only SSE record.
|
|
593
|
+
*
|
|
594
|
+
* Gateway-routed streaming ops carry the discriminator + sequence inside the
|
|
595
|
+
* JSON payload (`{ kind, seq, data, error, requestId }`), so no named SSE
|
|
596
|
+
* `event:` field is used — the client demultiplexes on `kind`/`seq`. The
|
|
597
|
+
* monotonic `seq` is also surfaced as the SSE `id:` so a reconnecting client can
|
|
598
|
+
* send `Last-Event-ID`.
|
|
599
|
+
*
|
|
600
|
+
* @param frame - The gateway stream event to serialize.
|
|
601
|
+
* @returns The SSE record as a string.
|
|
602
|
+
*/
|
|
603
|
+
declare function encodeStreamEvent(frame: GatewayStreamEvent): string;
|
|
604
|
+
/** The standard SSE response headers (no caching, no proxy buffering). */
|
|
605
|
+
declare const SSE_HEADERS: Readonly<Record<string, string>>;
|
|
606
|
+
/**
|
|
607
|
+
* A push-style SSE controller handed to an {@link SseSource} so it can emit
|
|
608
|
+
* frames and close the stream without touching the underlying
|
|
609
|
+
* `ReadableStreamDefaultController`.
|
|
610
|
+
*/
|
|
611
|
+
interface SseEmitter {
|
|
612
|
+
/** Emit one generic SSE frame; a no-op once the stream is closed. */
|
|
613
|
+
send(frame: SseFrame): void;
|
|
614
|
+
/** Emit one canonical {@link GatewayStreamEvent}; a no-op once closed. */
|
|
615
|
+
sendStreamEvent(frame: GatewayStreamEvent): void;
|
|
616
|
+
/**
|
|
617
|
+
* Emit a pre-encoded SSE record verbatim; a no-op once closed. For endpoints
|
|
618
|
+
* that own their own wire encoder (e.g. a bespoke `data: <json>\n\n`) and want
|
|
619
|
+
* to keep emitting byte-identical records while still routing lifecycle
|
|
620
|
+
* through this builder.
|
|
621
|
+
*/
|
|
622
|
+
sendRaw(record: string): void;
|
|
623
|
+
/** Whether the stream has been closed (client disconnect or `close()`). */
|
|
624
|
+
readonly closed: boolean;
|
|
625
|
+
/** Close the stream; idempotent. */
|
|
626
|
+
close(): void;
|
|
627
|
+
}
|
|
628
|
+
/**
|
|
629
|
+
* A teardown callback returned by an {@link SseSource}, invoked exactly once when
|
|
630
|
+
* the stream ends (client disconnect, `emitter.close()`, or producer completion).
|
|
631
|
+
*/
|
|
632
|
+
type SseTeardown = () => void;
|
|
633
|
+
/**
|
|
634
|
+
* A frame source for {@link createSseStream}. Receives an {@link SseEmitter} and
|
|
635
|
+
* MAY return an {@link SseTeardown} callback (return nothing to skip teardown).
|
|
636
|
+
*
|
|
637
|
+
* The source is started SYNCHRONOUSLY inside `ReadableStream.start`, mirroring
|
|
638
|
+
* the existing Studio endpoints (which schedule timers inside `start`). A source
|
|
639
|
+
* may push frames immediately and/or schedule async work.
|
|
640
|
+
*/
|
|
641
|
+
type SseSource = (emitter: SseEmitter) => SseTeardown | undefined;
|
|
642
|
+
/**
|
|
643
|
+
* Build a `text/event-stream` {@link ReadableStream} driven by an
|
|
644
|
+
* {@link SseSource}.
|
|
645
|
+
*
|
|
646
|
+
* This is the shared, abort-safe stream builder every SSE endpoint routes
|
|
647
|
+
* through. It guarantees:
|
|
648
|
+
* - frames are dropped (never throw) after the stream closes;
|
|
649
|
+
* - the source's teardown callback runs exactly once;
|
|
650
|
+
* - an optional {@link AbortSignal} (the request's) closes the stream on client
|
|
651
|
+
* disconnect, with its listener removed on teardown.
|
|
652
|
+
*
|
|
653
|
+
* The byte output is fully controlled by what the source emits — so an endpoint
|
|
654
|
+
* adopting this builder reproduces its prior wire exactly by emitting the same
|
|
655
|
+
* frames in the same order.
|
|
656
|
+
*
|
|
657
|
+
* @param source - The frame producer.
|
|
658
|
+
* @param signal - Optional request abort signal that closes the stream on
|
|
659
|
+
* client disconnect.
|
|
660
|
+
* @returns A ReadableStream of UTF-8 SSE bytes for a `Response` body.
|
|
661
|
+
*/
|
|
662
|
+
declare function createSseStream(source: SseSource, signal?: AbortSignal): ReadableStream<Uint8Array>;
|
|
663
|
+
|
|
664
|
+
/**
|
|
665
|
+
* Streaming-operation source adapters for the gateway HTTP/SSE transport (T11921).
|
|
666
|
+
*
|
|
667
|
+
* A streaming operation (`OperationDef.streaming === true`, served as
|
|
668
|
+
* `GET /v1/<domain>/<operation>`) needs a *producer*: an async source that tails
|
|
669
|
+
* an event origin (the agent/worker/board lifecycle bus) and feeds
|
|
670
|
+
* {@link GatewayStreamEvent} frames into the shared, abort-safe
|
|
671
|
+
* {@link createSseStream} builder. This module owns ONLY that producer concern —
|
|
672
|
+
* the wire encoding + stream lifecycle live in `./sse.ts`, and the route parsing
|
|
673
|
+
* lives in `./listen.ts`.
|
|
674
|
+
*
|
|
675
|
+
* The runtime is deliberately DB-free (the runtime invariant — no drizzle, no
|
|
676
|
+
* `@cleocode/cleo`), so a source here MUST NOT open a store directly. Instead the
|
|
677
|
+
* listener resolves a source by `<domain>.<operation>` key from the
|
|
678
|
+
* {@link STREAM_SOURCES} registry; an embedder that owns a richer origin (the
|
|
679
|
+
* daemon, a SvelteKit route) can inject its own source into the listener at
|
|
680
|
+
* boot. The default source shipped here is a bounded tick/heartbeat producer that
|
|
681
|
+
* proves the pipe end-to-end without reaching across the package boundary — and
|
|
682
|
+
* is exactly what the in-process test exercises.
|
|
683
|
+
*
|
|
684
|
+
* Secrets never touch the wire: a source emits only the public lifecycle payload
|
|
685
|
+
* it is handed; credential resolution (if any) happens server-side before the
|
|
686
|
+
* frame is built, exactly as it does for a unary dispatch.
|
|
687
|
+
*
|
|
688
|
+
* @packageDocumentation
|
|
689
|
+
* @module @cleocode/runtime/gateway/http/stream-sources
|
|
690
|
+
*
|
|
691
|
+
* @task T11921
|
|
692
|
+
* @epic T11769
|
|
693
|
+
* @saga T10400
|
|
694
|
+
*/
|
|
695
|
+
|
|
696
|
+
/**
|
|
697
|
+
* The bind coordinates a {@link GatewayStreamSource} receives: the resolved
|
|
698
|
+
* `(domain, operation)` of the streaming route, the already-validated request
|
|
699
|
+
* params, and the request id (so emitted frames correlate back to the origin
|
|
700
|
+
* request).
|
|
701
|
+
*/
|
|
702
|
+
interface StreamSourceContext {
|
|
703
|
+
/** The canonical domain segment of the streaming route. */
|
|
704
|
+
domain: string;
|
|
705
|
+
/** The operation segment of the streaming route. */
|
|
706
|
+
operation: string;
|
|
707
|
+
/** The decoded query params for the streaming request. */
|
|
708
|
+
params: Record<string, unknown>;
|
|
709
|
+
/** The request id stamped on every emitted {@link GatewayStreamEvent}. */
|
|
710
|
+
requestId: string;
|
|
711
|
+
}
|
|
712
|
+
/**
|
|
713
|
+
* A teardown callback a {@link GatewayStreamSource} MAY return; invoked exactly
|
|
714
|
+
* once when the stream ends (client disconnect, terminal frame, completion).
|
|
715
|
+
*/
|
|
716
|
+
type StreamSourceTeardown = () => void;
|
|
717
|
+
/**
|
|
718
|
+
* A producer for one streaming operation. Receives an {@link SseEmitter}
|
|
719
|
+
* (`sendStreamEvent` emits one canonical {@link GatewayStreamEvent}) plus the
|
|
720
|
+
* resolved {@link StreamSourceContext}, and MAY return a teardown callback.
|
|
721
|
+
*
|
|
722
|
+
* The source is started synchronously inside the stream builder, mirroring the
|
|
723
|
+
* Studio SSE endpoints; it may push frames immediately and/or schedule async
|
|
724
|
+
* work. It MUST drop frames once `emitter.closed` is true (the emitter already
|
|
725
|
+
* guards this) and MUST clean up timers/listeners in its teardown so a client
|
|
726
|
+
* disconnect closes the stream leak-free.
|
|
727
|
+
*/
|
|
728
|
+
type GatewayStreamSource = (emitter: SseEmitter, context: StreamSourceContext) => StreamSourceTeardown | undefined;
|
|
729
|
+
/**
|
|
730
|
+
* The default `orchestrate.events` source: a bounded tick/heartbeat producer.
|
|
731
|
+
*
|
|
732
|
+
* Emits a monotonic sequence of `data` {@link GatewayStreamEvent} frames at
|
|
733
|
+
* {@link DEFAULT_TICK_INTERVAL_MS}, then a terminal `done` frame once the
|
|
734
|
+
* `ticks` budget is exhausted; a client disconnect tears the timer down before
|
|
735
|
+
* then. It carries no DB handle and no secret — each frame's payload is a public
|
|
736
|
+
* heartbeat (`{ tick, ts }`) standing in for an agent/worker/board lifecycle
|
|
737
|
+
* event until the daemon injects its real origin-tailing source.
|
|
738
|
+
*
|
|
739
|
+
* A first frame is emitted SYNCHRONOUSLY so a client (and the test) observes a
|
|
740
|
+
* well-formed `data:` frame immediately, without waiting a full interval.
|
|
741
|
+
*
|
|
742
|
+
* @param emitter - The SSE emitter for this stream.
|
|
743
|
+
* @param context - The resolved streaming-route context.
|
|
744
|
+
* @returns A teardown that clears the tick timer.
|
|
745
|
+
*/
|
|
746
|
+
declare const tickStreamSource: GatewayStreamSource;
|
|
747
|
+
/**
|
|
748
|
+
* Register (or override) the {@link GatewayStreamSource} for a streaming op.
|
|
749
|
+
*
|
|
750
|
+
* Lets an embedder that owns a richer event origin (the daemon tailing the
|
|
751
|
+
* agent/worker/board lifecycle bus, a SvelteKit route reading a store) replace
|
|
752
|
+
* the default tick source for a given `(domain, operation)` without changing the
|
|
753
|
+
* listener. Idempotent — a later call wins.
|
|
754
|
+
*
|
|
755
|
+
* @param domain - The canonical domain of the streaming op.
|
|
756
|
+
* @param operation - The operation name of the streaming op.
|
|
757
|
+
* @param source - The producer to serve this streaming route with.
|
|
758
|
+
*/
|
|
759
|
+
declare function registerStreamSource(domain: string, operation: string, source: GatewayStreamSource): void;
|
|
760
|
+
/**
|
|
761
|
+
* Resolve the {@link GatewayStreamSource} registered for a streaming op.
|
|
762
|
+
*
|
|
763
|
+
* @param domain - The canonical domain of the streaming op.
|
|
764
|
+
* @param operation - The operation name of the streaming op.
|
|
765
|
+
* @returns The registered source, or `undefined` when none is registered (the
|
|
766
|
+
* listener then closes the stream with a single terminal `error` frame).
|
|
767
|
+
*/
|
|
768
|
+
declare function resolveStreamSource(domain: string, operation: string): GatewayStreamSource | undefined;
|
|
769
|
+
|
|
770
|
+
/**
|
|
771
|
+
* Static file serving for the bundled CLEO Web Studio assets.
|
|
772
|
+
*
|
|
773
|
+
* The gateway HTTP server calls {@link serveStudioStatic} for any request whose
|
|
774
|
+
* path starts with the `/studio` prefix. The handler:
|
|
775
|
+
*
|
|
776
|
+
* 1. Strips the `/studio` prefix and resolves the remainder against the
|
|
777
|
+
* `studioStaticDir` (the `build/client/` output of the Studio
|
|
778
|
+
* `adapter-node` build bundled inside `@cleocode/cleo`).
|
|
779
|
+
* 2. Serves the matched file with the correct MIME type (inferred from the
|
|
780
|
+
* extension via the built-in {@link MIME_TYPES} map).
|
|
781
|
+
* 3. Returns `index.html` for any path that does not resolve to a static file
|
|
782
|
+
* — the SPA fallback that lets the SvelteKit client-side router handle
|
|
783
|
+
* sub-routes.
|
|
784
|
+
* 4. Returns a clean JSON error envelope (not a stack trace) when
|
|
785
|
+
* `studioStaticDir` is absent (dev checkout without a Studio build, or an
|
|
786
|
+
* npm install where the postbuild copy was skipped).
|
|
787
|
+
*
|
|
788
|
+
* The handler deliberately has NO `process.exit`, NO drizzle, NO
|
|
789
|
+
* `@cleocode/cleo` dependency — it is a pure file-system responder that can be
|
|
790
|
+
* embedded in any `node:http` server (the gateway daemon, a test harness).
|
|
791
|
+
*
|
|
792
|
+
* The `build/client/` static tree is served under `/studio/` so all of its
|
|
793
|
+
* relative asset references (`/_app/immutable/...`) resolve correctly. The root
|
|
794
|
+
* `/studio` (no trailing slash) redirects to `/studio/` to keep the base URL
|
|
795
|
+
* consistent with the SvelteKit assets.
|
|
796
|
+
*
|
|
797
|
+
* @packageDocumentation
|
|
798
|
+
* @module @cleocode/runtime/gateway/http
|
|
799
|
+
*
|
|
800
|
+
* @task T11979
|
|
801
|
+
* @epic T11261
|
|
802
|
+
*/
|
|
803
|
+
|
|
804
|
+
/** The URL prefix the Studio static handler claims. */
|
|
805
|
+
declare const STUDIO_PREFIX = "/studio";
|
|
806
|
+
/**
|
|
807
|
+
* Options for the Studio static handler, injected at server startup.
|
|
808
|
+
*
|
|
809
|
+
* The caller resolves the absolute path to the static asset directory and
|
|
810
|
+
* passes it here — the handler never reads environment variables or resolves
|
|
811
|
+
* paths itself, keeping concerns separated and the handler testable.
|
|
812
|
+
*/
|
|
813
|
+
interface StudioStaticOptions {
|
|
814
|
+
/**
|
|
815
|
+
* Absolute path to the Studio static asset directory. This is the
|
|
816
|
+
* `build/client/` subdirectory from an `adapter-node` Studio build, bundled
|
|
817
|
+
* inside `@cleocode/cleo` at `studio-dist/client/`.
|
|
818
|
+
*
|
|
819
|
+
* When `undefined` or the path does not exist on disk, every request under
|
|
820
|
+
* `/studio` returns a clean JSON 503 "bundle absent" envelope.
|
|
821
|
+
*/
|
|
822
|
+
staticDir: string | undefined;
|
|
823
|
+
}
|
|
824
|
+
/**
|
|
825
|
+
* Determine whether a request path falls under the Studio prefix and should be
|
|
826
|
+
* handled by the static asset handler rather than the API gateway.
|
|
827
|
+
*
|
|
828
|
+
* @param pathname - The URL pathname (without query string).
|
|
829
|
+
* @returns `true` when the path is `/studio`, `/studio/`, or any
|
|
830
|
+
* `/studio/<subpath>`.
|
|
831
|
+
*/
|
|
832
|
+
declare function isStudioPath(pathname: string): boolean;
|
|
833
|
+
/**
|
|
834
|
+
* Resolve the file-system path for a Studio request within `staticDir`.
|
|
835
|
+
*
|
|
836
|
+
* Strips the `/studio` prefix and normalises the remainder; guards against
|
|
837
|
+
* path-traversal (a normalised path that escapes `staticDir` is rejected).
|
|
838
|
+
*
|
|
839
|
+
* @param staticDir - Absolute path to the Studio static asset directory.
|
|
840
|
+
* @param pathname - The request path (already verified to start with `/studio`).
|
|
841
|
+
* @returns The absolute candidate path, or `undefined` when path traversal is
|
|
842
|
+
* detected.
|
|
843
|
+
*/
|
|
844
|
+
declare function resolveStudioAssetPath(staticDir: string, pathname: string): string | undefined;
|
|
845
|
+
/**
|
|
846
|
+
* Infer the MIME type for a file from its extension.
|
|
847
|
+
*
|
|
848
|
+
* @param filePath - The file path (only the extension is used).
|
|
849
|
+
* @returns The appropriate `Content-Type` header value.
|
|
850
|
+
*/
|
|
851
|
+
declare function mimeType(filePath: string): string;
|
|
852
|
+
/**
|
|
853
|
+
* Write the "bundle absent" JSON error envelope and close the response.
|
|
854
|
+
*
|
|
855
|
+
* Used when `staticDir` is `undefined` or does not exist on disk — the Studio
|
|
856
|
+
* build was not found. The response is a `503 Service Unavailable` with a clean
|
|
857
|
+
* LAFS-flavoured JSON body so clients can detect the condition programmatically.
|
|
858
|
+
*
|
|
859
|
+
* @param res - The server response to write to.
|
|
860
|
+
* @param hint - Optional developer hint (e.g. the expected bundle path).
|
|
861
|
+
*/
|
|
862
|
+
declare function writeBundleAbsentError(res: ServerResponse, hint?: string): void;
|
|
863
|
+
/**
|
|
864
|
+
* Handle a single HTTP request whose path falls under `/studio`.
|
|
865
|
+
*
|
|
866
|
+
* Routing logic:
|
|
867
|
+
* - `GET /studio` → 302 redirect to `/studio/` (keep asset refs consistent)
|
|
868
|
+
* - `GET /studio/<static-asset>` → serve the file from `staticDir`
|
|
869
|
+
* - `GET /studio/*` (no matching file) → serve `index.html` (SPA fallback)
|
|
870
|
+
* - Any request when `staticDir` is absent → 503 JSON envelope
|
|
871
|
+
* - Non-GET method → 405 JSON envelope (Studio is read-only from the gateway)
|
|
872
|
+
*
|
|
873
|
+
* @param req - The inbound HTTP request.
|
|
874
|
+
* @param res - The outbound HTTP response.
|
|
875
|
+
* @param opts - Static handler options (resolved at server startup).
|
|
876
|
+
* @returns A promise that resolves once the response has been written.
|
|
877
|
+
*/
|
|
878
|
+
declare function serveStudioStatic(req: IncomingMessage, res: ServerResponse, opts: StudioStaticOptions): Promise<void>;
|
|
879
|
+
/**
|
|
880
|
+
* Resolve the bundled Studio static directory from the `@cleocode/cleo` package
|
|
881
|
+
* location.
|
|
882
|
+
*
|
|
883
|
+
* When running from an npm install, the Studio build is bundled at
|
|
884
|
+
* `<cleo-package-root>/studio-dist/client/`. When running from a monorepo dev
|
|
885
|
+
* checkout, the directory may not exist (developers must run
|
|
886
|
+
* `pnpm --filter @cleocode/studio run build` manually) — `undefined` is
|
|
887
|
+
* returned in that case, and the gateway handler will serve the bundle-absent
|
|
888
|
+
* error instead of crashing.
|
|
889
|
+
*
|
|
890
|
+
* @param cleoPackageDir - Absolute path to the `@cleocode/cleo` package root.
|
|
891
|
+
* Typically derived from `import.meta.url` at the call site.
|
|
892
|
+
* @returns The absolute path to `studio-dist/client/`, or `undefined` if the
|
|
893
|
+
* directory does not exist.
|
|
894
|
+
*/
|
|
895
|
+
declare function resolveStudioStaticDir(cleoPackageDir: string): string | undefined;
|
|
896
|
+
|
|
897
|
+
export { type GateDecision, type GatewayStreamSource, type HttpServerHandle, type HttpServerOptions, type HttpUnaryRequest, type HttpUnaryResult, NODE_PTY_INSTALL_HINT, SSE_HEADERS, STUDIO_PREFIX, type SseEmitter, type SseFrame, type SseSource, type StreamSourceContext, type StreamSourceTeardown, type StudioStaticOptions, type UpgradableServer, WS_PTY_PATH, type WsPtyEndpointHandle, type WsPtyOptions, attachWsPtyEndpoint, authorizeUpgrade, computeAcceptKey, createSseStream, decodeWsFrame, encodeSseFrame, encodeStreamEvent, encodeWsFrame, isStudioPath, isWsPtyPath, loadNodePty, mimeType, parseHttpRoute, registerStreamSource, resolveStreamSource, resolveStudioAssetPath, resolveStudioStaticDir, routeUnary, scrubPtyEnv, serveStudioStatic, startHttpServer, statusForResponse, tickStreamSource, writeBundleAbsentError };
|