@orkestrel/tool 0.0.4 → 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.
@@ -1,35 +1,18 @@
1
+ import { StreamInterface } from '@orkestrel/server';
1
2
  import { TerminalManagerInterface } from '@orkestrel/terminal';
2
3
  import { TimerHandler } from '@orkestrel/terminal';
3
4
 
4
5
  /**
5
- * Build the two `TerminalManagerInterface` (`@orkestrel/terminal`) routes a GET SSE stream and
6
- * a POST answer endpoint, both mounted on the SAME `:name`-templated path — that bridge a manager's
7
- * endpoints onto the wire, byte-compatible with `PromptClient`.
6
+ * Build the GET SSE stream and POST answer routes that bridge a terminal manager onto the wire.
8
7
  *
9
8
  * @remarks
10
- * - **GET (SSE).** Optionally token-gated (`401` on mismatch), `404` when `name` names no
11
- * endpoint. Opens a stream, replays every currently-{@link import('@orkestrel/terminal').PendingPrompt}
12
- * as a `pending` frame, then live-forwards the manager's own `pending` / `expire` events scoped
13
- * to `name`. A `keepalive`-interval `: ` comment ping is armed via the injected `timer` (default
14
- * the host `setTimeout`). On the request's `AbortSignal` firing (client disconnect OR server
15
- * stop) the keepalive is cancelled, both listeners unsubscribed, and the stream ended — no
16
- * `shutdown` frame is sent, so a reconnecting client is never told the endpoint is gone.
17
- * - **POST (answer).** Same token + `404` checks, then reads the body capped at `options.limit`
18
- * bytes (`413` over, `manager.answer` never called — see {@link TerminalRoutesOptions.limit}),
19
- * parses the JSON body (`400` on invalid JSON, `422` when it isn't an `{ id, value }`
20
- * {@link import('@orkestrel/terminal').isAnswerPayload} shape), and routes it through
21
- * `manager.answer` — `204` on success, `404` for `'terminal'`, `422` for `'unknown'` / `'rejected'`.
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.
22
12
  *
23
- * @remarks
24
- * The `token` option (a string OR a validator function {@link TerminalToken}) is validated at
25
- * GET connect, on EVERY POST, and RE-VALIDATED on every keepalive tick of a live SSE stream — a
26
- * stream whose presented token stops validating is torn down rather than left streaming forever.
27
- * Concurrent POST answerers race first-write-wins — a late POST for an already-settled prompt
28
- * returns `422`.
29
- *
30
- * @param manager - The `TerminalManagerInterface` (`@orkestrel/terminal`) whose endpoints are bridged
31
- * @param options - See {@link TerminalRoutesOptions}
32
- * @returns Exactly two {@link TerminalRoute} records — GET then POST — sharing one path
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
33
16
  *
34
17
  * @example
35
18
  * ```ts
@@ -39,7 +22,6 @@ import { TimerHandler } from '@orkestrel/terminal';
39
22
  * const manager = createTerminalManager()
40
23
  * manager.add('assistant')
41
24
  * const routes = createTerminalRoutes(manager, { token: 'secret' })
42
- * // mount `routes` against any router accepting `{ method, path, handler }`
43
25
  * ```
44
26
  */
45
27
  export declare function createTerminalRoutes(manager: TerminalManagerInterface, options?: TerminalRoutesOptions): readonly TerminalRoute[];
@@ -61,6 +43,39 @@ export declare const TERMINAL_KEEPALIVE_MS = 15000;
61
43
  */
62
44
  export declare const TERMINAL_ROUTES_PATH = "/terminals/:name";
63
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
+
64
79
  /**
65
80
  * One structural route record {@link import('./factories.js').createTerminalRoutes} returns — a
66
81
  * plain `{ method, path, handler }` shape carrying NO dependency on `@orkestrel/router`'s own
@@ -81,6 +96,33 @@ export declare interface TerminalRouteContext {
81
96
  readonly params: Readonly<Record<string, string>>;
82
97
  }
83
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
+
84
126
  /**
85
127
  * Options for {@link import('./factories.js').createTerminalRoutes}.
86
128
  *
@@ -1,5 +1,5 @@
1
1
  import { HEADER_TOKEN, defaultTimer, isAnswerPayload, serializeExpire, serializePending } from "@orkestrel/terminal";
2
- import { DEFAULT_BODY_LIMIT, openStream } from "@orkestrel/server";
2
+ import { ContentTooLargeError, DEFAULT_BODY_LIMIT, collectRequestBody, openStream } from "@orkestrel/server";
3
3
  //#region src/server/constants.ts
4
4
  /**
5
5
  * The default `:name`-templated path {@link import('./factories.js').createTerminalRoutes}
@@ -14,186 +14,249 @@ var TERMINAL_ROUTES_PATH = "/terminals/:name";
14
14
  */
