@orkestrel/tool 0.0.1 → 0.0.2

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.
@@ -0,0 +1,202 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ let _orkestrel_terminal = require("@orkestrel/terminal");
3
+ let _orkestrel_server = require("@orkestrel/server");
4
+ //#region src/server/constants.ts
5
+ /**
6
+ * The default `:name`-templated path {@link import('./factories.js').createTerminalRoutes}
7
+ * mounts its GET (SSE) + POST (answer) routes under.
8
+ */
9
+ var TERMINAL_ROUTES_PATH = "/terminals/:name";
10
+ /**
11
+ * The default SSE keepalive interval (in milliseconds)
12
+ * {@link import('./factories.js').createTerminalRoutes} arms per open connection — a `: `
13
+ * comment ping a conforming SSE parser ignores, keeping intermediary proxies from timing out an
14
+ * otherwise-idle stream.
15
+ */
16
+ var TERMINAL_KEEPALIVE_MS = 15e3;
17
+ //#endregion
18
+ //#region src/server/factories.ts
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
48
+ *
49
+ * @example
50
+ * ```ts
51
+ * import { createTerminalRoutes } from '@src/server'
52
+ * import { createTerminalManager } from '@orkestrel/terminal'
53
+ *
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 }`
58
+ * ```
59
+ */
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);
83
+ }
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;
89
+ }
90
+ return {
91
+ ok: true,
92
+ text: new TextDecoder().decode(buffer)
93
+ };
94
+ }
95
+ function valid(presented) {
96
+ if (token === void 0) return true;
97
+ try {
98
+ return typeof token === "function" ? token(presented) : presented === token;
99
+ } catch {
100
+ return false;
101
+ }
102
+ }
103
+ function authorized(request) {
104
+ return valid(request.headers.get(_orkestrel_terminal.HEADER_TOKEN) ?? void 0);
105
+ }
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;
173
+ }
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 });
194
+ }
195
+ }];
196
+ }
197
+ //#endregion
198
+ exports.TERMINAL_KEEPALIVE_MS = TERMINAL_KEEPALIVE_MS;
199
+ exports.TERMINAL_ROUTES_PATH = TERMINAL_ROUTES_PATH;
200
+ exports.createTerminalRoutes = createTerminalRoutes;
201
+
202
+ //# sourceMappingURL=index.cjs.map
@@ -0,0 +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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"}
@@ -0,0 +1,3 @@
1
+ export type * from './types.js';
2
+ export * from './constants.js';
3
+ export * from './factories.js';
@@ -0,0 +1,3 @@
1
+ export type * from './types.js';
2
+ export * from './constants.js';
3
+ export * from './factories.js';
@@ -0,0 +1,199 @@
1
+ import { HEADER_TOKEN, defaultTimer, isAnswerPayload, serializeExpire, serializePending } from "@orkestrel/terminal";
2
+ import { DEFAULT_BODY_LIMIT, openStream } from "@orkestrel/server";
3
+ //#region src/server/constants.ts
4
+ /**
5
+ * The default `:name`-templated path {@link import('./factories.js').createTerminalRoutes}
6
+ * mounts its GET (SSE) + POST (answer) routes under.
7
+ */
8
+ var TERMINAL_ROUTES_PATH = "/terminals/:name";
9
+ /**
10
+ * The default SSE keepalive interval (in milliseconds)
11
+ * {@link import('./factories.js').createTerminalRoutes} arms per open connection — a `: `
12
+ * comment ping a conforming SSE parser ignores, keeping intermediary proxies from timing out an
13
+ * otherwise-idle stream.
14
+ */
15
+ var TERMINAL_KEEPALIVE_MS = 15e3;
16
+ //#endregion
17
+ //#region src/server/factories.ts
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
47
+ *
48
+ * @example
49
+ * ```ts
50
+ * import { createTerminalRoutes } from '@src/server'
51
+ * import { createTerminalManager } from '@orkestrel/terminal'
52
+ *
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 }`
57
+ * ```
58
+ */
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);
82
+ }
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;
88
+ }
89
+ return {
90
+ ok: true,
91
+ text: new TextDecoder().decode(buffer)
92
+ };
93
+ }
94
+ function valid(presented) {
95
+ if (token === void 0) return true;
96
+ try {
97
+ return typeof token === "function" ? token(presented) : presented === token;
98
+ } catch {
99
+ return false;
100
+ }
101
+ }
102
+ function authorized(request) {
103
+ return valid(request.headers.get(HEADER_TOKEN) ?? void 0);
104
+ }
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;
172
+ }
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 });
193
+ }
194
+ }];
195
+ }
196
+ //#endregion
197
+ export { TERMINAL_KEEPALIVE_MS, TERMINAL_ROUTES_PATH, createTerminalRoutes };
198
+
199
+ //# sourceMappingURL=index.js.map
@@ -0,0 +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":";;;;;;;AAOA,IAAa,uBAAuB;;;;;;;AAQpC,IAAa,wBAAwB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;ACkDrC,SAAgB,qBACf,SACA,SAC2B;CAC3B,MAAM,OAAO,SAAS,QAAA;CACtB,MAAM,QAAmC,SAAS;CAClD,MAAM,YAAY,SAAS,aAAA;CAC3B,MAAM,QAAQ,SAAS,SAAS;CAChC,MAAM,QAAQ,SAAS,SAAS;CAUhC,eAAe,gBACd,SACiF;EACjF,MAAM,SAAS,QAAQ,MAAM,UAAU;EACvC,IAAI,WAAW,KAAA,GAAW,OAAO;GAAE,IAAI;GAAM,MAAM;EAAG;EACtD,MAAM,SAAuB,CAAC;EAC9B,IAAI,WAAW;EACf,OAAO,MAAM;GACZ,MAAM,EAAE,MAAM,UAAU,MAAM,OAAO,KAAK;GAC1C,IAAI,MAAM;GACV,YAAY,MAAM;GAClB,IAAI,WAAW,OAAO;IACrB,MAAM,OAAO,OAAO;IACpB,OAAO,EAAE,IAAI,MAAM;GACpB;GACA,OAAO,KAAK,KAAK;EAClB;EACA,MAAM,SAAS,IAAI,WAAW,QAAQ;EACtC,IAAI,SAAS;EACb,KAAK,MAAM,SAAS,QAAQ;GAC3B,OAAO,IAAI,OAAO,MAAM;GACxB,UAAU,MAAM;EACjB;EACA,OAAO;GAAE,IAAI;GAAM,MAAM,IAAI,YAAY,CAAC,CAAC,OAAO,MAAM;EAAE;CAC3D;CAUA,SAAS,MAAM,WAAwC;EACtD,IAAI,UAAU,KAAA,GAAW,OAAO;EAChC,IAAI;GACH,OAAO,OAAO,UAAU,aAAa,MAAM,SAAS,IAAI,cAAc;EACvE,QAAQ;GACP,OAAO;EACR;CACD;CAEA,SAAS,WAAW,SAA2B;EAC9C,OAAO,MAAM,QAAQ,QAAQ,IAAI,YAAY,KAAK,KAAA,CAAS;CAC5D;CAmGA,OAAO,CAAC;EAhGP,QAAQ;EACR;EACA,QAAQ,SAAkB,SAAyC;GAClE,IAAI,CAAC,WAAW,OAAO,GAAG,OAAO,IAAI,SAAS,MAAM,EAAE,QAAQ,IAAI,CAAC;GACnE,MAAM,OAAO,QAAQ,OAAO;GAC5B,IAAI,QAAQ,SAAS,IAAI,MAAM,KAAA,GAAW,OAAO,IAAI,SAAS,MAAM,EAAE,QAAQ,IAAI,CAAC;GACnF,MAAM,YAAY,QAAQ,QAAQ,IAAI,YAAY,KAAK,KAAA;GAEvD,MAAM,SAAS,WAAW;GAC1B,KAAK,MAAM,UAAU,QAAQ,QAAQ,IAAI,GAAG;IAC3C,MAAM,OAAO,iBAAiB,MAAM;IACpC,OAAO,MAAM;KAAE,OAAO,KAAK;KAAO,MAAM,KAAK;KAAM,IAAI,KAAK;IAAG,CAAC;GACjE;GAQA,IAAI,wBAAqC,CAAC;GAC1C,MAAM,iBAAuB;IAC5B,gBAAgB;IAChB,QAAQ,QAAQ,IAAI,WAAW,cAAc;IAC7C,QAAQ,QAAQ,IAAI,UAAU,aAAa;IAC3C,QAAQ,OAAO,oBAAoB,SAAS,QAAQ;IACpD,OAAO,IAAI;GACZ;GAEA,MAAM,kBAAkB,WAAgC;IACvD,IAAI,OAAO,OAAO,MAAM;IACxB,IAAI,OAAO,QAAQ;KAClB,SAAS;KACT;IACD;IACA,MAAM,OAAO,iBAAiB,MAAM;IACpC,OAAO,MAAM;KAAE,OAAO,KAAK;KAAO,MAAM,KAAK;KAAM,IAAI,KAAK;IAAG,CAAC;GACjE;GACA,MAAM,iBAAiB,IAAY,OAAqB;IACvD,IAAI,OAAO,MAAM;IACjB,IAAI,OAAO,QAAQ;KAClB,SAAS;KACT;IACD;IACA,MAAM,OAAO,gBAAgB,EAAE;IAC/B,OAAO,MAAM;KAAE,OAAO,KAAK;KAAO,MAAM,KAAK;KAAM,IAAI,KAAK;IAAG,CAAC;GACjE;GAEA,QAAQ,QAAQ,GAAG,WAAW,cAAc;GAC5C,QAAQ,QAAQ,GAAG,UAAU,aAAa;GAE1C,kBAAkB,MAAM,SAAS,OAAa;IAC7C,IAAI,OAAO,QAAQ;KAClB,SAAS;KACT;IACD;IACA,IAAI,CAAC,MAAM,SAAS,GAAG;KACtB,SAAS;KACT;IACD;IACA,OAAO,QAAQ,EAAE;IACjB,kBAAkB,MAAM,MAAM,SAAS;GACxC,GAAG,SAAS;GAEZ,QAAQ,OAAO,iBAAiB,SAAS,QAAQ;GAEjD,OAAO,OAAO;EACf;CA6BO,GAAK;EAzBZ,QAAQ;EACR;EACA,MAAM,QAAQ,SAAkB,SAAkD;GACjF,IAAI,CAAC,WAAW,OAAO,GAAG,OAAO,IAAI,SAAS,MAAM,EAAE,QAAQ,IAAI,CAAC;GACnE,MAAM,OAAO,QAAQ,OAAO;GAC5B,IAAI,QAAQ,SAAS,IAAI,MAAM,KAAA,GAAW,OAAO,IAAI,SAAS,MAAM,EAAE,QAAQ,IAAI,CAAC;GAEnF,MAAM,UAAU,MAAM,gBAAgB,OAAO;GAC7C,IAAI,CAAC,QAAQ,IAAI,OAAO,IAAI,SAAS,MAAM,EAAE,QAAQ,IAAI,CAAC;GAE1D,IAAI;GACJ,IAAI;IACH,OAAO,KAAK,MAAM,QAAQ,IAAI;GAC/B,QAAQ;IACP,OAAO,IAAI,SAAS,MAAM,EAAE,QAAQ,IAAI,CAAC;GAC1C;GACA,IAAI,CAAC,gBAAgB,IAAI,GAAG,OAAO,IAAI,SAAS,MAAM,EAAE,QAAQ,IAAI,CAAC;GAErE,MAAM,SAAS,QAAQ,OAAO,MAAM,KAAK,IAAI,KAAK,KAAK;GACvD,IAAI,OAAO,SAAS,OAAO,IAAI,SAAS,MAAM,EAAE,QAAQ,IAAI,CAAC;GAC7D,IAAI,OAAO,UAAU,YAAY,OAAO,IAAI,SAAS,OAAO,OAAO,EAAE,QAAQ,IAAI,CAAC;GAClF,OAAO,IAAI,SAAS,OAAO,OAAO,EAAE,QAAQ,IAAI,CAAC;EAClD;CAGY,CAAI;AAClB"}
@@ -0,0 +1,60 @@
1
+ import { TimerHandler } from '@orkestrel/terminal';
2
+ /** The HTTP method literal a {@link TerminalRoute} declares — the exact 7-literal union `@orkestrel/router`'s `Method` accepts. */
3
+ export type Method = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'HEAD' | 'OPTIONS';
4
+ /**
5
+ * The minimal route-dispatch context a {@link TerminalRoute} handler reads — exactly the frozen,
6
+ * URL-decoded `:name` path param slice a router hands a matched handler.
7
+ */
8
+ export interface TerminalRouteContext {
9
+ readonly params: Readonly<Record<string, string>>;
10
+ }
11
+ /**
12
+ * One structural route record {@link import('./factories.js').createTerminalRoutes} returns — a
13
+ * plain `{ method, path, handler }` shape carrying NO dependency on `@orkestrel/router`'s own
14
+ * `Route` type, so a consumer mounts it against any router that accepts a two-arg
15
+ * `(request, context) => Response | Promise<Response>` handler keyed by `method` + `path`.
16
+ */
17
+ export interface TerminalRoute {
18
+ readonly method: Method;
19
+ readonly path: string;
20
+ readonly handler: (request: Request, context: TerminalRouteContext) => Response | Promise<Response>;
21
+ }
22
+ /**
23
+ * The `token` gate a {@link TerminalRoutesOptions} may configure — a plain string compared for
24
+ * equality against the `x-orkestrel-token` header, OR a validator function the consumer fully
25
+ * controls, enabling expiry/rotation (a JWT `exp` check, a revocation-list lookup, anything
26
+ * time-varying) that a fixed string cannot express. `undefined` disables the auth check entirely.
27
+ */
28
+ export type TerminalToken = string | ((value: string | undefined) => boolean);
29
+ /**
30
+ * Options for {@link import('./factories.js').createTerminalRoutes}.
31
+ *
32
+ * @remarks
33
+ * - `path` — the shared `:name`-templated path both the GET (SSE) and POST (answer) routes
34
+ * mount under; defaults to {@link import('./constants.js').TERMINAL_ROUTES_PATH}.
35
+ * - `token` — a {@link TerminalToken}: a string is compared for equality against the
36
+ * `x-orkestrel-token` header; a function receives the header's value (`undefined` when absent)
37
+ * and returns whether it validates, letting the consumer roll/expire tokens out-of-band.
38
+ * Validated at GET connect, on EVERY POST, and RE-VALIDATED on every keepalive tick of a live
39
+ * SSE stream — a stream whose presented token stops validating (rotated, expired, revoked) is
40
+ * torn down (the abort/self-heal teardown path, no `shutdown` frame) rather than left open
41
+ * forever; the client reconnects and re-authenticates. Omitted ⇒ no auth check. Because
42
+ * re-validation only happens on the keepalive tick, the revocation window equals the keepalive
43
+ * interval — a token rejected/expired between ticks keeps streaming until the next one. A
44
+ * validator function that THROWS is treated as rejection (fail-closed) at every call site.
45
+ * - `keepalive` — the SSE comment-ping interval in milliseconds; defaults to
46
+ * {@link import('./constants.js').TERMINAL_KEEPALIVE_MS}.
47
+ * - `timer` — the injected {@link TimerHandler} driving the keepalive interval (default the host
48
+ * `setTimeout`/`clearTimeout`), so a test drives the keepalive deterministically.
49
+ * - `limit` — the maximum POST answer body size in bytes, streamed and enforced BEFORE JSON
50
+ * parsing (ignoring any `Content-Length` header, so a lying header can never bypass the cap);
51
+ * a body exceeding it is rejected `413` and `manager.answer` is never called. Defaults to
52
+ * `@orkestrel/server`'s own `DEFAULT_BODY_LIMIT` (1 MiB).
53
+ */
54
+ export interface TerminalRoutesOptions {
55
+ readonly path?: string;
56
+ readonly token?: TerminalToken;
57
+ readonly keepalive?: number;
58
+ readonly timer?: TimerHandler;
59
+ readonly limit?: number;
60
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@orkestrel/tool",
3
- "version": "0.0.1",
3
+ "version": "0.0.2",
4
4
  "description": "Concrete LLM-callable tools for the @orkestrel line — workflow authoring, workspace editing, and sub-agent delegation, over the agent tool runtime, with pluggable stores. Part of the @orkestrel line.",
5
5
  "keywords": [
6
6
  "agent",
@@ -37,6 +37,16 @@
37
37
  "default": "./dist/src/core/index.cjs"
38
38
  }
39
39
  },
