@orkestrel/tool 0.0.3 → 0.0.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.
@@ -15,188 +15,253 @@ var TERMINAL_ROUTES_PATH = "/terminals/:name";
15
15
  */
16
16
  var TERMINAL_KEEPALIVE_MS = 15e3;
17
17
  //#endregion
18
- //#region src/server/factories.ts
18
+ //#region src/server/routes/TerminalConnection.ts
19
19
  /**
20
- * Build the two `TerminalManagerInterface` (`@orkestrel/terminal`) routes a GET SSE stream and
21
- * a POST answer endpoint, both mounted on the SAME `:name`-templated path — that bridge a manager's
22
- * endpoints onto the wire, byte-compatible with `PromptClient`.
23
- *
24
- * @remarks
25
- * - **GET (SSE).** Optionally token-gated (`401` on mismatch), `404` when `name` names no
26
- * endpoint. Opens a stream, replays every currently-{@link import('@orkestrel/terminal').PendingPrompt}
27
- * as a `pending` frame, then live-forwards the manager's own `pending` / `expire` events scoped
28
- * to `name`. A `keepalive`-interval `: ` comment ping is armed via the injected `timer` (default
29
- * the host `setTimeout`). On the request's `AbortSignal` firing (client disconnect OR server
30
- * stop) the keepalive is cancelled, both listeners unsubscribed, and the stream ended — no
31
- * `shutdown` frame is sent, so a reconnecting client is never told the endpoint is gone.
32
- * - **POST (answer).** Same token + `404` checks, then reads the body capped at `options.limit`
33
- * bytes (`413` over, `manager.answer` never called — see {@link TerminalRoutesOptions.limit}),
34
- * parses the JSON body (`400` on invalid JSON, `422` when it isn't an `{ id, value }`
35
- * {@link import('@orkestrel/terminal').isAnswerPayload} shape), and routes it through
36
- * `manager.answer` — `204` on success, `404` for `'terminal'`, `422` for `'unknown'` / `'rejected'`.
37
- *
38
- * @remarks
39
- * The `token` option (a string OR a validator function — {@link TerminalToken}) is validated at
40
- * GET connect, on EVERY POST, and RE-VALIDATED on every keepalive tick of a live SSE stream — a
41
- * stream whose presented token stops validating is torn down rather than left streaming forever.
42
- * Concurrent POST answerers race first-write-wins — a late POST for an already-settled prompt
43
- * returns `422`.
44
- *
45
- * @param manager - The `TerminalManagerInterface` (`@orkestrel/terminal`) whose endpoints are bridged
46
- * @param options - See {@link TerminalRoutesOptions}
47
- * @returns Exactly two {@link TerminalRoute} records — GET then POST — sharing one path
20
+ * Own one terminal SSE connection's replay, subscriptions, keepalive, and teardown.
48
21
  *
49
22
  * @example
50
23
  * ```ts
51
- * import { createTerminalRoutes } from '@src/server'
52
- * import { createTerminalManager } from '@orkestrel/terminal'
24
+ * import { TerminalConnection } from '@orkestrel/tool/server'
53
25
  *
54
- * const manager = createTerminalManager()
55
- * manager.add('assistant')
56
- * const routes = createTerminalRoutes(manager, { token: 'secret' })
57
- * // mount `routes` against any router accepting `{ method, path, handler }`
26
+ * const connection = new TerminalConnection(manager, name, request, stream, accepts, timer, 15_000)
27
+ * const response = connection.open()
58
28
  * ```
59
29
  */