15
15
  var TERMINAL_KEEPALIVE_MS = 15e3;
16
16
  //#endregion
17
- //#region src/server/factories.ts
17
+ //#region src/server/routes/TerminalConnection.ts
18
18
  /**
19
- * Build the two `TerminalManagerInterface` (`@orkestrel/terminal`) routes a GET SSE stream and
20
- * a POST answer endpoint, both mounted on the SAME `:name`-templated path — that bridge a manager's
21
- * endpoints onto the wire, byte-compatible with `PromptClient`.
22
- *
23
- * @remarks
24
- * - **GET (SSE).** Optionally token-gated (`401` on mismatch), `404` when `name` names no
25
- * endpoint. Opens a stream, replays every currently-{@link import('@orkestrel/terminal').PendingPrompt}
26
- * as a `pending` frame, then live-forwards the manager's own `pending` / `expire` events scoped
27
- * to `name`. A `keepalive`-interval `: ` comment ping is armed via the injected `timer` (default
28
- * the host `setTimeout`). On the request's `AbortSignal` firing (client disconnect OR server
29
- * stop) the keepalive is cancelled, both listeners unsubscribed, and the stream ended — no
30
- * `shutdown` frame is sent, so a reconnecting client is never told the endpoint is gone.
31
- * - **POST (answer).** Same token + `404` checks, then reads the body capped at `options.limit`
32
- * bytes (`413` over, `manager.answer` never called — see {@link TerminalRoutesOptions.limit}),
33
- * parses the JSON body (`400` on invalid JSON, `422` when it isn't an `{ id, value }`
34
- * {@link import('@orkestrel/terminal').isAnswerPayload} shape), and routes it through
35
- * `manager.answer` — `204` on success, `404` for `'terminal'`, `422` for `'unknown'` / `'rejected'`.
36
- *
37
- * @remarks
38
- * The `token` option (a string OR a validator function — {@link TerminalToken}) is validated at
39
- * GET connect, on EVERY POST, and RE-VALIDATED on every keepalive tick of a live SSE stream — a
40
- * stream whose presented token stops validating is torn down rather than left streaming forever.
41
- * Concurrent POST answerers race first-write-wins — a late POST for an already-settled prompt
42
- * returns `422`.
43
- *
44
- * @param manager - The `TerminalManagerInterface` (`@orkestrel/terminal`) whose endpoints are bridged
45
- * @param options - See {@link TerminalRoutesOptions}
46
- * @returns Exactly two {@link TerminalRoute} records — GET then POST — sharing one path
19
+ * Own one terminal SSE connection's replay, subscriptions, keepalive, and teardown.
47
20
  *
48
21
  * @example
49
22
  * ```ts
50
- * import { createTerminalRoutes } from '@src/server'
51
- * import { createTerminalManager } from '@orkestrel/terminal'
23
+ * import { TerminalConnection } from '@orkestrel/tool/server'
52
24
  *
53
- * const manager = createTerminalManager()
54
- * manager.add('assistant')
55
- * const routes = createTerminalRoutes(manager, { token: 'secret' })
56
- * // mount `routes` against any router accepting `{ method, path, handler }`
25
+ * const connection = new TerminalConnection(manager, name, request, stream, accepts, timer, 15_000)
26
+ * const response = connection.open()
57
27
  * ```
58
28
  */