40
+ "./server": {
41
+ "import": {
42
+ "types": "./dist/src/server/index.d.ts",
43
+ "default": "./dist/src/server/index.js"
44
+ },
45
+ "require": {
46
+ "types": "./dist/src/server/index.d.cts",
47
+ "default": "./dist/src/server/index.cjs"
48
+ }
49
+ },
40
50
  "./package.json": "./package.json"
41
51
  },
42
52
  "publishConfig": {
@@ -48,37 +58,42 @@
48
58
  "tmp:txt": "node -e \"const fs=require('node:fs'),p=require('node:path');function walk(d){for(const e of fs.readdirSync(d,{withFileTypes:true})){const f=p.join(d,e.name);if(e.isDirectory()){walk(f)}else if(!e.name.endsWith('.md')&&!e.name.endsWith('.txt')){const t=f+'.txt';if(!fs.existsSync(t)){fs.renameSync(f,t)}else{console.warn('Skipping '+f+' — target exists: '+t)}}}}try{walk('tmp')}catch(e){if(e.code!=='ENOENT')throw e}\"",
49
59
  "lint": "oxlint --config .oxlintrc.json --fix .",
50
60
  "check": "tsc --noEmit --project tsconfig.json && npm run check:src",
51
- "check:src": "npm run check:src:core",
61
+ "check:src": "npm run check:src:core && npm run check:src:server",
52
62
  "check:src:core": "tsc --noEmit -p configs/src/tsconfig.core.json",
63
+ "check:src:server": "tsc --noEmit -p configs/src/tsconfig.server.json",
53
64
  "format": "oxfmt --config .oxfmtrc.json --write .",
54
65
  "format:check": "oxfmt --config .oxfmtrc.json --check .",
55
66
  "lint:check": "oxlint --config .oxlintrc.json .",
56
67
  "test": "npm run test:src && npm run test:guides",
57
- "test:src": "vitest run --config vite.config.ts --no-cache --reporter=dot --project src:core",
68
+ "test:src": "vitest run --config vite.config.ts --no-cache --reporter=dot --project src:core --project src:server",
58
69
  "test:src:core": "vitest run --config vite.config.ts --no-cache --reporter=dot --project src:core",
70
+ "test:src:server": "vitest run --config vite.config.ts --no-cache --reporter=dot --project src:server",
59
71
  "test:guides": "vitest run --config vite.config.ts --reporter=dot --project guides",
60
72
  "build": "npm run clean && npm run build:src",
61
- "build:src": "npm run build:src:core",
73
+ "build:src": "npm run build:src:core && npm run build:src:server",
62
74
  "build:src:core": "vite build --config configs/src/vite.core.config.ts && npm run copy dist/src/core/index.d.ts dist/src/core/index.d.cts",
75
+ "build:src:server": "vite build --config configs/src/vite.server.config.ts && npm run copy dist/src/server/index.d.ts dist/src/server/index.d.cts",
63
76
  "prepublishOnly": "npm run format:check && npm run lint:check && npm run check && npm run build && npm test"
64
77
  },