60
- function createTerminalRoutes(manager, options) {
61
- const path = options?.path ?? "/terminals/:name";
62
- const token = options?.token;
63
- const keepalive = options?.keepalive ?? 15e3;
64
- const timer = options?.timer ?? _orkestrel_terminal.defaultTimer;
65
- const limit = options?.limit ?? _orkestrel_server.DEFAULT_BODY_LIMIT;
66
- async function readBoundedText(request) {
67
- const reader = request.body?.getReader();
68
- if (reader === void 0) return {
69
- ok: true,
70
- text: ""
71
- };
72
- const chunks = [];
73
- let received = 0;
74
- while (true) {
75
- const { done, value } = await reader.read();
76
- if (done) break;
77
- received += value.byteLength;
78
- if (received > limit) {
79
- await reader.cancel();
80
- return { ok: false };
81
- }
82
- chunks.push(value);
30
+ var TerminalConnection = class {
31
+ #manager;
32
+ #name;
33
+ #request;
34
+ #stream;
35
+ #accepts;
36
+ #timer;
37
+ #keepalive;
38
+ #presented;
39
+ #cancel;
40
+ #destroyHandler;
41
+ #pendingHandler;
42
+ #expireHandler;
43
+ #tickHandler;
44
+ /**
45
+ * Create a terminal stream connection.
46
+ *
47
+ * @param manager - Terminal manager supplying pending prompts and lifecycle events
48
+ * @param name - Terminal endpoint streamed by this connection
49
+ * @param request - Request whose abort signal owns the connection lifetime
50
+ * @param stream - Open SSE stream
51
+ * @param accepts - Presented-token validator
52
+ * @param timer - Keepalive timer implementation
53
+ * @param keepalive - Keepalive interval in milliseconds
54
+ */
55
+ constructor(manager, name, request, stream, accepts, timer, keepalive) {
56
+ this.#manager = manager;
57
+ this.#name = name;
58
+ this.#request = request;
59
+ this.#stream = stream;
60
+ this.#accepts = accepts;
61
+ this.#timer = timer;
62
+ this.#keepalive = keepalive;
63
+ this.#presented = request.headers.get(_orkestrel_terminal.HEADER_TOKEN) ?? void 0;
64
+ this.#destroyHandler = this.#destroy.bind(this);
65
+ this.#pendingHandler = this.#pending.bind(this);
66
+ this.#expireHandler = this.#expire.bind(this);
67
+ this.#tickHandler = this.#tick.bind(this);
68
+ }
69
+ /**
70
+ * Open the connection by replaying pending prompts, subscribing, and arming keepalive handling.
71
+ *
72
+ * @returns The SSE response
73
+ */
74
+ open() {
75
+ if (this.#stream.closed || this.#request.signal.aborted) {
76
+ this.#destroy();
77
+ return this.#stream.response;
83
78
  }
84
- const buffer = new Uint8Array(received);
85
- let offset = 0;
86
- for (const chunk of chunks) {
87
- buffer.set(chunk, offset);
88
- offset += chunk.byteLength;
79
+ if (this.#cancel !== void 0) return this.#stream.response;
80
+ for (const prompt of this.#manager.pending(this.#name)) this.#write((0, _orkestrel_terminal.serializePending)(prompt));
81
+ this.#manager.emitter.on("pending", this.#pendingHandler);
82
+ this.#manager.emitter.on("expire", this.#expireHandler);
83
+ this.#cancel = this.#timer(this.#tickHandler, this.#keepalive);
84
+ this.#request.signal.addEventListener("abort", this.#destroyHandler);
85
+ return this.#stream.response;
86
+ }
87
+ #write(wire) {
88
+ this.#stream.write({
89
+ event: wire.event,
90
+ data: wire.data,
91
+ ...wire.id === void 0 ? {} : { id: wire.id }
92
+ });
93
+ }
94
+ #pending(prompt) {
95
+ if (prompt.to !== this.#name) return;
96
+ if (this.#stream.closed) {
97
+ this.#destroy();
98
+ return;
89
99
  }
90
- return {
91
- ok: true,
92
- text: new TextDecoder().decode(buffer)
93
- };
100
+ this.#write((0, _orkestrel_terminal.serializePending)(prompt));
101
+ }
102
+ #expire(to, id) {
103
+ if (to !== this.#name) return;
104
+ if (this.#stream.closed) {
105
+ this.#destroy();
106
+ return;
107
+ }
108
+ this.#write((0, _orkestrel_terminal.serializeExpire)(id));
109
+ }
110
+ #tick() {
111
+ if (this.#stream.closed || !this.#accepted()) {
112
+ this.#destroy();
113
+ return;
114
+ }
115
+ this.#stream.comment("");
116
+ this.#cancel = this.#timer(this.#tickHandler, this.#keepalive);
117
+ }
118
+ #accepted() {
119
+ try {
120
+ return this.#accepts(this.#presented);
121
+ } catch {
122
+ return false;
123
+ }
124
+ }
125
+ #destroy() {
126
+ const cancel = this.#cancel;
127
+ this.#cancel = void 0;
128
+ cancel?.();
129
+ this.#manager.emitter.off("pending", this.#pendingHandler);
130
+ this.#manager.emitter.off("expire", this.#expireHandler);
131
+ this.#request.signal.removeEventListener("abort", this.#destroyHandler);
132
+ this.#stream.end();
133
+ }
134
+ };
135
+ //#endregion
136
+ //#region src/server/routes/TerminalRoutes.ts
137
+ /**
138
+ * Build and serve the terminal manager's GET stream and POST answer routes.
139
+ *
140
+ * @example
141
+ * ```ts
142
+ * import { TerminalRoutes } from '@orkestrel/tool/server'
143
+ *
144
+ * const routes = new TerminalRoutes(manager).routes()
145
+ * ```
146
+ */
147
+ var TerminalRoutes = class {
148
+ #manager;
149
+ #path;
150
+ #token;
151
+ #keepalive;
152
+ #timer;
153
+ #limit;
154
+ #accepts;
155
+ #get;
156
+ #post;
157
+ /**
158
+ * Create a terminal route owner.
159
+ *
160
+ * @param manager - Terminal manager bridged onto HTTP
161
+ * @param options - Shared route, authorization, keepalive, timer, and body-limit options
162
+ */
163
+ constructor(manager, options) {
164
+ this.#manager = manager;
165
+ this.#path = options?.path ?? "/terminals/:name";
166
+ this.#token = options?.token;
167
+ this.#keepalive = options?.keepalive ?? 15e3;
168
+ this.#timer = options?.timer ?? _orkestrel_terminal.defaultTimer;
169
+ const limit = options?.limit;
170
+ this.#limit = limit === void 0 || !Number.isFinite(limit) ? _orkestrel_server.DEFAULT_BODY_LIMIT : Math.max(0, Math.floor(limit));
171
+ this.#accepts = this.#valid.bind(this);
172
+ this.#get = this.#handleGet.bind(this);
173
+ this.#post = this.#handlePost.bind(this);
174
+ }
175
+ /**
176
+ * Project the bound GET and POST route records.
177
+ *
178
+ * @returns The GET stream route followed by the POST answer route
179
+ */
180
+ routes() {
181
+ return [{
182
+ method: "GET",
183
+ path: this.#path,
184
+ handler: this.#get
185
+ }, {
186
+ method: "POST",
187
+ path: this.#path,
188
+ handler: this.#post
189
+ }];
94
190
  }
95
- function valid(presented) {
96
- if (token === void 0) return true;
191
+ #valid(presented) {
192
+ if (this.#token === void 0) return true;
97
193
  try {
98
- return typeof token === "function" ? token(presented) : presented === token;
194
+ return typeof this.#token === "function" ? this.#token(presented) : presented === this.#token;
99
195
  } catch {
100
196
  return false;
101
197
  }
102
198
  }
103
- function authorized(request) {
104
- return valid(request.headers.get(_orkestrel_terminal.HEADER_TOKEN) ?? void 0);
199
+ #authorized(request) {
200
+ return this.#valid(request.headers.get(_orkestrel_terminal.HEADER_TOKEN) ?? void 0);
105
201
  }
106
- return [{
107
- method: "GET",
108
- path,
109
- handler(request, context) {
110
- if (!authorized(request)) return new Response(null, { status: 401 });
111
- const name = context.params.name;
112
- if (manager.terminal(name) === void 0) return new Response(null, { status: 404 });
113
- const presented = request.headers.get(_orkestrel_terminal.HEADER_TOKEN) ?? void 0;
114
- const stream = (0, _orkestrel_server.openStream)();
115
- for (const prompt of manager.pending(name)) {
116
- const wire = (0, _orkestrel_terminal.serializePending)(prompt);
117
- stream.write({
118
- event: wire.event,
119
- data: wire.data,
120
- id: wire.id
121
- });
122
- }
123
- let cancelKeepalive = () => {};
124
- const teardown = () => {
125
- cancelKeepalive();
126
- manager.emitter.off("pending", pendingHandler);
127
- manager.emitter.off("expire", expireHandler);
128
- request.signal.removeEventListener("abort", teardown);
129
- stream.end();
130
- };
131
- const pendingHandler = (prompt) => {
132
- if (prompt.to !== name) return;
133
- if (stream.closed) {
134
- teardown();
135
- return;
136
- }
137
- const wire = (0, _orkestrel_terminal.serializePending)(prompt);
138
- stream.write({
139
- event: wire.event,
140
- data: wire.data,
141
- id: wire.id
142
- });
143
- };
144
- const expireHandler = (to, id) => {
145
- if (to !== name) return;
146
- if (stream.closed) {
147
- teardown();
148
- return;
149
- }
150
- const wire = (0, _orkestrel_terminal.serializeExpire)(id);
151
- stream.write({
152
- event: wire.event,
153
- data: wire.data,
154
- id: wire.id
155
- });
156
- };
157
- manager.emitter.on("pending", pendingHandler);
158
- manager.emitter.on("expire", expireHandler);
159
- cancelKeepalive = timer(function ping() {
160
- if (stream.closed) {
161
- teardown();
162
- return;
163
- }
164
- if (!valid(presented)) {
165
- teardown();
166
- return;
167
- }
168
- stream.comment("");
169
- cancelKeepalive = timer(ping, keepalive);
170
- }, keepalive);
171
- request.signal.addEventListener("abort", teardown);
172
- return stream.response;
202
+ #handleGet(request, context) {
203
+ if (!this.#authorized(request)) return new Response(null, { status: 401 });
204
+ const name = context.params.name;
205
+ if (name === void 0 || this.#manager.terminal(name) === void 0) return new Response(null, { status: 404 });
206
+ return new TerminalConnection(this.#manager, name, request, (0, _orkestrel_server.openStream)(), this.#accepts, this.#timer, this.#keepalive).open();
207
+ }
208
+ async #handlePost(request, context) {
209
+ if (!this.#authorized(request)) return new Response(null, { status: 401 });
210
+ const name = context.params.name;
211
+ if (name === void 0 || this.#manager.terminal(name) === void 0) return new Response(null, { status: 404 });
212
+ let bytes;
213
+ try {
214
+ bytes = await (0, _orkestrel_server.collectRequestBody)(request, Math.max(1, this.#limit));
215
+ } catch (error) {
216
+ if (error instanceof _orkestrel_server.ContentTooLargeError) return new Response(null, { status: 413 });
217
+ throw error;
173
218
  }
174
- }, {
175
- method: "POST",
176
- path,
177
- async handler(request, context) {
178
- if (!authorized(request)) return new Response(null, { status: 401 });
179
- const name = context.params.name;
180
- if (manager.terminal(name) === void 0) return new Response(null, { status: 404 });
181
- const bounded = await readBoundedText(request);
182
- if (!bounded.ok) return new Response(null, { status: 413 });
183
- let body;
184
- try {
185
- body = JSON.parse(bounded.text);
186
- } catch {
187
- return new Response(null, { status: 400 });
188
- }
189
- if (!(0, _orkestrel_terminal.isAnswerPayload)(body)) return new Response(null, { status: 422 });
190
- const result = manager.answer(name, body.id, body.value);
191
- if (result.success) return new Response(null, { status: 204 });
192
- if (result.error === "terminal") return new Response(result.error, { status: 404 });
193
- return new Response(result.error, { status: 422 });
219
+ if (bytes.byteLength > 0 && bytes.byteLength > this.#limit) return new Response(null, { status: 413 });
220
+ let body;
221
+ try {
222
+ body = JSON.parse(new TextDecoder().decode(bytes));
223
+ } catch {
224
+ return new Response(null, { status: 400 });
194
225
  }
195
- }];
226
+ if (!(0, _orkestrel_terminal.isAnswerPayload)(body)) return new Response(null, { status: 422 });
227
+ const result = this.#manager.answer(name, body.id, body.value);
228
+ if (result.success) return new Response(null, { status: 204 });
229
+ if (result.error === "terminal") return new Response(result.error, { status: 404 });
230
+ return new Response(result.error, { status: 422 });
231
+ }
232
+ };
233
+ //#endregion
234
+ //#region src/server/factories.ts
235
+ /**
236
+ * Build the GET SSE stream and POST answer routes that bridge a terminal manager onto the wire.
237
+ *
238
+ * @remarks
239
+ * Both routes share the configured `:name` path and optional token gate. The GET route replays
240
+ * pending prompts, forwards live pending/expire events, and owns abort/keepalive teardown. The
241
+ * POST route bounds the request body before parsing and maps answer outcomes to HTTP statuses.
242
+ *
243
+ * @param manager - The terminal manager whose endpoints are bridged
244
+ * @param options - Route path, token, keepalive, timer, and body-limit options
245
+ * @returns The GET route followed by the POST route
246
+ *
247
+ * @example
248
+ * ```ts
249
+ * import { createTerminalRoutes } from '@src/server'
250
+ * import { createTerminalManager } from '@orkestrel/terminal'
251
+ *
252
+ * const manager = createTerminalManager()
253
+ * manager.add('assistant')
254
+ * const routes = createTerminalRoutes(manager, { token: 'secret' })
255
+ * ```
256
+ */
257
+ function createTerminalRoutes(manager, options) {
258
+ return new TerminalRoutes(manager, options).routes();
196
259
  }
197
260
  //#endregion
198
261
  exports.TERMINAL_KEEPALIVE_MS = TERMINAL_KEEPALIVE_MS;
199
262
  exports.TERMINAL_ROUTES_PATH = TERMINAL_ROUTES_PATH;
263
+ exports.TerminalConnection = TerminalConnection;
264
+ exports.TerminalRoutes = TerminalRoutes;
200
265
  exports.createTerminalRoutes = createTerminalRoutes;
201
266
 
202
267
  //# sourceMappingURL=index.cjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.cjs","names":[],"sources":["../../../src/server/constants.ts","../../../src/server/factories.ts"],"sourcesContent":["// Server-package constants — UPPER_SNAKE, `Object.freeze`d where structural, every member\n// exported (AGENTS §5).\n\n/**\n * The default `:name`-templated path {@link import('./factories.js').createTerminalRoutes}\n * mounts its GET (SSE) + POST (answer) routes under.\n */\nexport const TERMINAL_ROUTES_PATH = '/terminals/:name'\n\n/**\n * The default SSE keepalive interval (in milliseconds)\n * {@link import('./factories.js').createTerminalRoutes} arms per open connection — a `: `\n * comment ping a conforming SSE parser ignores, keeping intermediary proxies from timing out an\n * otherwise-idle stream.\n */\nexport const TERMINAL_KEEPALIVE_MS = 15_000\n","import type { PendingPrompt, TerminalManagerInterface, TimerCancel } from '@orkestrel/terminal'\nimport type {\n\tTerminalRoute,\n\tTerminalRouteContext,\n\tTerminalRoutesOptions,\n\tTerminalToken,\n} from './types.js'\nimport {\n\tdefaultTimer,\n\tHEADER_TOKEN,\n\tisAnswerPayload,\n\tserializeExpire,\n\tserializePending,\n} from '@orkestrel/terminal'\nimport { DEFAULT_BODY_LIMIT, openStream } from '@orkestrel/server'\nimport { TERMINAL_KEEPALIVE_MS, TERMINAL_ROUTES_PATH } from './constants.js'\n\n// Server-package factories — the SSE + POST mount over a `TerminalManagerInterface`\n// (`@orkestrel/terminal`), returned as plain structural `TerminalRoute` records (never\n// `@orkestrel/router`'s own `Route`), so a consumer mounts them against ANY router that accepts\n// the two-arg handler shape. Byte-compatible with `@orkestrel/terminal`'s own `PromptClient` —\n// same GET url streams, same POST url answers, same `{ id, value }` body, same `x-orkestrel-token`\n// header name.\n\n/**\n * Build the two `TerminalManagerInterface` (`@orkestrel/terminal`) routes — a GET SSE stream and\n * a POST answer endpoint, both mounted on the SAME `:name`-templated path — that bridge a manager's\n * endpoints onto the wire, byte-compatible with `PromptClient`.\n *\n * @remarks\n * - **GET (SSE).** Optionally token-gated (`401` on mismatch), `404` when `name` names no\n * endpoint. Opens a stream, replays every currently-{@link import('@orkestrel/terminal').PendingPrompt}\n * as a `pending` frame, then live-forwards the manager's own `pending` / `expire` events scoped\n * to `name`. A `keepalive`-interval `: ` comment ping is armed via the injected `timer` (default\n * the host `setTimeout`). On the request's `AbortSignal` firing (client disconnect OR server\n * stop) the keepalive is cancelled, both listeners unsubscribed, and the stream ended — no\n * `shutdown` frame is sent, so a reconnecting client is never told the endpoint is gone.\n * - **POST (answer).** Same token + `404` checks, then reads the body capped at `options.limit`\n * bytes (`413` over, `manager.answer` never called — see {@link TerminalRoutesOptions.limit}),\n * parses the JSON body (`400` on invalid JSON, `422` when it isn't an `{ id, value }`\n * {@link import('@orkestrel/terminal').isAnswerPayload} shape), and routes it through\n * `manager.answer` — `204` on success, `404` for `'terminal'`, `422` for `'unknown'` / `'rejected'`.\n *\n * @remarks\n * The `token` option (a string OR a validator function — {@link TerminalToken}) is validated at\n * GET connect, on EVERY POST, and RE-VALIDATED on every keepalive tick of a live SSE stream — a\n * stream whose presented token stops validating is torn down rather than left streaming forever.\n * Concurrent POST answerers race first-write-wins — a late POST for an already-settled prompt\n * returns `422`.\n *\n * @param manager - The `TerminalManagerInterface` (`@orkestrel/terminal`) whose endpoints are bridged\n * @param options - See {@link TerminalRoutesOptions}\n * @returns Exactly two {@link TerminalRoute} records — GET then POST — sharing one path\n *\n * @example\n * ```ts\n * import { createTerminalRoutes } from '@src/server'\n * import { createTerminalManager } from '@orkestrel/terminal'\n *\n * const manager = createTerminalManager()\n * manager.add('assistant')\n * const routes = createTerminalRoutes(manager, { token: 'secret' })\n * // mount `routes` against any router accepting `{ method, path, handler }`\n * ```\n */\nexport function createTerminalRoutes(\n\tmanager: TerminalManagerInterface,\n\toptions?: TerminalRoutesOptions,\n): readonly TerminalRoute[] {\n\tconst path = options?.path ?? TERMINAL_ROUTES_PATH\n\tconst token: TerminalToken | undefined = options?.token\n\tconst keepalive = options?.keepalive ?? TERMINAL_KEEPALIVE_MS\n\tconst timer = options?.timer ?? defaultTimer\n\tconst limit = options?.limit ?? DEFAULT_BODY_LIMIT\n\n\t// Bounded body read — `@orkestrel/server`'s own `readBody` decodes by `Content-Type`\n\t// (`application/json` parses, anything else decodes as text), so a bare answer POST that\n\t// omits `Content-Type` (as this route's own byte-compatible `PromptClient` counterpart does)\n\t// would be decoded as TEXT rather than parsed JSON, changing the existing 400/422 status\n\t// mapping. Reading the body ourselves via the `ReadableStream` reader — capped at `limit`,\n\t// ignoring `Content-Length` entirely so a lying header can never bypass the cap — then\n\t// `JSON.parse`ing the accumulated text preserves that mapping exactly while still bounding\n\t// the read.\n\tasync function readBoundedText(\n\t\trequest: Request,\n\t): Promise<{ readonly ok: true; readonly text: string } | { readonly ok: false }> {\n\t\tconst reader = request.body?.getReader()\n\t\tif (reader === undefined) return { ok: true, text: '' }\n\t\tconst chunks: Uint8Array[] = []\n\t\tlet received = 0\n\t\twhile (true) {\n\t\t\tconst { done, value } = await reader.read()\n\t\t\tif (done) break\n\t\t\treceived += value.byteLength\n\t\t\tif (received > limit) {\n\t\t\t\tawait reader.cancel()\n\t\t\t\treturn { ok: false }\n\t\t\t}\n\t\t\tchunks.push(value)\n\t\t}\n\t\tconst buffer = new Uint8Array(received)\n\t\tlet offset = 0\n\t\tfor (const chunk of chunks) {\n\t\t\tbuffer.set(chunk, offset)\n\t\t\toffset += chunk.byteLength\n\t\t}\n\t\treturn { ok: true, text: new TextDecoder().decode(buffer) }\n\t}\n\n\t// Single validation closure covering all three token shapes ({@link TerminalToken}):\n\t// `undefined` disables the check, a string is compared for equality, and a function is a\n\t// consumer-controlled validator — used at GET connect, on every POST, and re-run on every\n\t// keepalive tick against the connection's captured presented header value, so a token that\n\t// rotates or expires mid-stream tears the stream down instead of streaming forever. A\n\t// validator that THROWS is treated as invalid (fail-closed) at all three call sites — a throw\n\t// escaping the keepalive tick's timer callback would otherwise skip teardown entirely and\n\t// crash the timer host.\n\tfunction valid(presented: string | undefined): boolean {\n\t\tif (token === undefined) return true\n\t\ttry {\n\t\t\treturn typeof token === 'function' ? token(presented) : presented === token\n\t\t} catch {\n\t\t\treturn false\n\t\t}\n\t}\n\n\tfunction authorized(request: Request): boolean {\n\t\treturn valid(request.headers.get(HEADER_TOKEN) ?? undefined)\n\t}\n\n\tconst get: TerminalRoute = {\n\t\tmethod: 'GET',\n\t\tpath,\n\t\thandler(request: Request, context: TerminalRouteContext): Response {\n\t\t\tif (!authorized(request)) return new Response(null, { status: 401 })\n\t\t\tconst name = context.params.name\n\t\t\tif (manager.terminal(name) === undefined) return new Response(null, { status: 404 })\n\t\t\tconst presented = request.headers.get(HEADER_TOKEN) ?? undefined\n\n\t\t\tconst stream = openStream()\n\t\t\tfor (const prompt of manager.pending(name)) {\n\t\t\t\tconst wire = serializePending(prompt)\n\t\t\t\tstream.write({ event: wire.event, data: wire.data, id: wire.id })\n\t\t\t}\n\n\t\t\t// Shared teardown — the ONE place that cancels the keepalive and detaches all\n\t\t\t// listeners (both manager listeners and the request's `abort` listener), so the\n\t\t\t// abort path and the self-heal path (a stream that closed without the request's\n\t\t\t// `AbortSignal` firing, e.g. a consumer that only cancels its reader) can never\n\t\t\t// drift apart. `cancelKeepalive`/`stream.end`/`removeEventListener` are safe\n\t\t\t// no-ops if already run — teardown itself is idempotent.\n\t\t\tlet cancelKeepalive: TimerCancel = () => {}\n\t\t\tconst teardown = (): void => {\n\t\t\t\tcancelKeepalive()\n\t\t\t\tmanager.emitter.off('pending', pendingHandler)\n\t\t\t\tmanager.emitter.off('expire', expireHandler)\n\t\t\t\trequest.signal.removeEventListener('abort', teardown)\n\t\t\t\tstream.end()\n\t\t\t}\n\n\t\t\tconst pendingHandler = (prompt: PendingPrompt): void => {\n\t\t\t\tif (prompt.to !== name) return\n\t\t\t\tif (stream.closed) {\n\t\t\t\t\tteardown()\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tconst wire = serializePending(prompt)\n\t\t\t\tstream.write({ event: wire.event, data: wire.data, id: wire.id })\n\t\t\t}\n\t\t\tconst expireHandler = (to: string, id: string): void => {\n\t\t\t\tif (to !== name) return\n\t\t\t\tif (stream.closed) {\n\t\t\t\t\tteardown()\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tconst wire = serializeExpire(id)\n\t\t\t\tstream.write({ event: wire.event, data: wire.data, id: wire.id })\n\t\t\t}\n\n\t\t\tmanager.emitter.on('pending', pendingHandler)\n\t\t\tmanager.emitter.on('expire', expireHandler)\n\n\t\t\tcancelKeepalive = timer(function ping(): void {\n\t\t\t\tif (stream.closed) {\n\t\t\t\t\tteardown()\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tif (!valid(presented)) {\n\t\t\t\t\tteardown()\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tstream.comment('')\n\t\t\t\tcancelKeepalive = timer(ping, keepalive)\n\t\t\t}, keepalive)\n\n\t\t\trequest.signal.addEventListener('abort', teardown)\n\n\t\t\treturn stream.response\n\t\t},\n\t}\n\n\tconst post: TerminalRoute = {\n\t\tmethod: 'POST',\n\t\tpath,\n\t\tasync handler(request: Request, context: TerminalRouteContext): Promise<Response> {\n\t\t\tif (!authorized(request)) return new Response(null, { status: 401 })\n\t\t\tconst name = context.params.name\n\t\t\tif (manager.terminal(name) === undefined) return new Response(null, { status: 404 })\n\n\t\t\tconst bounded = await readBoundedText(request)\n\t\t\tif (!bounded.ok) return new Response(null, { status: 413 })\n\n\t\t\tlet body: unknown\n\t\t\ttry {\n\t\t\t\tbody = JSON.parse(bounded.text)\n\t\t\t} catch {\n\t\t\t\treturn new Response(null, { status: 400 })\n\t\t\t}\n\t\t\tif (!isAnswerPayload(body)) return new Response(null, { status: 422 })\n\n\t\t\tconst result = manager.answer(name, body.id, body.value)\n\t\t\tif (result.success) return new Response(null, { status: 204 })\n\t\t\tif (result.error === 'terminal') return new Response(result.error, { status: 404 })\n\t\t\treturn new Response(result.error, { status: 422 })\n\t\t},\n\t}\n\n\treturn [get, post]\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"index.cjs","names":["#manager","#name","#request","#stream","#accepts","#timer","#keepalive","#presented","#destroyHandler","#pendingHandler","#expireHandler","#tickHandler","#destroy","#pending","#expire","#tick","#cancel","#write","#accepted","#manager","#path","#token","#keepalive","#timer","#limit","#accepts","#get","#post","#valid","#handleGet","#handlePost","#authorized"],"sources":["../../../src/server/constants.ts","../../../src/server/routes/TerminalConnection.ts","../../../src/server/routes/TerminalRoutes.ts","../../../src/server/factories.ts"],"sourcesContent":["// Server-package constants — UPPER_SNAKE, `Object.freeze`d where structural, every member\n// exported (AGENTS §5).\n\n/**\n * The default `:name`-templated path {@link import('./factories.js').createTerminalRoutes}\n * mounts its GET (SSE) + POST (answer) routes under.\n */\nexport const TERMINAL_ROUTES_PATH = '/terminals/:name'\n\n/**\n * The default SSE keepalive interval (in milliseconds)\n * {@link import('./factories.js').createTerminalRoutes} arms per open connection — a `: `\n * comment ping a conforming SSE parser ignores, keeping intermediary proxies from timing out an\n * otherwise-idle stream.\n */\nexport const TERMINAL_KEEPALIVE_MS = 15_000\n","import type {\n\tPendingPrompt,\n\tTerminalManagerInterface,\n\tTimerCancel,\n\tTimerHandler,\n\tWireEvent,\n} from '@orkestrel/terminal'\nimport type { StreamInterface } from '@orkestrel/server'\nimport { HEADER_TOKEN, serializeExpire, serializePending } from '@orkestrel/terminal'\n\n/**\n * Own one terminal SSE connection's replay, subscriptions, keepalive, and teardown.\n *\n * @example\n * ```ts\n * import { TerminalConnection } from '@orkestrel/tool/server'\n *\n * const connection = new TerminalConnection(manager, name, request, stream, accepts, timer, 15_000)\n * const response = connection.open()\n * ```\n */\nexport class TerminalConnection {\n\treadonly #manager: TerminalManagerInterface\n\treadonly #name: string\n\treadonly #request: Request\n\treadonly #stream: StreamInterface\n\treadonly #accepts: (presented: string | undefined) => boolean\n\treadonly #timer: TimerHandler\n\treadonly #keepalive: number\n\treadonly #presented: string | undefined\n\t#cancel: TimerCancel | undefined\n\treadonly #destroyHandler: () => void\n\treadonly #pendingHandler: (prompt: PendingPrompt) => void\n\treadonly #expireHandler: (to: string, id: string) => void\n\treadonly #tickHandler: () => void\n\n\t/**\n\t * Create a terminal stream connection.\n\t *\n\t * @param manager - Terminal manager supplying pending prompts and lifecycle events\n\t * @param name - Terminal endpoint streamed by this connection\n\t * @param request - Request whose abort signal owns the connection lifetime\n\t * @param stream - Open SSE stream\n\t * @param accepts - Presented-token validator\n\t * @param timer - Keepalive timer implementation\n\t * @param keepalive - Keepalive interval in milliseconds\n\t */\n\tconstructor(\n\t\tmanager: TerminalManagerInterface,\n\t\tname: string,\n\t\trequest: Request,\n\t\tstream: StreamInterface,\n\t\taccepts: (presented: string | undefined) => boolean,\n\t\ttimer: TimerHandler,\n\t\tkeepalive: number,\n\t) {\n\t\tthis.#manager = manager\n\t\tthis.#name = name\n\t\tthis.#request = request\n\t\tthis.#stream = stream\n\t\tthis.#accepts = accepts\n\t\tthis.#timer = timer\n\t\tthis.#keepalive = keepalive\n\t\tthis.#presented = request.headers.get(HEADER_TOKEN) ?? undefined\n\t\tthis.#destroyHandler = this.#destroy.bind(this)\n\t\tthis.#pendingHandler = this.#pending.bind(this)\n\t\tthis.#expireHandler = this.#expire.bind(this)\n\t\tthis.#tickHandler = this.#tick.bind(this)\n\t}\n\n\t/**\n\t * Open the connection by replaying pending prompts, subscribing, and arming keepalive handling.\n\t *\n\t * @returns The SSE response\n\t */\n\topen(): Response {\n\t\tif (this.#stream.closed || this.#request.signal.aborted) {\n\t\t\tthis.#destroy()\n\t\t\treturn this.#stream.response\n\t\t}\n\t\tif (this.#cancel !== undefined) return this.#stream.response\n\t\tfor (const prompt of this.#manager.pending(this.#name)) {\n\t\t\tthis.#write(serializePending(prompt))\n\t\t}\n\t\tthis.#manager.emitter.on('pending', this.#pendingHandler)\n\t\tthis.#manager.emitter.on('expire', this.#expireHandler)\n\t\tthis.#cancel = this.#timer(this.#tickHandler, this.#keepalive)\n\t\tthis.#request.signal.addEventListener('abort', this.#destroyHandler)\n\t\treturn this.#stream.response\n\t}\n\n\t#write(wire: WireEvent): void {\n\t\tthis.#stream.write({\n\t\t\tevent: wire.event,\n\t\t\tdata: wire.data,\n\t\t\t...(wire.id === undefined ? {} : { id: wire.id }),\n\t\t})\n\t}\n\n\t#pending(prompt: PendingPrompt): void {\n\t\tif (prompt.to !== this.#name) return\n\t\tif (this.#stream.closed) {\n\t\t\tthis.#destroy()\n\t\t\treturn\n\t\t}\n\t\tthis.#write(serializePending(prompt))\n\t}\n\n\t#expire(to: string, id: string): void {\n\t\tif (to !== this.#name) return\n\t\tif (this.#stream.closed) {\n\t\t\tthis.#destroy()\n\t\t\treturn\n\t\t}\n\t\tthis.#write(serializeExpire(id))\n\t}\n\n\t#tick(): void {\n\t\tif (this.#stream.closed || !this.#accepted()) {\n\t\t\tthis.#destroy()\n\t\t\treturn\n\t\t}\n\t\tthis.#stream.comment('')\n\t\tthis.#cancel = this.#timer(this.#tickHandler, this.#keepalive)\n\t}\n\n\t#accepted(): boolean {\n\t\ttry {\n\t\t\treturn this.#accepts(this.#presented)\n\t\t} catch {\n\t\t\treturn false\n\t\t}\n\t}\n\n\t#destroy(): void {\n\t\tconst cancel = this.#cancel\n\t\tthis.#cancel = undefined\n\t\tcancel?.()\n\t\tthis.#manager.emitter.off('pending', this.#pendingHandler)\n\t\tthis.#manager.emitter.off('expire', this.#expireHandler)\n\t\tthis.#request.signal.removeEventListener('abort', this.#destroyHandler)\n\t\tthis.#stream.end()\n\t}\n}\n","import type { TerminalManagerInterface, TimerHandler } from '@orkestrel/terminal'\nimport type {\n\tTerminalRoute,\n\tTerminalRouteContext,\n\tTerminalRoutesOptions,\n\tTerminalToken,\n} from '../types.js'\nimport { defaultTimer, HEADER_TOKEN, isAnswerPayload } from '@orkestrel/terminal'\nimport {\n\tcollectRequestBody,\n\tContentTooLargeError,\n\tDEFAULT_BODY_LIMIT,\n\topenStream,\n} from '@orkestrel/server'\nimport { TERMINAL_KEEPALIVE_MS, TERMINAL_ROUTES_PATH } from '../constants.js'\nimport { TerminalConnection } from './TerminalConnection.js'\n\n/**\n * Build and serve the terminal manager's GET stream and POST answer routes.\n *\n * @example\n * ```ts\n * import { TerminalRoutes } from '@orkestrel/tool/server'\n *\n * const routes = new TerminalRoutes(manager).routes()\n * ```\n */\nexport class TerminalRoutes {\n\treadonly #manager: TerminalManagerInterface\n\treadonly #path: string\n\treadonly #token: TerminalToken | undefined\n\treadonly #keepalive: number\n\treadonly #timer: TimerHandler\n\treadonly #limit: number\n\treadonly #accepts: (presented: string | undefined) => boolean\n\treadonly #get: TerminalRoute['handler']\n\treadonly #post: TerminalRoute['handler']\n\n\t/**\n\t * Create a terminal route owner.\n\t *\n\t * @param manager - Terminal manager bridged onto HTTP\n\t * @param options - Shared route, authorization, keepalive, timer, and body-limit options\n\t */\n\tconstructor(manager: TerminalManagerInterface, options?: TerminalRoutesOptions) {\n\t\tthis.#manager = manager\n\t\tthis.#path = options?.path ?? TERMINAL_ROUTES_PATH\n\t\tthis.#token = options?.token\n\t\tthis.#keepalive = options?.keepalive ?? TERMINAL_KEEPALIVE_MS\n\t\tthis.#timer = options?.timer ?? defaultTimer\n\t\tconst limit = options?.limit\n\t\tthis.#limit =\n\t\t\tlimit === undefined || !Number.isFinite(limit)\n\t\t\t\t? DEFAULT_BODY_LIMIT\n\t\t\t\t: Math.max(0, Math.floor(limit))\n\t\tthis.#accepts = this.#valid.bind(this)\n\t\tthis.#get = this.#handleGet.bind(this)\n\t\tthis.#post = this.#handlePost.bind(this)\n\t}\n\n\t/**\n\t * Project the bound GET and POST route records.\n\t *\n\t * @returns The GET stream route followed by the POST answer route\n\t */\n\troutes(): readonly TerminalRoute[] {\n\t\treturn [\n\t\t\t{ method: 'GET', path: this.#path, handler: this.#get },\n\t\t\t{ method: 'POST', path: this.#path, handler: this.#post },\n\t\t]\n\t}\n\n\t#valid(presented: string | undefined): boolean {\n\t\tif (this.#token === undefined) return true\n\t\ttry {\n\t\t\treturn typeof this.#token === 'function' ? this.#token(presented) : presented === this.#token\n\t\t} catch {\n\t\t\treturn false\n\t\t}\n\t}\n\n\t#authorized(request: Request): boolean {\n\t\treturn this.#valid(request.headers.get(HEADER_TOKEN) ?? undefined)\n\t}\n\n\t#handleGet(request: Request, context: TerminalRouteContext): Response {\n\t\tif (!this.#authorized(request)) return new Response(null, { status: 401 })\n\t\tconst name = context.params.name\n\t\tif (name === undefined || this.#manager.terminal(name) === undefined) {\n\t\t\treturn new Response(null, { status: 404 })\n\t\t}\n\t\tconst connection = new TerminalConnection(\n\t\t\tthis.#manager,\n\t\t\tname,\n\t\t\trequest,\n\t\t\topenStream(),\n\t\t\tthis.#accepts,\n\t\t\tthis.#timer,\n\t\t\tthis.#keepalive,\n\t\t)\n\t\treturn connection.open()\n\t}\n\n\tasync #handlePost(request: Request, context: TerminalRouteContext): Promise<Response> {\n\t\tif (!this.#authorized(request)) return new Response(null, { status: 401 })\n\t\tconst name = context.params.name\n\t\tif (name === undefined || this.#manager.terminal(name) === undefined) {\n\t\t\treturn new Response(null, { status: 404 })\n\t\t}\n\t\tlet bytes: Uint8Array\n\t\ttry {\n\t\t\tbytes = await collectRequestBody(request, Math.max(1, this.#limit))\n\t\t} catch (error) {\n\t\t\tif (error instanceof ContentTooLargeError) return new Response(null, { status: 413 })\n\t\t\tthrow error\n\t\t}\n\t\tif (bytes.byteLength > 0 && bytes.byteLength > this.#limit) {\n\t\t\treturn new Response(null, { status: 413 })\n\t\t}\n\n\t\tlet body: unknown\n\t\ttry {\n\t\t\tbody = JSON.parse(new TextDecoder().decode(bytes))\n\t\t} catch {\n\t\t\treturn new Response(null, { status: 400 })\n\t\t}\n\t\tif (!isAnswerPayload(body)) return new Response(null, { status: 422 })\n\n\t\tconst result = this.#manager.answer(name, body.id, body.value)\n\t\tif (result.success) return new Response(null, { status: 204 })\n\t\tif (result.error === 'terminal') return new Response(result.error, { status: 404 })\n\t\treturn new Response(result.error, { status: 422 })\n\t}\n}\n","import type { TerminalManagerInterface } from '@orkestrel/terminal'\nimport type { TerminalRoute, TerminalRoutesOptions } from './types.js'\nimport { TerminalRoutes } from './routes/TerminalRoutes.js'\n\n/**\n * Build the GET SSE stream and POST answer routes that bridge a terminal manager onto the wire.\n *\n * @remarks\n * Both routes share the configured `:name` path and optional token gate. The GET route replays\n * pending prompts, forwards live pending/expire events, and owns abort/keepalive teardown. The\n * POST route bounds the request body before parsing and maps answer outcomes to HTTP statuses.\n *\n * @param manager - The terminal manager whose endpoints are bridged\n * @param options - Route path, token, keepalive, timer, and body-limit options\n * @returns The GET route followed by the POST route\n *\n * @example\n * ```ts\n * import { createTerminalRoutes } from '@src/server'\n * import { createTerminalManager } from '@orkestrel/terminal'\n *\n * const manager = createTerminalManager()\n * manager.add('assistant')\n * const routes = createTerminalRoutes(manager, { token: 'secret' })\n * ```\n */\nexport function createTerminalRoutes(\n\tmanager: TerminalManagerInterface,\n\toptions?: TerminalRoutesOptions,\n): readonly TerminalRoute[] {\n\treturn new TerminalRoutes(manager, options).routes()\n}\n"],"mappings":"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"}
@@ -1,3 +1,167 @@
1
- export type * from './types.js';
2
- export * from './constants.js';
3
- export * from './factories.js';
1
+ import { StreamInterface } from '@orkestrel/server';
2
+ import { TerminalManagerInterface } from '@orkestrel/terminal';
3
+ import { TimerHandler } from '@orkestrel/terminal';
4
+
5
+ /**
6
+ * Build the GET SSE stream and POST answer routes that bridge a terminal manager onto the wire.
7
+ *
8
+ * @remarks
9
+ * Both routes share the configured `:name` path and optional token gate. The GET route replays
10
+ * pending prompts, forwards live pending/expire events, and owns abort/keepalive teardown. The
11
+ * POST route bounds the request body before parsing and maps answer outcomes to HTTP statuses.
12
+ *
13
+ * @param manager - The terminal manager whose endpoints are bridged
14
+ * @param options - Route path, token, keepalive, timer, and body-limit options
15
+ * @returns The GET route followed by the POST route
16
+ *
17
+ * @example
18
+ * ```ts
19
+ * import { createTerminalRoutes } from '@src/server'
20
+ * import { createTerminalManager } from '@orkestrel/terminal'
21
+ *
22
+ * const manager = createTerminalManager()
23
+ * manager.add('assistant')
24
+ * const routes = createTerminalRoutes(manager, { token: 'secret' })
25
+ * ```
26
+ */
27
+ export declare function createTerminalRoutes(manager: TerminalManagerInterface, options?: TerminalRoutesOptions): readonly TerminalRoute[];
28
+
29
+ /** The HTTP method literal a {@link TerminalRoute} declares — the exact 7-literal union `@orkestrel/router`'s `Method` accepts. */
30
+ export declare type Method = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'HEAD' | 'OPTIONS';
31
+
32
+ /**
33
+ * The default SSE keepalive interval (in milliseconds)
34
+ * {@link import('./factories.js').createTerminalRoutes} arms per open connection — a `: `
35
+ * comment ping a conforming SSE parser ignores, keeping intermediary proxies from timing out an
36
+ * otherwise-idle stream.
37
+ */
38
+ export declare const TERMINAL_KEEPALIVE_MS = 15000;
39
+
40
+ /**
41
+ * The default `:name`-templated path {@link import('./factories.js').createTerminalRoutes}
42
+ * mounts its GET (SSE) + POST (answer) routes under.
43
+ */
44
+ export declare const TERMINAL_ROUTES_PATH = "/terminals/:name";
45
+
46
+ /**
47
+ * Own one terminal SSE connection's replay, subscriptions, keepalive, and teardown.
48
+ *
49
+ * @example
50
+ * ```ts
51
+ * import { TerminalConnection } from '@orkestrel/tool/server'
52
+ *
53
+ * const connection = new TerminalConnection(manager, name, request, stream, accepts, timer, 15_000)
54
+ * const response = connection.open()
55
+ * ```
56
+ */
57
+ export declare class TerminalConnection {
58
+ #private;
59
+ /**
60
+ * Create a terminal stream connection.
61
+ *
62
+ * @param manager - Terminal manager supplying pending prompts and lifecycle events
63
+ * @param name - Terminal endpoint streamed by this connection
64
+ * @param request - Request whose abort signal owns the connection lifetime
65
+ * @param stream - Open SSE stream
66
+ * @param accepts - Presented-token validator
67
+ * @param timer - Keepalive timer implementation
68
+ * @param keepalive - Keepalive interval in milliseconds
69
+ */
70
+ constructor(manager: TerminalManagerInterface, name: string, request: Request, stream: StreamInterface, accepts: (presented: string | undefined) => boolean, timer: TimerHandler, keepalive: number);
71
+ /**
72
+ * Open the connection by replaying pending prompts, subscribing, and arming keepalive handling.
73
+ *
74
+ * @returns The SSE response
75
+ */
76
+ open(): Response;
77
+ }
78
+
79
+ /**
80
+ * One structural route record {@link import('./factories.js').createTerminalRoutes} returns — a
81
+ * plain `{ method, path, handler }` shape carrying NO dependency on `@orkestrel/router`'s own
82
+ * `Route` type, so a consumer mounts it against any router that accepts a two-arg
83
+ * `(request, context) => Response | Promise<Response>` handler keyed by `method` + `path`.
84
+ */
85
+ export declare interface TerminalRoute {
86
+ readonly method: Method;
87
+ readonly path: string;
88
+ readonly handler: (request: Request, context: TerminalRouteContext) => Response | Promise<Response>;
89
+ }
90
+
91
+ /**
92
+ * The minimal route-dispatch context a {@link TerminalRoute} handler reads — exactly the frozen,
93
+ * URL-decoded `:name` path param slice a router hands a matched handler.
94
+ */
95
+ export declare interface TerminalRouteContext {
96
+ readonly params: Readonly<Record<string, string>>;
97
+ }
98
+
99
+ /**
100
+ * Build and serve the terminal manager's GET stream and POST answer routes.
101
+ *
102
+ * @example
103
+ * ```ts
104
+ * import { TerminalRoutes } from '@orkestrel/tool/server'
105
+ *
106
+ * const routes = new TerminalRoutes(manager).routes()
107
+ * ```
108
+ */
109
+ export declare class TerminalRoutes {
110
+ #private;
111
+ /**
112
+ * Create a terminal route owner.
113
+ *
114
+ * @param manager - Terminal manager bridged onto HTTP
115
+ * @param options - Shared route, authorization, keepalive, timer, and body-limit options
116
+ */
117
+ constructor(manager: TerminalManagerInterface, options?: TerminalRoutesOptions);
118
+ /**
119
+ * Project the bound GET and POST route records.
120
+ *
121
+ * @returns The GET stream route followed by the POST answer route
122
+ */
123
+ routes(): readonly TerminalRoute[];
124
+ }
125
+
126
+ /**
127
+ * Options for {@link import('./factories.js').createTerminalRoutes}.
128
+ *
129
+ * @remarks
130
+ * - `path` — the shared `:name`-templated path both the GET (SSE) and POST (answer) routes
131
+ * mount under; defaults to {@link import('./constants.js').TERMINAL_ROUTES_PATH}.
132
+ * - `token` — a {@link TerminalToken}: a string is compared for equality against the
133
+ * `x-orkestrel-token` header; a function receives the header's value (`undefined` when absent)
134
+ * and returns whether it validates, letting the consumer roll/expire tokens out-of-band.
135
+ * Validated at GET connect, on EVERY POST, and RE-VALIDATED on every keepalive tick of a live
136
+ * SSE stream — a stream whose presented token stops validating (rotated, expired, revoked) is
137
+ * torn down (the abort/self-heal teardown path, no `shutdown` frame) rather than left open
138
+ * forever; the client reconnects and re-authenticates. Omitted ⇒ no auth check. Because
139
+ * re-validation only happens on the keepalive tick, the revocation window equals the keepalive
140
+ * interval — a token rejected/expired between ticks keeps streaming until the next one. A
141
+ * validator function that THROWS is treated as rejection (fail-closed) at every call site.
142
+ * - `keepalive` — the SSE comment-ping interval in milliseconds; defaults to
143
+ * {@link import('./constants.js').TERMINAL_KEEPALIVE_MS}.
144
+ * - `timer` — the injected {@link TimerHandler} driving the keepalive interval (default the host
145
+ * `setTimeout`/`clearTimeout`), so a test drives the keepalive deterministically.
146
+ * - `limit` — the maximum POST answer body size in bytes, streamed and enforced BEFORE JSON
147
+ * parsing (ignoring any `Content-Length` header, so a lying header can never bypass the cap);
148
+ * a body exceeding it is rejected `413` and `manager.answer` is never called. Defaults to
149
+ * `@orkestrel/server`'s own `DEFAULT_BODY_LIMIT` (1 MiB).
150
+ */
151
+ export declare interface TerminalRoutesOptions {
152
+ readonly path?: string;
153
+ readonly token?: TerminalToken;
154
+ readonly keepalive?: number;
155
+ readonly timer?: TimerHandler;
156
+ readonly limit?: number;
157
+ }
158
+
159
+ /**
160
+ * The `token` gate a {@link TerminalRoutesOptions} may configure — a plain string compared for
161
+ * equality against the `x-orkestrel-token` header, OR a validator function the consumer fully
162
+ * controls, enabling expiry/rotation (a JWT `exp` check, a revocation-list lookup, anything
163
+ * time-varying) that a fixed string cannot express. `undefined` disables the auth check entirely.
164
+ */
165
+ export declare type TerminalToken = string | ((value: string | undefined) => boolean);
166
+
167
+ export { }