59
- function createTerminalRoutes(manager, options) {
60
- const path = options?.path ?? "/terminals/:name";
61
- const token = options?.token;
62
- const keepalive = options?.keepalive ?? 15e3;
63
- const timer = options?.timer ?? defaultTimer;
64
- const limit = options?.limit ?? DEFAULT_BODY_LIMIT;
65
- async function readBoundedText(request) {
66
- const reader = request.body?.getReader();
67
- if (reader === void 0) return {
68
- ok: true,
69
- text: ""
70
- };
71
- const chunks = [];
72
- let received = 0;
73
- while (true) {
74
- const { done, value } = await reader.read();
75
- if (done) break;
76
- received += value.byteLength;
77
- if (received > limit) {
78
- await reader.cancel();
79
- return { ok: false };
80
- }
81
- chunks.push(value);
29
+ var TerminalConnection = class {
30
+ #manager;
31
+ #name;
32
+ #request;
33
+ #stream;
34
+ #accepts;
35
+ #timer;
36
+ #keepalive;
37
+ #presented;
38
+ #cancel;
39
+ #destroyHandler;
40
+ #pendingHandler;
41
+ #expireHandler;
42
+ #tickHandler;
43
+ /**
44
+ * Create a terminal stream connection.
45
+ *
46
+ * @param manager - Terminal manager supplying pending prompts and lifecycle events
47
+ * @param name - Terminal endpoint streamed by this connection
48
+ * @param request - Request whose abort signal owns the connection lifetime
49
+ * @param stream - Open SSE stream
50
+ * @param accepts - Presented-token validator
51
+ * @param timer - Keepalive timer implementation
52
+ * @param keepalive - Keepalive interval in milliseconds
53
+ */
54
+ constructor(manager, name, request, stream, accepts, timer, keepalive) {
55
+ this.#manager = manager;
56
+ this.#name = name;
57
+ this.#request = request;
58
+ this.#stream = stream;
59
+ this.#accepts = accepts;
60
+ this.#timer = timer;
61
+ this.#keepalive = keepalive;
62
+ this.#presented = request.headers.get(HEADER_TOKEN) ?? void 0;
63
+ this.#destroyHandler = this.#destroy.bind(this);
64
+ this.#pendingHandler = this.#pending.bind(this);
65
+ this.#expireHandler = this.#expire.bind(this);
66
+ this.#tickHandler = this.#tick.bind(this);
67
+ }
68
+ /**
69
+ * Open the connection by replaying pending prompts, subscribing, and arming keepalive handling.
70
+ *
71
+ * @returns The SSE response
72
+ */
73
+ open() {
74
+ if (this.#stream.closed || this.#request.signal.aborted) {
75
+ this.#destroy();
76
+ return this.#stream.response;
82
77
  }
83
- const buffer = new Uint8Array(received);
84
- let offset = 0;
85
- for (const chunk of chunks) {
86
- buffer.set(chunk, offset);
87
- offset += chunk.byteLength;
78
+ if (this.#cancel !== void 0) return this.#stream.response;
79
+ for (const prompt of this.#manager.pending(this.#name)) this.#write(serializePending(prompt));
80
+ this.#manager.emitter.on("pending", this.#pendingHandler);
81
+ this.#manager.emitter.on("expire", this.#expireHandler);
82
+ this.#cancel = this.#timer(this.#tickHandler, this.#keepalive);
83
+ this.#request.signal.addEventListener("abort", this.#destroyHandler);
84
+ return this.#stream.response;
85
+ }
86
+ #write(wire) {
87
+ this.#stream.write({
88
+ event: wire.event,
89
+ data: wire.data,
90
+ ...wire.id === void 0 ? {} : { id: wire.id }
91
+ });
92
+ }
93
+ #pending(prompt) {
94
+ if (prompt.to !== this.#name) return;
95
+ if (this.#stream.closed) {
96
+ this.#destroy();
97
+ return;
88
98
  }
89
- return {
90
- ok: true,
91
- text: new TextDecoder().decode(buffer)
92
- };
99
+ this.#write(serializePending(prompt));
100
+ }
101
+ #expire(to, id) {
102
+ if (to !== this.#name) return;
103
+ if (this.#stream.closed) {
104
+ this.#destroy();
105
+ return;
106
+ }
107
+ this.#write(serializeExpire(id));
108
+ }
109
+ #tick() {
110
+ if (this.#stream.closed || !this.#accepted()) {
111
+ this.#destroy();
112
+ return;
113
+ }
114
+ this.#stream.comment("");
115
+ this.#cancel = this.#timer(this.#tickHandler, this.#keepalive);
116
+ }
117
+ #accepted() {
118
+ try {
119
+ return this.#accepts(this.#presented);
120
+ } catch {
121
+ return false;
122
+ }
123
+ }
124
+ #destroy() {
125
+ const cancel = this.#cancel;
126
+ this.#cancel = void 0;
127
+ cancel?.();
128
+ this.#manager.emitter.off("pending", this.#pendingHandler);
129
+ this.#manager.emitter.off("expire", this.#expireHandler);
130
+ this.#request.signal.removeEventListener("abort", this.#destroyHandler);
131
+ this.#stream.end();
132
+ }
133
+ };
134
+ //#endregion
135
+ //#region src/server/routes/TerminalRoutes.ts
136
+ /**
137
+ * Build and serve the terminal manager's GET stream and POST answer routes.
138
+ *
139
+ * @example
140
+ * ```ts
141
+ * import { TerminalRoutes } from '@orkestrel/tool/server'
142
+ *
143
+ * const routes = new TerminalRoutes(manager).routes()
144
+ * ```
145
+ */
146
+ var TerminalRoutes = class {
147
+ #manager;
148
+ #path;
149
+ #token;
150
+ #keepalive;
151
+ #timer;
152
+ #limit;
153
+ #accepts;
154
+ #get;
155
+ #post;
156
+ /**
157
+ * Create a terminal route owner.
158
+ *
159
+ * @param manager - Terminal manager bridged onto HTTP
160
+ * @param options - Shared route, authorization, keepalive, timer, and body-limit options
161
+ */
162
+ constructor(manager, options) {
163
+ this.#manager = manager;
164
+ this.#path = options?.path ?? "/terminals/:name";
165
+ this.#token = options?.token;
166
+ this.#keepalive = options?.keepalive ?? 15e3;
167
+ this.#timer = options?.timer ?? defaultTimer;
168
+ const limit = options?.limit;
169
+ this.#limit = limit === void 0 || !Number.isFinite(limit) ? DEFAULT_BODY_LIMIT : Math.max(0, Math.floor(limit));
170
+ this.#accepts = this.#valid.bind(this);
171
+ this.#get = this.#handleGet.bind(this);
172
+ this.#post = this.#handlePost.bind(this);
173
+ }
174
+ /**
175
+ * Project the bound GET and POST route records.
176
+ *
177
+ * @returns The GET stream route followed by the POST answer route
178
+ */
179
+ routes() {
180
+ return [{
181
+ method: "GET",
182
+ path: this.#path,
183
+ handler: this.#get
184
+ }, {
185
+ method: "POST",
186
+ path: this.#path,
187
+ handler: this.#post
188
+ }];
93
189
  }
94
- function valid(presented) {
95
- if (token === void 0) return true;
190
+ #valid(presented) {
191
+ if (this.#token === void 0) return true;
96
192
  try {
97
- return typeof token === "function" ? token(presented) : presented === token;
193
+ return typeof this.#token === "function" ? this.#token(presented) : presented === this.#token;
98
194
  } catch {
99
195
  return false;
100
196
  }
101
197
  }
102
- function authorized(request) {
103
- return valid(request.headers.get(HEADER_TOKEN) ?? void 0);
198
+ #authorized(request) {
199
+ return this.#valid(request.headers.get(HEADER_TOKEN) ?? void 0);
104
200
  }
105
- return [{
106
- method: "GET",
107
- path,
108
- handler(request, context) {
109
- if (!authorized(request)) return new Response(null, { status: 401 });
110
- const name = context.params.name;
111
- if (manager.terminal(name) === void 0) return new Response(null, { status: 404 });
112
- const presented = request.headers.get(HEADER_TOKEN) ?? void 0;
113
- const stream = openStream();
114
- for (const prompt of manager.pending(name)) {
115
- const wire = serializePending(prompt);
116
- stream.write({
117
- event: wire.event,
118
- data: wire.data,
119
- id: wire.id
120
- });
121
- }
122
- let cancelKeepalive = () => {};
123
- const teardown = () => {
124
- cancelKeepalive();
125
- manager.emitter.off("pending", pendingHandler);
126
- manager.emitter.off("expire", expireHandler);
127
- request.signal.removeEventListener("abort", teardown);
128
- stream.end();
129
- };
130
- const pendingHandler = (prompt) => {
131
- if (prompt.to !== name) return;
132
- if (stream.closed) {
133
- teardown();
134
- return;
135
- }
136
- const wire = serializePending(prompt);
137
- stream.write({
138
- event: wire.event,
139
- data: wire.data,
140
- id: wire.id
141
- });
142
- };
143
- const expireHandler = (to, id) => {
144
- if (to !== name) return;
145
- if (stream.closed) {
146
- teardown();
147
- return;
148
- }
149
- const wire = serializeExpire(id);
150
- stream.write({
151
- event: wire.event,
152
- data: wire.data,
153
- id: wire.id
154
- });
155
- };
156
- manager.emitter.on("pending", pendingHandler);
157
- manager.emitter.on("expire", expireHandler);
158
- cancelKeepalive = timer(function ping() {
159
- if (stream.closed) {
160
- teardown();
161
- return;
162
- }
163
- if (!valid(presented)) {
164
- teardown();
165
- return;
166
- }
167
- stream.comment("");
168
- cancelKeepalive = timer(ping, keepalive);
169
- }, keepalive);
170
- request.signal.addEventListener("abort", teardown);
171
- return stream.response;
201
+ #handleGet(request, context) {
202
+ if (!this.#authorized(request)) return new Response(null, { status: 401 });
203
+ const name = context.params.name;
204
+ if (name === void 0 || this.#manager.terminal(name) === void 0) return new Response(null, { status: 404 });
205
+ return new TerminalConnection(this.#manager, name, request, openStream(), this.#accepts, this.#timer, this.#keepalive).open();
206
+ }
207
+ async #handlePost(request, context) {
208
+ if (!this.#authorized(request)) return new Response(null, { status: 401 });
209
+ const name = context.params.name;
210
+ if (name === void 0 || this.#manager.terminal(name) === void 0) return new Response(null, { status: 404 });
211
+ let bytes;
212
+ try {
213
+ bytes = await collectRequestBody(request, Math.max(1, this.#limit));
214
+ } catch (error) {
215
+ if (error instanceof ContentTooLargeError) return new Response(null, { status: 413 });
216
+ throw error;
172
217
  }
173
- }, {
174
- method: "POST",
175
- path,
176
- async handler(request, context) {
177
- if (!authorized(request)) return new Response(null, { status: 401 });
178
- const name = context.params.name;
179
- if (manager.terminal(name) === void 0) return new Response(null, { status: 404 });
180
- const bounded = await readBoundedText(request);
181
- if (!bounded.ok) return new Response(null, { status: 413 });
182
- let body;
183
- try {
184
- body = JSON.parse(bounded.text);
185
- } catch {
186
- return new Response(null, { status: 400 });
187
- }
188
- if (!isAnswerPayload(body)) return new Response(null, { status: 422 });
189
- const result = manager.answer(name, body.id, body.value);
190
- if (result.success) return new Response(null, { status: 204 });
191
- if (result.error === "terminal") return new Response(result.error, { status: 404 });
192
- return new Response(result.error, { status: 422 });
218
+ if (bytes.byteLength > 0 && bytes.byteLength > this.#limit) return new Response(null, { status: 413 });
219
+ let body;
220
+ try {
221
+ body = JSON.parse(new TextDecoder().decode(bytes));
222
+ } catch {
223
+ return new Response(null, { status: 400 });
193
224
  }
194
- }];
225
+ if (!isAnswerPayload(body)) return new Response(null, { status: 422 });
226
+ const result = this.#manager.answer(name, body.id, body.value);
227
+ if (result.success) return new Response(null, { status: 204 });
228
+ if (result.error === "terminal") return new Response(result.error, { status: 404 });
229
+ return new Response(result.error, { status: 422 });
230
+ }
231
+ };
232
+ //#endregion
233
+ //#region src/server/factories.ts
234
+ /**
235
+ * Build the GET SSE stream and POST answer routes that bridge a terminal manager onto the wire.
236
+ *
237
+ * @remarks
238
+ * Both routes share the configured `:name` path and optional token gate. The GET route replays
239
+ * pending prompts, forwards live pending/expire events, and owns abort/keepalive teardown. The
240
+ * POST route bounds the request body before parsing and maps answer outcomes to HTTP statuses.
241
+ *
242
+ * @param manager - The terminal manager whose endpoints are bridged
243
+ * @param options - Route path, token, keepalive, timer, and body-limit options
244
+ * @returns The GET route followed by the POST route
245
+ *
246
+ * @example
247
+ * ```ts
248
+ * import { createTerminalRoutes } from '@src/server'
249
+ * import { createTerminalManager } from '@orkestrel/terminal'
250
+ *
251
+ * const manager = createTerminalManager()
252
+ * manager.add('assistant')
253
+ * const routes = createTerminalRoutes(manager, { token: 'secret' })
254
+ * ```
255
+ */
256
+ function createTerminalRoutes(manager, options) {
257
+ return new TerminalRoutes(manager, options).routes();
195
258
  }
196
259
  //#endregion
197
- export { TERMINAL_KEEPALIVE_MS, TERMINAL_ROUTES_PATH, createTerminalRoutes };
260
+ export { TERMINAL_KEEPALIVE_MS, TERMINAL_ROUTES_PATH, TerminalConnection, TerminalRoutes, createTerminalRoutes };
198
261
 
199
262
  //# sourceMappingURL=index.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","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.js","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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"}