65
78
  "dependencies": {
66
- "@orkestrel/agent": "^0.0.4",
67
- "@orkestrel/contract": "^0.0.1",
68
- "@orkestrel/workflow": "^0.0.4"
79
+ "@orkestrel/agent": "^0.0.5",
80
+ "@orkestrel/contract": "^0.0.2",
81
+ "@orkestrel/server": "^0.0.5",
82
+ "@orkestrel/terminal": "^0.0.2",
83
+ "@orkestrel/workflow": "^0.0.5"
69
84
  },
70
85
  "devDependencies": {
71
- "@microsoft/api-extractor": "^7.58.9",
72
- "@orkestrel/guide": "^0.0.1",
86
+ "@microsoft/api-extractor": "^7.58.11",
87
+ "@orkestrel/guide": "^0.0.2",
73
88
  "@types/node": "^26.1.1",
74
- "oxfmt": "^0.58.0",
75
- "oxlint": "^1.73.0",
89
+ "oxfmt": "^0.59.0",
90
+ "oxlint": "^1.74.0",
76
91
  "typescript": "^6.0.3",
77
- "vite": "^8.1.4",
92
+ "vite": "^8.1.5",
78
93
  "vite-plugin-dts": "^5.0.3",
79
94
  "vitest": "^4.1.10"
80
95
  },
81
96
  "engines": {
82
- "node": ">=24"
97
+ "node": ">=22"
83
98
  }
84
99
  }