@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.
- package/dist/src/core/index.cjs +195 -87
- package/dist/src/core/index.cjs.map +1 -1
- package/dist/src/core/index.d.cts +84 -24
- package/dist/src/core/index.d.ts +84 -24
- package/dist/src/core/index.js +195 -88
- package/dist/src/core/index.js.map +1 -1
- package/dist/src/server/index.cjs +225 -160
- package/dist/src/server/index.cjs.map +1 -1
- package/dist/src/server/index.d.cts +68 -26
- package/dist/src/server/index.d.ts +68 -26
- package/dist/src/server/index.js +225 -162
- package/dist/src/server/index.js.map +1 -1
- package/package.json +20 -20
|
@@ -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
|
|
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
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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
|
-
* @
|
|
24
|
-
*
|
|
25
|
-
*
|
|
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
|
*
|
package/dist/src/server/index.js
CHANGED
|
@@ -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/
|
|
17
|
+
//#region src/server/routes/TerminalConnection.ts
|
|
18
18
|
/**
|
|
19
|
-
*
|
|
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 {
|
|
51
|
-
* import { createTerminalManager } from '@orkestrel/terminal'
|
|
23
|
+
* import { TerminalConnection } from '@orkestrel/tool/server'
|
|
52
24
|
*
|
|
53
|
-
* const
|
|
54
|
-
*
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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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"}
|