@orkestrel/toolbox 0.0.23 → 0.0.25
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 +592 -245
- package/dist/src/core/index.cjs.map +1 -1
- package/dist/src/core/index.d.cts +429 -125
- package/dist/src/core/index.d.ts +429 -125
- package/dist/src/core/index.js +581 -249
- package/dist/src/core/index.js.map +1 -1
- package/dist/src/server/index.cjs +71 -16
- package/dist/src/server/index.cjs.map +1 -1
- package/dist/src/server/index.d.cts +45 -12
- package/dist/src/server/index.d.ts +45 -12
- package/dist/src/server/index.js +72 -18
- package/dist/src/server/index.js.map +1 -1
- package/package.json +19 -19
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
+
let _orkestrel_contract = require("@orkestrel/contract");
|
|
2
3
|
let _orkestrel_terminal = require("@orkestrel/terminal");
|
|
3
4
|
let _orkestrel_form = require("@orkestrel/form");
|
|
4
|
-
let _orkestrel_contract = require("@orkestrel/contract");
|
|
5
5
|
let _orkestrel_server = require("@orkestrel/server");
|
|
6
|
+
let _src_core = require("../core/index.cjs");
|
|
6
7
|
// src/server/constants.ts
|
|
7
8
|
/**
|
|
8
9
|
* Holds the default `:name`-templated path {@link import('./factories.js').createTerminalRoutes}
|
|
@@ -15,6 +16,31 @@ var TERMINAL_ROUTES_PATH = "/terminals/:name";
|
|
|
15
16
|
* keeping intermediary proxies from timing out an otherwise-idle stream.
|
|
16
17
|
*/
|
|
17
18
|
var TERMINAL_KEEPALIVE_MS = 15e3;
|
|
19
|
+
// src/server/shapers.ts
|
|
20
|
+
/**
|
|
21
|
+
* Describes the body a terminal POST route admits — the pending form's `id` and its answers.
|
|
22
|
+
*
|
|
23
|
+
* @remarks
|
|
24
|
+
* The shape closes the envelope. `@orkestrel/form`'s `isFormValues` guard still owns what one answer
|
|
25
|
+
* holds, so this shape does not restate the field value union.
|
|
26
|
+
*
|
|
27
|
+
* @example
|
|
28
|
+
* ```ts
|
|
29
|
+
* import { createContract } from '@orkestrel/contract'
|
|
30
|
+
* import { terminalAnswerShape } from '@src/server'
|
|
31
|
+
*
|
|
32
|
+
* const contract = createContract(terminalAnswerShape)
|
|
33
|
+
* contract.is({ id: 'form-1', values: { name: 'Ada' } }) // true
|
|
34
|
+
* contract.audit({ id: 'form-1', values: {}, reply: true }) // [{ reason: 'extra', path: ['reply'] }]
|
|
35
|
+
* ```
|
|
36
|
+
*/
|
|
37
|
+
var terminalAnswerShape = (0, _orkestrel_contract.objectShape)({
|
|
38
|
+
id: (0, _orkestrel_contract.stringShape)({
|
|
39
|
+
min: 1,
|
|
40
|
+
description: "The id of the pending form to answer."
|
|
41
|
+
}),
|
|
42
|
+
values: (0, _orkestrel_contract.recordShape)((0, _orkestrel_contract.jsonShape)(), { description: "The form answers keyed by field name." })
|
|
43
|
+
});
|
|
18
44
|
// src/server/terminals/TerminalConnection.ts
|
|
19
45
|
/**
|
|
20
46
|
* Owns one terminal SSE connection's replay, subscriptions, keepalive, and teardown.
|
|
@@ -142,6 +168,7 @@ var TerminalBridge = class {
|
|
|
142
168
|
#keepalive;
|
|
143
169
|
#timer;
|
|
144
170
|
#limit;
|
|
171
|
+
#answer;
|
|
145
172
|
#accepts;
|
|
146
173
|
#get;
|
|
147
174
|
#post;
|
|
@@ -149,16 +176,22 @@ var TerminalBridge = class {
|
|
|
149
176
|
* Creates a terminal bridge.
|
|
150
177
|
*
|
|
151
178
|
* @param manager - Terminal manager bridged onto HTTP
|
|
152
|
-
* @param options -
|
|
179
|
+
* @param options - Required token, plus shared route, keepalive, timer, and body-limit options
|
|
180
|
+
* @throws {ToolboxError} Thrown with code `TOOL` for a missing or malformed `token`, a `keepalive`
|
|
181
|
+
* that is not a positive finite number, or a `limit` that is not a positive safe integer
|
|
153
182
|
*/
|
|
154
183
|
constructor(manager, options) {
|
|
184
|
+
const token = options?.token;
|
|
185
|
+
if (!(0, _orkestrel_contract.isNonEmptyString)(token) && !(0, _orkestrel_contract.isFunction)(token)) throw new _src_core.ToolboxError("TOOL", `terminal routes need a token; received ${(0, _orkestrel_contract.preview)(token)}; pass token: 'SECRET' to compare the ${_orkestrel_terminal.HEADER_TOKEN} header, a function to decide each value, or token: () => true to leave the routes open`);
|
|
186
|
+
const keepalive = options.keepalive ?? 15e3;
|
|
187
|
+
if (!(0, _orkestrel_contract.isFiniteNumber)(keepalive) || keepalive <= 0) throw new _src_core.ToolboxError("TOOL", `terminal keepalive must be a positive finite number of milliseconds; received ${(0, _orkestrel_contract.preview)(keepalive)}; omit it for the default ${String(TERMINAL_KEEPALIVE_MS)}`);
|
|
155
188
|
this.#manager = manager;
|
|
156
|
-
this.#path = options
|
|
157
|
-
this.#token =
|
|
158
|
-
this.#keepalive =
|
|
159
|
-
this.#timer = options
|
|
160
|
-
|
|
161
|
-
this.#
|
|
189
|
+
this.#path = options.path ?? "/terminals/:name";
|
|
190
|
+
this.#token = token;
|
|
191
|
+
this.#keepalive = keepalive;
|
|
192
|
+
this.#timer = options.timer ?? _orkestrel_terminal.defaultTimer;
|
|
193
|
+
this.#limit = (0, _src_core.resolveBound)(options.limit, _orkestrel_server.DEFAULT_BODY_LIMIT, "terminal body limit");
|
|
194
|
+
this.#answer = (0, _orkestrel_contract.createContract)(terminalAnswerShape);
|
|
162
195
|
this.#accepts = this.#valid.bind(this);
|
|
163
196
|
this.#get = this.#handleGet.bind(this);
|
|
164
197
|
this.#post = this.#handlePost.bind(this);
|
|
@@ -180,7 +213,6 @@ var TerminalBridge = class {
|
|
|
180
213
|
}];
|
|
181
214
|
}
|
|
182
215
|
#valid(presented) {
|
|
183
|
-
if (this.#token === void 0) return true;
|
|
184
216
|
try {
|
|
185
217
|
return typeof this.#token === "function" ? this.#token(presented) : presented === this.#token;
|
|
186
218
|
} catch {
|
|
@@ -202,18 +234,35 @@ var TerminalBridge = class {
|
|
|
202
234
|
if (name === void 0 || this.#manager.terminal(name) === void 0) return new Response(null, { status: 404 });
|
|
203
235
|
const bytes = await this.#readBody(request);
|
|
204
236
|
if (bytes instanceof Response) return bytes;
|
|
205
|
-
if (bytes.byteLength > 0 && bytes.byteLength > this.#limit) return new Response(null, { status: 413 });
|
|
206
237
|
const body = (0, _orkestrel_contract.parseJSON)(new TextDecoder().decode(bytes));
|
|
207
238
|
if (body === void 0) return new Response(null, { status: 400 });
|
|
208
|
-
if (!
|
|
209
|
-
const
|
|
239
|
+
if (!this.#answer.is(body)) return this.#refuse(this.#answer.audit(body));
|
|
240
|
+
const { id, values } = body;
|
|
241
|
+
if (!(0, _orkestrel_form.isFormValues)(values)) return this.#refuse(Object.entries(values).filter(([, value]) => !(0, _orkestrel_form.isFieldValue)(value)).map(([key, value]) => ({
|
|
242
|
+
reason: "type",
|
|
243
|
+
path: ["values", key],
|
|
244
|
+
expected: "union",
|
|
245
|
+
received: (0, _orkestrel_contract.preview)(value)
|
|
246
|
+
})));
|
|
247
|
+
const result = this.#manager.answer(name, id, values);
|
|
210
248
|
if (result.success) return Response.json(result);
|
|
211
249
|
if (result.error.reason === "target") return Response.json(result, { status: 404 });
|
|
212
250
|
return Response.json(result, { status: 422 });
|
|
213
251
|
}
|
|
252
|
+
#refuse(faults) {
|
|
253
|
+
const message = (0, _src_core.limitRefusal)(`an answer body is { id, values }, where values maps each field name to a string, a finite number, a boolean, or a list of strings; correct each line and send it again:\n${(0, _orkestrel_contract.describeFaults)(faults, this.#answer.schema)}`);
|
|
254
|
+
return Response.json({
|
|
255
|
+
success: false,
|
|
256
|
+
error: {
|
|
257
|
+
reason: "malformed",
|
|
258
|
+
message,
|
|
259
|
+
faults
|
|
260
|
+
}
|
|
261
|
+
}, { status: 422 });
|
|
262
|
+
}
|
|
214
263
|
async #readBody(request) {
|
|
215
264
|
try {
|
|
216
|
-
return await (0, _orkestrel_server.collectRequestBody)(request,
|
|
265
|
+
return await (0, _orkestrel_server.collectRequestBody)(request, this.#limit);
|
|
217
266
|
} catch (error) {
|
|
218
267
|
if ((0, _orkestrel_contract.isInstance)(error, _orkestrel_server.ContentTooLargeError)) return new Response(null, { status: 413 });
|
|
219
268
|
throw error;
|
|
@@ -229,13 +278,18 @@ var TerminalBridge = class {
|
|
|
229
278
|
* streams, the same POST url answers, the same `{ id, values }` request body, the same JSON answer
|
|
230
279
|
* `Result` response body, and the same `x-orkestrel-token` header.
|
|
231
280
|
*
|
|
232
|
-
* Both routes share the configured `:name` path and
|
|
281
|
+
* Both routes share the configured `:name` path and the required token gate. The GET route replays
|
|
233
282
|
* pending prompts, forwards live pending/expire events, and owns abort/keepalive teardown. The
|
|
234
|
-
* POST route bounds the request body before parsing
|
|
283
|
+
* POST route bounds the request body before parsing, refuses a body outside
|
|
284
|
+
* {@link import('./shapers.js').terminalAnswerShape} with its faults, and maps answer outcomes to
|
|
285
|
+
* HTTP statuses.
|
|
235
286
|
*
|
|
236
287
|
* @param manager - The terminal manager whose endpoints are bridged
|
|
237
|
-
* @param options -
|
|
288
|
+
* @param options - The required token, plus route path, keepalive, timer, and body-limit options
|
|
238
289
|
* @returns The GET route followed by the POST route
|
|
290
|
+
* @throws {import('@src/core').ToolboxError} Thrown with code `TOOL` for a missing or malformed
|
|
291
|
+
* `token`, a `keepalive` that is not a positive finite number, or a `limit` that is not a positive
|
|
292
|
+
* safe integer; `token: () => true` is the explicit open
|
|
239
293
|
*
|
|
240
294
|
* @example
|
|
241
295
|
* ```ts
|
|
@@ -253,5 +307,6 @@ function createTerminalRoutes(manager, options) {
|
|
|
253
307
|
exports.TERMINAL_KEEPALIVE_MS = TERMINAL_KEEPALIVE_MS;
|
|
254
308
|
exports.TERMINAL_ROUTES_PATH = TERMINAL_ROUTES_PATH;
|
|
255
309
|
exports.createTerminalRoutes = createTerminalRoutes;
|
|
310
|
+
exports.terminalAnswerShape = terminalAnswerShape;
|
|
256
311
|
|
|
257
312
|
//# sourceMappingURL=index.cjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.cjs","names":["HEADER_TOKEN","serializePending","serializeExpire","defaultTimer","isFiniteNumber","DEFAULT_BODY_LIMIT","HEADER_TOKEN","createStream","parseJSON","isRecord","isNonEmptyString","isFormValues","collectRequestBody","isInstance","ContentTooLargeError"],"sources":["../../../src/server/constants.ts","../../../src/server/terminals/TerminalConnection.ts","../../../src/server/terminals/TerminalBridge.ts","../../../src/server/factories.ts"],"sourcesContent":["// Server-package constants — UPPER_SNAKE, `Object.freeze`d where structural, every member\n// exported.\n\n/**\n * Holds the default `:name`-templated path {@link import('./factories.js').createTerminalRoutes}\n * mounts its GET (SSE) and POST (answer) routes under, `/terminals/:name`.\n */\nexport const TERMINAL_ROUTES_PATH = '/terminals/:name'\n\n/**\n * Holds the default SSE keepalive interval {@link import('./factories.js').createTerminalRoutes}\n * arms per open connection, `15_000` ms — a `: ` comment ping a conforming SSE parser ignores,\n * keeping intermediary proxies from timing out an otherwise-idle stream.\n */\nexport const TERMINAL_KEEPALIVE_MS = 15_000\n","import type {\n\tPendingForm,\n\tTerminalManagerInterface,\n\tTimerCancelFunction,\n\tTimerHandler,\n\tWireEvent,\n} from '@orkestrel/terminal'\nimport type { SSEStreamInterface } from '@orkestrel/server'\nimport { HEADER_TOKEN, serializeExpire, serializePending } from '@orkestrel/terminal'\n\n/**\n * Owns one terminal SSE connection's replay, subscriptions, keepalive, and teardown.\n *\n * Not exported from the `@orkestrel/toolbox/server` barrel — reach this behaviour through\n * `createTerminalRoutes`.\n */\nexport class TerminalConnection {\n\treadonly #manager: TerminalManagerInterface\n\treadonly #name: string\n\treadonly #request: Request\n\treadonly #stream: SSEStreamInterface\n\treadonly #accepts: (presented: string | undefined) => boolean\n\treadonly #timer: TimerHandler\n\treadonly #keepalive: number\n\treadonly #presented: string | undefined\n\t#cancel: TimerCancelFunction | undefined\n\treadonly #destroyHandler: () => void\n\treadonly #pendingHandler: (form: PendingForm) => void\n\treadonly #expireHandler: (to: string, id: string) => void\n\treadonly #tickHandler: () => void\n\n\t/**\n\t * Creates a terminal stream connection.\n\t *\n\t * @param manager - Terminal manager supplying pending forms 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: SSEStreamInterface,\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 * Opens the connection by replaying pending forms, 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 form of this.#manager.pending(this.#name)) {\n\t\t\tthis.#write(serializePending(form))\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(form: PendingForm): void {\n\t\tif (form.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(form))\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 } from '@orkestrel/terminal'\nimport { isFormValues } from '@orkestrel/form'\nimport {\n\tisFiniteNumber,\n\tisInstance,\n\tisNonEmptyString,\n\tisRecord,\n\tparseJSON,\n} from '@orkestrel/contract'\nimport {\n\tcollectRequestBody,\n\tContentTooLargeError,\n\tcreateStream,\n\tDEFAULT_BODY_LIMIT,\n} from '@orkestrel/server'\nimport { TERMINAL_KEEPALIVE_MS, TERMINAL_ROUTES_PATH } from '../constants.js'\nimport { TerminalConnection } from './TerminalConnection.js'\n\n/**\n * Bridges a terminal manager onto the wire — owns the shared route options, the token gate, and\n * the bound GET stream and POST answer handlers.\n *\n * Not exported from the `@orkestrel/toolbox/server` barrel — reach this behaviour through\n * `createTerminalRoutes`.\n */\nexport class TerminalBridge {\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 * Creates a terminal bridge.\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 || !isFiniteNumber(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 * Projects 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\tcreateStream(),\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\tconst bytes = await this.#readBody(request)\n\t\tif (bytes instanceof Response) return bytes\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\tconst body = parseJSON(new TextDecoder().decode(bytes))\n\t\tif (body === undefined) {\n\t\t\treturn new Response(null, { status: 400 })\n\t\t}\n\t\tif (!isRecord(body) || !isNonEmptyString(body.id) || !isFormValues(body.values)) {\n\t\t\treturn new Response(null, { status: 422 })\n\t\t}\n\n\t\tconst result = this.#manager.answer(name, body.id, body.values)\n\t\tif (result.success) return Response.json(result)\n\t\tif (result.error.reason === 'target') return Response.json(result, { status: 404 })\n\t\treturn Response.json(result, { status: 422 })\n\t}\n\n\tasync #readBody(request: Request): Promise<Uint8Array | Response> {\n\t\ttry {\n\t\t\treturn await collectRequestBody(request, Math.max(1, this.#limit))\n\t\t} catch (error) {\n\t\t\tif (isInstance(error, ContentTooLargeError)) return new Response(null, { status: 413 })\n\t\t\tthrow error\n\t\t}\n\t}\n}\n","import type { TerminalManagerInterface } from '@orkestrel/terminal'\nimport type { TerminalRoute, TerminalRoutesOptions } from './types.js'\nimport { TerminalBridge } from './terminals/TerminalBridge.js'\n\n/**\n * Builds the GET SSE stream and POST answer routes that bridge a terminal manager onto the wire.\n *\n * @remarks\n * The pair stays byte-compatible with `@orkestrel/terminal`'s own `PromptClient`: the same GET url\n * streams, the same POST url answers, the same `{ id, values }` request body, the same JSON answer\n * `Result` response body, and the same `x-orkestrel-token` header.\n *\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 TerminalBridge(manager, options).routes()\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"index.cjs","names":["objectShape","stringShape","recordShape","jsonShape","HEADER_TOKEN","serializePending","serializeExpire","isNonEmptyString","isFunction","ToolboxError","preview","HEADER_TOKEN","isFiniteNumber","defaultTimer","resolveBound","DEFAULT_BODY_LIMIT","createContract","createStream","parseJSON","isFormValues","isFieldValue","limitRefusal","describeFaults","collectRequestBody","isInstance","ContentTooLargeError"],"sources":["../../../src/server/constants.ts","../../../src/server/shapers.ts","../../../src/server/terminals/TerminalConnection.ts","../../../src/server/terminals/TerminalBridge.ts","../../../src/server/factories.ts"],"sourcesContent":["// Server-package constants — UPPER_SNAKE, `Object.freeze`d where structural, every member\n// exported.\n\n/**\n * Holds the default `:name`-templated path {@link import('./factories.js').createTerminalRoutes}\n * mounts its GET (SSE) and POST (answer) routes under, `/terminals/:name`.\n */\nexport const TERMINAL_ROUTES_PATH = '/terminals/:name'\n\n/**\n * Holds the default SSE keepalive interval {@link import('./factories.js').createTerminalRoutes}\n * arms per open connection, `15_000` ms — a `: ` comment ping a conforming SSE parser ignores,\n * keeping intermediary proxies from timing out an otherwise-idle stream.\n */\nexport const TERMINAL_KEEPALIVE_MS = 15_000\n","import { jsonShape, objectShape, recordShape, stringShape } from '@orkestrel/contract'\n\n// Server-package shapes — the wire body `createTerminalRoutes` admits, compiled once per bridge.\n\n/**\n * Describes the body a terminal POST route admits — the pending form's `id` and its answers.\n *\n * @remarks\n * The shape closes the envelope. `@orkestrel/form`'s `isFormValues` guard still owns what one answer\n * holds, so this shape does not restate the field value union.\n *\n * @example\n * ```ts\n * import { createContract } from '@orkestrel/contract'\n * import { terminalAnswerShape } from '@src/server'\n *\n * const contract = createContract(terminalAnswerShape)\n * contract.is({ id: 'form-1', values: { name: 'Ada' } }) // true\n * contract.audit({ id: 'form-1', values: {}, reply: true }) // [{ reason: 'extra', path: ['reply'] }]\n * ```\n */\nexport const terminalAnswerShape = objectShape({\n\tid: stringShape({ min: 1, description: 'The id of the pending form to answer.' }),\n\tvalues: recordShape(jsonShape(), { description: 'The form answers keyed by field name.' }),\n})\n","import type {\n\tPendingForm,\n\tTerminalManagerInterface,\n\tTimerCancelFunction,\n\tTimerHandler,\n\tWireEvent,\n} from '@orkestrel/terminal'\nimport type { SSEStreamInterface } from '@orkestrel/server'\nimport { HEADER_TOKEN, serializeExpire, serializePending } from '@orkestrel/terminal'\n\n/**\n * Owns one terminal SSE connection's replay, subscriptions, keepalive, and teardown.\n *\n * Not exported from the `@orkestrel/toolbox/server` barrel — reach this behaviour through\n * `createTerminalRoutes`.\n */\nexport class TerminalConnection {\n\treadonly #manager: TerminalManagerInterface\n\treadonly #name: string\n\treadonly #request: Request\n\treadonly #stream: SSEStreamInterface\n\treadonly #accepts: (presented: string | undefined) => boolean\n\treadonly #timer: TimerHandler\n\treadonly #keepalive: number\n\treadonly #presented: string | undefined\n\t#cancel: TimerCancelFunction | undefined\n\treadonly #destroyHandler: () => void\n\treadonly #pendingHandler: (form: PendingForm) => void\n\treadonly #expireHandler: (to: string, id: string) => void\n\treadonly #tickHandler: () => void\n\n\t/**\n\t * Creates a terminal stream connection.\n\t *\n\t * @param manager - Terminal manager supplying pending forms 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: SSEStreamInterface,\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 * Opens the connection by replaying pending forms, 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 form of this.#manager.pending(this.#name)) {\n\t\t\tthis.#write(serializePending(form))\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(form: PendingForm): void {\n\t\tif (form.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(form))\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 { AuditFault, ContractInterface, Infer } from '@orkestrel/contract'\nimport type { TerminalManagerInterface, TimerHandler } from '@orkestrel/terminal'\nimport type {\n\tTerminalRoute,\n\tTerminalRouteContext,\n\tTerminalRoutesOptions,\n\tTerminalToken,\n} from '../types.js'\nimport { defaultTimer, HEADER_TOKEN } from '@orkestrel/terminal'\nimport { isFieldValue, isFormValues } from '@orkestrel/form'\nimport {\n\tcreateContract,\n\tisFiniteNumber,\n\tisFunction,\n\tisInstance,\n\tisNonEmptyString,\n\tparseJSON,\n\tpreview,\n} from '@orkestrel/contract'\nimport {\n\tcollectRequestBody,\n\tContentTooLargeError,\n\tcreateStream,\n\tDEFAULT_BODY_LIMIT,\n} from '@orkestrel/server'\nimport { describeFaults } from '@orkestrel/contract'\nimport { limitRefusal, resolveBound, ToolboxError } from '@src/core'\nimport { TERMINAL_KEEPALIVE_MS, TERMINAL_ROUTES_PATH } from '../constants.js'\nimport { terminalAnswerShape } from '../shapers.js'\nimport { TerminalConnection } from './TerminalConnection.js'\n\n/**\n * Bridges a terminal manager onto the wire — owns the shared route options, the token gate, and\n * the bound GET stream and POST answer handlers.\n *\n * Not exported from the `@orkestrel/toolbox/server` barrel — reach this behaviour through\n * `createTerminalRoutes`.\n */\nexport class TerminalBridge {\n\treadonly #manager: TerminalManagerInterface\n\treadonly #path: string\n\treadonly #token: TerminalToken\n\treadonly #keepalive: number\n\treadonly #timer: TimerHandler\n\treadonly #limit: number\n\treadonly #answer: ContractInterface<Infer<typeof terminalAnswerShape>>\n\treadonly #accepts: (presented: string | undefined) => boolean\n\treadonly #get: TerminalRoute['handler']\n\treadonly #post: TerminalRoute['handler']\n\n\t/**\n\t * Creates a terminal bridge.\n\t *\n\t * @param manager - Terminal manager bridged onto HTTP\n\t * @param options - Required token, plus shared route, keepalive, timer, and body-limit options\n\t * @throws {ToolboxError} Thrown with code `TOOL` for a missing or malformed `token`, a `keepalive`\n\t * that is not a positive finite number, or a `limit` that is not a positive safe integer\n\t */\n\tconstructor(manager: TerminalManagerInterface, options: TerminalRoutesOptions) {\n\t\t// An untyped caller can omit the options, which is the same refusal as omitting the token.\n\t\tconst token = options?.token\n\t\tif (!isNonEmptyString(token) && !isFunction(token)) {\n\t\t\tthrow new ToolboxError(\n\t\t\t\t'TOOL',\n\t\t\t\t`terminal routes need a token; received ${preview(token)}; pass token: 'SECRET' to compare the ${HEADER_TOKEN} header, a function to decide each value, or token: () => true to leave the routes open`,\n\t\t\t)\n\t\t}\n\t\tconst keepalive = options.keepalive ?? TERMINAL_KEEPALIVE_MS\n\t\tif (!isFiniteNumber(keepalive) || keepalive <= 0) {\n\t\t\tthrow new ToolboxError(\n\t\t\t\t'TOOL',\n\t\t\t\t`terminal keepalive must be a positive finite number of milliseconds; received ${preview(keepalive)}; omit it for the default ${String(TERMINAL_KEEPALIVE_MS)}`,\n\t\t\t)\n\t\t}\n\t\tthis.#manager = manager\n\t\tthis.#path = options.path ?? TERMINAL_ROUTES_PATH\n\t\tthis.#token = token\n\t\tthis.#keepalive = keepalive\n\t\tthis.#timer = options.timer ?? defaultTimer\n\t\tthis.#limit = resolveBound(options.limit, DEFAULT_BODY_LIMIT, 'terminal body limit')\n\t\tthis.#answer = createContract(terminalAnswerShape)\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 * Projects 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\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\tcreateStream(),\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\tconst bytes = await this.#readBody(request)\n\t\tif (bytes instanceof Response) return bytes\n\n\t\tconst body = parseJSON(new TextDecoder().decode(bytes))\n\t\tif (body === undefined) {\n\t\t\treturn new Response(null, { status: 400 })\n\t\t}\n\t\tif (!this.#answer.is(body)) return this.#refuse(this.#answer.audit(body))\n\t\tconst { id, values } = body\n\t\tif (!isFormValues(values)) {\n\t\t\treturn this.#refuse(\n\t\t\t\tObject.entries(values)\n\t\t\t\t\t.filter(([, value]) => !isFieldValue(value))\n\t\t\t\t\t.map(([key, value]) => ({\n\t\t\t\t\t\treason: 'type',\n\t\t\t\t\t\tpath: ['values', key],\n\t\t\t\t\t\texpected: 'union',\n\t\t\t\t\t\treceived: preview(value),\n\t\t\t\t\t})),\n\t\t\t)\n\t\t}\n\n\t\tconst result = this.#manager.answer(name, id, values)\n\t\tif (result.success) return Response.json(result)\n\t\tif (result.error.reason === 'target') return Response.json(result, { status: 404 })\n\t\treturn Response.json(result, { status: 422 })\n\t}\n\n\t// A malformed body answers in the manager's result envelope, so a client reads one body shape.\n\t#refuse(faults: readonly AuditFault[]): Response {\n\t\tconst message = limitRefusal(\n\t\t\t`an answer body is { id, values }, where values maps each field name to a string, a finite number, a boolean, or a list of strings; correct each line and send it again:\\n${describeFaults(faults, this.#answer.schema)}`,\n\t\t)\n\t\treturn Response.json(\n\t\t\t{ success: false, error: { reason: 'malformed', message, faults } },\n\t\t\t{ status: 422 },\n\t\t)\n\t}\n\n\tasync #readBody(request: Request): Promise<Uint8Array | Response> {\n\t\ttry {\n\t\t\treturn await collectRequestBody(request, this.#limit)\n\t\t} catch (error) {\n\t\t\tif (isInstance(error, ContentTooLargeError)) return new Response(null, { status: 413 })\n\t\t\tthrow error\n\t\t}\n\t}\n}\n","import type { TerminalManagerInterface } from '@orkestrel/terminal'\nimport type { TerminalRoute, TerminalRoutesOptions } from './types.js'\nimport { TerminalBridge } from './terminals/TerminalBridge.js'\n\n/**\n * Builds the GET SSE stream and POST answer routes that bridge a terminal manager onto the wire.\n *\n * @remarks\n * The pair stays byte-compatible with `@orkestrel/terminal`'s own `PromptClient`: the same GET url\n * streams, the same POST url answers, the same `{ id, values }` request body, the same JSON answer\n * `Result` response body, and the same `x-orkestrel-token` header.\n *\n * Both routes share the configured `:name` path and the required 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, refuses a body outside\n * {@link import('./shapers.js').terminalAnswerShape} with its faults, and maps answer outcomes to\n * HTTP statuses.\n *\n * @param manager - The terminal manager whose endpoints are bridged\n * @param options - The required token, plus route path, keepalive, timer, and body-limit options\n * @returns The GET route followed by the POST route\n * @throws {import('@src/core').ToolboxError} Thrown with code `TOOL` for a missing or malformed\n * `token`, a `keepalive` that is not a positive finite number, or a `limit` that is not a positive\n * safe integer; `token: () => true` is the explicit open\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 TerminalBridge(manager, options).routes()\n}\n"],"mappings":"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"}
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { JSONShape } from '@orkestrel/contract';
|
|
2
|
+
import { ObjectShape } from '@orkestrel/contract';
|
|
3
|
+
import { StringShape } from '@orkestrel/contract';
|
|
1
4
|
import type { TerminalManagerInterface } from '@orkestrel/terminal';
|
|
2
5
|
import type { TimerHandler } from '@orkestrel/terminal';
|
|
3
6
|
|
|
@@ -9,13 +12,18 @@ import type { TimerHandler } from '@orkestrel/terminal';
|
|
|
9
12
|
* streams, the same POST url answers, the same `{ id, values }` request body, the same JSON answer
|
|
10
13
|
* `Result` response body, and the same `x-orkestrel-token` header.
|
|
11
14
|
*
|
|
12
|
-
* Both routes share the configured `:name` path and
|
|
15
|
+
* Both routes share the configured `:name` path and the required token gate. The GET route replays
|
|
13
16
|
* pending prompts, forwards live pending/expire events, and owns abort/keepalive teardown. The
|
|
14
|
-
* POST route bounds the request body before parsing
|
|
17
|
+
* POST route bounds the request body before parsing, refuses a body outside
|
|
18
|
+
* {@link import('./shapers.js').terminalAnswerShape} with its faults, and maps answer outcomes to
|
|
19
|
+
* HTTP statuses.
|
|
15
20
|
*
|
|
16
21
|
* @param manager - The terminal manager whose endpoints are bridged
|
|
17
|
-
* @param options -
|
|
22
|
+
* @param options - The required token, plus route path, keepalive, timer, and body-limit options
|
|
18
23
|
* @returns The GET route followed by the POST route
|
|
24
|
+
* @throws {import('@orkestrel/toolbox').ToolboxError} Thrown with code `TOOL` for a missing or malformed
|
|
25
|
+
* `token`, a `keepalive` that is not a positive finite number, or a `limit` that is not a positive
|
|
26
|
+
* safe integer; `token: () => true` is the explicit open
|
|
19
27
|
*
|
|
20
28
|
* @example
|
|
21
29
|
* ```ts
|
|
@@ -27,7 +35,7 @@ import type { TimerHandler } from '@orkestrel/terminal';
|
|
|
27
35
|
* const routes = createTerminalRoutes(manager, { token: 'secret' })
|
|
28
36
|
* ```
|
|
29
37
|
*/
|
|
30
|
-
export declare function createTerminalRoutes(manager: TerminalManagerInterface, options
|
|
38
|
+
export declare function createTerminalRoutes(manager: TerminalManagerInterface, options: TerminalRoutesOptions): readonly TerminalRoute[];
|
|
31
39
|
|
|
32
40
|
/**
|
|
33
41
|
* Holds the default SSE keepalive interval {@link import('./factories.js').createTerminalRoutes}
|
|
@@ -42,6 +50,28 @@ export declare const TERMINAL_KEEPALIVE_MS = 15000;
|
|
|
42
50
|
*/
|
|
43
51
|
export declare const TERMINAL_ROUTES_PATH = "/terminals/:name";
|
|
44
52
|
|
|
53
|
+
/**
|
|
54
|
+
* Describes the body a terminal POST route admits — the pending form's `id` and its answers.
|
|
55
|
+
*
|
|
56
|
+
* @remarks
|
|
57
|
+
* The shape closes the envelope. `@orkestrel/form`'s `isFormValues` guard still owns what one answer
|
|
58
|
+
* holds, so this shape does not restate the field value union.
|
|
59
|
+
*
|
|
60
|
+
* @example
|
|
61
|
+
* ```ts
|
|
62
|
+
* import { createContract } from '@orkestrel/contract'
|
|
63
|
+
* import { terminalAnswerShape } from '@src/server'
|
|
64
|
+
*
|
|
65
|
+
* const contract = createContract(terminalAnswerShape)
|
|
66
|
+
* contract.is({ id: 'form-1', values: { name: 'Ada' } }) // true
|
|
67
|
+
* contract.audit({ id: 'form-1', values: {}, reply: true }) // [{ reason: 'extra', path: ['reply'] }]
|
|
68
|
+
* ```
|
|
69
|
+
*/
|
|
70
|
+
export declare const terminalAnswerShape: ObjectShape< {
|
|
71
|
+
id: StringShape;
|
|
72
|
+
values: ObjectShape<Record<never, never>, JSONShape>;
|
|
73
|
+
}, false>;
|
|
74
|
+
|
|
45
75
|
/**
|
|
46
76
|
* Represents one structural route record {@link import('./factories.js').createTerminalRoutes}
|
|
47
77
|
* returns — a plain `{ method, path, handler }` shape carrying no dependency on
|
|
@@ -79,32 +109,35 @@ export declare type TerminalRouteMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DE
|
|
|
79
109
|
* Validated at GET connect, on every POST, and re-validated on every keepalive tick of a live
|
|
80
110
|
* SSE stream — a stream whose presented token stops validating (rotated, expired, revoked) is
|
|
81
111
|
* torn down (the abort/self-heal teardown path, no `shutdown` frame) rather than left open
|
|
82
|
-
* forever; the client reconnects and re-authenticates.
|
|
112
|
+
* forever; the client reconnects and re-authenticates. Required: omitting it, or passing an empty
|
|
113
|
+
* string or a value that is neither a string nor a function, throws at construction, so routes
|
|
114
|
+
* never open by accident; `token: () => true` is the explicit open. Because
|
|
83
115
|
* re-validation only happens on the keepalive tick, the revocation window equals the keepalive
|
|
84
116
|
* interval — a token rejected/expired between ticks keeps streaming until the next one. A
|
|
85
117
|
* validator function that throws is treated as rejection (fail-closed) at every call site.
|
|
86
|
-
* - `keepalive` — the SSE comment-ping interval in milliseconds; defaults
|
|
87
|
-
* {@link import('./constants.js').TERMINAL_KEEPALIVE_MS}.
|
|
118
|
+
* - `keepalive` — the SSE comment-ping interval in milliseconds, a positive finite number; defaults
|
|
119
|
+
* to {@link import('./constants.js').TERMINAL_KEEPALIVE_MS}. Any other value throws at construction.
|
|
88
120
|
* - `timer` — the injected {@link TimerHandler} driving the keepalive interval (default the host
|
|
89
121
|
* `setTimeout`/`clearTimeout`), so a test drives the keepalive deterministically.
|
|
90
122
|
* - `limit` — the maximum POST answer body size in bytes, streamed and enforced before JSON
|
|
91
123
|
* parsing (ignoring any `Content-Length` header, so a lying header can never bypass the cap);
|
|
92
|
-
* a body exceeding it is rejected `413` and `manager.answer` is never called.
|
|
93
|
-
* `@orkestrel/server`'s own
|
|
124
|
+
* a body exceeding it is rejected `413` and `manager.answer` is never called. A positive safe
|
|
125
|
+
* integer; any other value throws at construction. Defaults to `@orkestrel/server`'s own
|
|
126
|
+
* `DEFAULT_BODY_LIMIT` (1 MiB).
|
|
94
127
|
*/
|
|
95
128
|
export declare interface TerminalRoutesOptions {
|
|
96
129
|
readonly path?: string;
|
|
97
|
-
readonly token
|
|
130
|
+
readonly token: TerminalToken;
|
|
98
131
|
readonly keepalive?: number;
|
|
99
132
|
readonly timer?: TimerHandler;
|
|
100
133
|
readonly limit?: number;
|
|
101
134
|
}
|
|
102
135
|
|
|
103
136
|
/**
|
|
104
|
-
* Represents the `token` gate {@link TerminalRoutesOptions}
|
|
137
|
+
* Represents the `token` gate {@link TerminalRoutesOptions} requires — a plain string compared
|
|
105
138
|
* for equality against the `x-orkestrel-token` header, or a validator function the consumer fully
|
|
106
139
|
* controls, enabling the expiry and rotation a fixed string cannot express (a JWT `exp` check, a
|
|
107
|
-
* revocation-list lookup, anything time-varying).
|
|
140
|
+
* revocation-list lookup, anything time-varying). The explicit open is the validator `() => true`.
|
|
108
141
|
*/
|
|
109
142
|
export declare type TerminalToken = string | ((value: string | undefined) => boolean);
|
|
110
143
|
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { JSONShape } from '@orkestrel/contract';
|
|
2
|
+
import { ObjectShape } from '@orkestrel/contract';
|
|
3
|
+
import { StringShape } from '@orkestrel/contract';
|
|
1
4
|
import type { TerminalManagerInterface } from '@orkestrel/terminal';
|
|
2
5
|
import type { TimerHandler } from '@orkestrel/terminal';
|
|
3
6
|
|
|
@@ -9,13 +12,18 @@ import type { TimerHandler } from '@orkestrel/terminal';
|
|
|
9
12
|
* streams, the same POST url answers, the same `{ id, values }` request body, the same JSON answer
|
|
10
13
|
* `Result` response body, and the same `x-orkestrel-token` header.
|
|
11
14
|
*
|
|
12
|
-
* Both routes share the configured `:name` path and
|
|
15
|
+
* Both routes share the configured `:name` path and the required token gate. The GET route replays
|
|
13
16
|
* pending prompts, forwards live pending/expire events, and owns abort/keepalive teardown. The
|
|
14
|
-
* POST route bounds the request body before parsing
|
|
17
|
+
* POST route bounds the request body before parsing, refuses a body outside
|
|
18
|
+
* {@link import('./shapers.js').terminalAnswerShape} with its faults, and maps answer outcomes to
|
|
19
|
+
* HTTP statuses.
|
|
15
20
|
*
|
|
16
21
|
* @param manager - The terminal manager whose endpoints are bridged
|
|
17
|
-
* @param options -
|
|
22
|
+
* @param options - The required token, plus route path, keepalive, timer, and body-limit options
|
|
18
23
|
* @returns The GET route followed by the POST route
|
|
24
|
+
* @throws {import('@orkestrel/toolbox').ToolboxError} Thrown with code `TOOL` for a missing or malformed
|
|
25
|
+
* `token`, a `keepalive` that is not a positive finite number, or a `limit` that is not a positive
|
|
26
|
+
* safe integer; `token: () => true` is the explicit open
|
|
19
27
|
*
|
|
20
28
|
* @example
|
|
21
29
|
* ```ts
|
|
@@ -27,7 +35,7 @@ import type { TimerHandler } from '@orkestrel/terminal';
|
|
|
27
35
|
* const routes = createTerminalRoutes(manager, { token: 'secret' })
|
|
28
36
|
* ```
|
|
29
37
|
*/
|
|
30
|
-
export declare function createTerminalRoutes(manager: TerminalManagerInterface, options
|
|
38
|
+
export declare function createTerminalRoutes(manager: TerminalManagerInterface, options: TerminalRoutesOptions): readonly TerminalRoute[];
|
|
31
39
|
|
|
32
40
|
/**
|
|
33
41
|
* Holds the default SSE keepalive interval {@link import('./factories.js').createTerminalRoutes}
|
|
@@ -42,6 +50,28 @@ export declare const TERMINAL_KEEPALIVE_MS = 15000;
|
|
|
42
50
|
*/
|
|
43
51
|
export declare const TERMINAL_ROUTES_PATH = "/terminals/:name";
|
|
44
52
|
|
|
53
|
+
/**
|
|
54
|
+
* Describes the body a terminal POST route admits — the pending form's `id` and its answers.
|
|
55
|
+
*
|
|
56
|
+
* @remarks
|
|
57
|
+
* The shape closes the envelope. `@orkestrel/form`'s `isFormValues` guard still owns what one answer
|
|
58
|
+
* holds, so this shape does not restate the field value union.
|
|
59
|
+
*
|
|
60
|
+
* @example
|
|
61
|
+
* ```ts
|
|
62
|
+
* import { createContract } from '@orkestrel/contract'
|
|
63
|
+
* import { terminalAnswerShape } from '@src/server'
|
|
64
|
+
*
|
|
65
|
+
* const contract = createContract(terminalAnswerShape)
|
|
66
|
+
* contract.is({ id: 'form-1', values: { name: 'Ada' } }) // true
|
|
67
|
+
* contract.audit({ id: 'form-1', values: {}, reply: true }) // [{ reason: 'extra', path: ['reply'] }]
|
|
68
|
+
* ```
|
|
69
|
+
*/
|
|
70
|
+
export declare const terminalAnswerShape: ObjectShape< {
|
|
71
|
+
id: StringShape;
|
|
72
|
+
values: ObjectShape<Record<never, never>, JSONShape>;
|
|
73
|
+
}, false>;
|
|
74
|
+
|
|
45
75
|
/**
|
|
46
76
|
* Represents one structural route record {@link import('./factories.js').createTerminalRoutes}
|
|
47
77
|
* returns — a plain `{ method, path, handler }` shape carrying no dependency on
|
|
@@ -79,32 +109,35 @@ export declare type TerminalRouteMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DE
|
|
|
79
109
|
* Validated at GET connect, on every POST, and re-validated on every keepalive tick of a live
|
|
80
110
|
* SSE stream — a stream whose presented token stops validating (rotated, expired, revoked) is
|
|
81
111
|
* torn down (the abort/self-heal teardown path, no `shutdown` frame) rather than left open
|
|
82
|
-
* forever; the client reconnects and re-authenticates.
|
|
112
|
+
* forever; the client reconnects and re-authenticates. Required: omitting it, or passing an empty
|
|
113
|
+
* string or a value that is neither a string nor a function, throws at construction, so routes
|
|
114
|
+
* never open by accident; `token: () => true` is the explicit open. Because
|
|
83
115
|
* re-validation only happens on the keepalive tick, the revocation window equals the keepalive
|
|
84
116
|
* interval — a token rejected/expired between ticks keeps streaming until the next one. A
|
|
85
117
|
* validator function that throws is treated as rejection (fail-closed) at every call site.
|
|
86
|
-
* - `keepalive` — the SSE comment-ping interval in milliseconds; defaults
|
|
87
|
-
* {@link import('./constants.js').TERMINAL_KEEPALIVE_MS}.
|
|
118
|
+
* - `keepalive` — the SSE comment-ping interval in milliseconds, a positive finite number; defaults
|
|
119
|
+
* to {@link import('./constants.js').TERMINAL_KEEPALIVE_MS}. Any other value throws at construction.
|
|
88
120
|
* - `timer` — the injected {@link TimerHandler} driving the keepalive interval (default the host
|
|
89
121
|
* `setTimeout`/`clearTimeout`), so a test drives the keepalive deterministically.
|
|
90
122
|
* - `limit` — the maximum POST answer body size in bytes, streamed and enforced before JSON
|
|
91
123
|
* parsing (ignoring any `Content-Length` header, so a lying header can never bypass the cap);
|
|
92
|
-
* a body exceeding it is rejected `413` and `manager.answer` is never called.
|
|
93
|
-
* `@orkestrel/server`'s own
|
|
124
|
+
* a body exceeding it is rejected `413` and `manager.answer` is never called. A positive safe
|
|
125
|
+
* integer; any other value throws at construction. Defaults to `@orkestrel/server`'s own
|
|
126
|
+
* `DEFAULT_BODY_LIMIT` (1 MiB).
|
|
94
127
|
*/
|
|
95
128
|
export declare interface TerminalRoutesOptions {
|
|
96
129
|
readonly path?: string;
|
|
97
|
-
readonly token
|
|
130
|
+
readonly token: TerminalToken;
|
|
98
131
|
readonly keepalive?: number;
|
|
99
132
|
readonly timer?: TimerHandler;
|
|
100
133
|
readonly limit?: number;
|
|
101
134
|
}
|
|
102
135
|
|
|
103
136
|
/**
|
|
104
|
-
* Represents the `token` gate {@link TerminalRoutesOptions}
|
|
137
|
+
* Represents the `token` gate {@link TerminalRoutesOptions} requires — a plain string compared
|
|
105
138
|
* for equality against the `x-orkestrel-token` header, or a validator function the consumer fully
|
|
106
139
|
* controls, enabling the expiry and rotation a fixed string cannot express (a JWT `exp` check, a
|
|
107
|
-
* revocation-list lookup, anything time-varying).
|
|
140
|
+
* revocation-list lookup, anything time-varying). The explicit open is the validator `() => true`.
|
|
108
141
|
*/
|
|
109
142
|
export declare type TerminalToken = string | ((value: string | undefined) => boolean);
|
|
110
143
|
|
package/dist/src/server/index.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
+
import { createContract, describeFaults, isFiniteNumber, isFunction, isInstance, isNonEmptyString, jsonShape, objectShape, parseJSON, preview, recordShape, stringShape } from "@orkestrel/contract";
|
|
1
2
|
import { HEADER_TOKEN, defaultTimer, serializeExpire, serializePending } from "@orkestrel/terminal";
|
|
2
|
-
import { isFormValues } from "@orkestrel/form";
|
|
3
|
-
import { isFiniteNumber, isInstance, isNonEmptyString, isRecord, parseJSON } from "@orkestrel/contract";
|
|
3
|
+
import { isFieldValue, isFormValues } from "@orkestrel/form";
|
|
4
4
|
import { ContentTooLargeError, DEFAULT_BODY_LIMIT, collectRequestBody, createStream } from "@orkestrel/server";
|
|
5
|
+
import { ToolboxError, limitRefusal, resolveBound } from "../core/index.js";
|
|
5
6
|
// src/server/constants.ts
|
|
6
7
|
/**
|
|
7
8
|
* Holds the default `:name`-templated path {@link import('./factories.js').createTerminalRoutes}
|
|
@@ -14,6 +15,31 @@ var TERMINAL_ROUTES_PATH = "/terminals/:name";
|
|
|
14
15
|
* keeping intermediary proxies from timing out an otherwise-idle stream.
|
|
15
16
|
*/
|
|
16
17
|
var TERMINAL_KEEPALIVE_MS = 15e3;
|
|
18
|
+
// src/server/shapers.ts
|
|
19
|
+
/**
|
|
20
|
+
* Describes the body a terminal POST route admits — the pending form's `id` and its answers.
|
|
21
|
+
*
|
|
22
|
+
* @remarks
|
|
23
|
+
* The shape closes the envelope. `@orkestrel/form`'s `isFormValues` guard still owns what one answer
|
|
24
|
+
* holds, so this shape does not restate the field value union.
|
|
25
|
+
*
|
|
26
|
+
* @example
|
|
27
|
+
* ```ts
|
|
28
|
+
* import { createContract } from '@orkestrel/contract'
|
|
29
|
+
* import { terminalAnswerShape } from '@src/server'
|
|
30
|
+
*
|
|
31
|
+
* const contract = createContract(terminalAnswerShape)
|
|
32
|
+
* contract.is({ id: 'form-1', values: { name: 'Ada' } }) // true
|
|
33
|
+
* contract.audit({ id: 'form-1', values: {}, reply: true }) // [{ reason: 'extra', path: ['reply'] }]
|
|
34
|
+
* ```
|
|
35
|
+
*/
|
|
36
|
+
var terminalAnswerShape = objectShape({
|
|
37
|
+
id: stringShape({
|
|
38
|
+
min: 1,
|
|
39
|
+
description: "The id of the pending form to answer."
|
|
40
|
+
}),
|
|
41
|
+
values: recordShape(jsonShape(), { description: "The form answers keyed by field name." })
|
|
42
|
+
});
|
|
17
43
|
// src/server/terminals/TerminalConnection.ts
|
|
18
44
|
/**
|
|
19
45
|
* Owns one terminal SSE connection's replay, subscriptions, keepalive, and teardown.
|
|
@@ -141,6 +167,7 @@ var TerminalBridge = class {
|
|
|
141
167
|
#keepalive;
|
|
142
168
|
#timer;
|
|
143
169
|
#limit;
|
|
170
|
+
#answer;
|
|
144
171
|
#accepts;
|
|
145
172
|
#get;
|
|
146
173
|
#post;
|
|
@@ -148,16 +175,22 @@ var TerminalBridge = class {
|
|
|
148
175
|
* Creates a terminal bridge.
|
|
149
176
|
*
|
|
150
177
|
* @param manager - Terminal manager bridged onto HTTP
|
|
151
|
-
* @param options -
|
|
178
|
+
* @param options - Required token, plus shared route, keepalive, timer, and body-limit options
|
|
179
|
+
* @throws {ToolboxError} Thrown with code `TOOL` for a missing or malformed `token`, a `keepalive`
|
|
180
|
+
* that is not a positive finite number, or a `limit` that is not a positive safe integer
|
|
152
181
|
*/
|
|
153
182
|
constructor(manager, options) {
|
|
183
|
+
const token = options?.token;
|
|
184
|
+
if (!isNonEmptyString(token) && !isFunction(token)) throw new ToolboxError("TOOL", `terminal routes need a token; received ${preview(token)}; pass token: 'SECRET' to compare the ${HEADER_TOKEN} header, a function to decide each value, or token: () => true to leave the routes open`);
|
|
185
|
+
const keepalive = options.keepalive ?? 15e3;
|
|
186
|
+
if (!isFiniteNumber(keepalive) || keepalive <= 0) throw new ToolboxError("TOOL", `terminal keepalive must be a positive finite number of milliseconds; received ${preview(keepalive)}; omit it for the default ${String(TERMINAL_KEEPALIVE_MS)}`);
|
|
154
187
|
this.#manager = manager;
|
|
155
|
-
this.#path = options
|
|
156
|
-
this.#token =
|
|
157
|
-
this.#keepalive =
|
|
158
|
-
this.#timer = options
|
|
159
|
-
|
|
160
|
-
this.#
|
|
188
|
+
this.#path = options.path ?? "/terminals/:name";
|
|
189
|
+
this.#token = token;
|
|
190
|
+
this.#keepalive = keepalive;
|
|
191
|
+
this.#timer = options.timer ?? defaultTimer;
|
|
192
|
+
this.#limit = resolveBound(options.limit, DEFAULT_BODY_LIMIT, "terminal body limit");
|
|
193
|
+
this.#answer = createContract(terminalAnswerShape);
|
|
161
194
|
this.#accepts = this.#valid.bind(this);
|
|
162
195
|
this.#get = this.#handleGet.bind(this);
|
|
163
196
|
this.#post = this.#handlePost.bind(this);
|
|
@@ -179,7 +212,6 @@ var TerminalBridge = class {
|
|
|
179
212
|
}];
|
|
180
213
|
}
|
|
181
214
|
#valid(presented) {
|
|
182
|
-
if (this.#token === void 0) return true;
|
|
183
215
|
try {
|
|
184
216
|
return typeof this.#token === "function" ? this.#token(presented) : presented === this.#token;
|
|
185
217
|
} catch {
|
|
@@ -201,18 +233,35 @@ var TerminalBridge = class {
|
|
|
201
233
|
if (name === void 0 || this.#manager.terminal(name) === void 0) return new Response(null, { status: 404 });
|
|
202
234
|
const bytes = await this.#readBody(request);
|
|
203
235
|
if (bytes instanceof Response) return bytes;
|
|
204
|
-
if (bytes.byteLength > 0 && bytes.byteLength > this.#limit) return new Response(null, { status: 413 });
|
|
205
236
|
const body = parseJSON(new TextDecoder().decode(bytes));
|
|
206
237
|
if (body === void 0) return new Response(null, { status: 400 });
|
|
207
|
-
if (!
|
|
208
|
-
const
|
|
238
|
+
if (!this.#answer.is(body)) return this.#refuse(this.#answer.audit(body));
|
|
239
|
+
const { id, values } = body;
|
|
240
|
+
if (!isFormValues(values)) return this.#refuse(Object.entries(values).filter(([, value]) => !isFieldValue(value)).map(([key, value]) => ({
|
|
241
|
+
reason: "type",
|
|
242
|
+
path: ["values", key],
|
|
243
|
+
expected: "union",
|
|
244
|
+
received: preview(value)
|
|
245
|
+
})));
|
|
246
|
+
const result = this.#manager.answer(name, id, values);
|
|
209
247
|
if (result.success) return Response.json(result);
|
|
210
248
|
if (result.error.reason === "target") return Response.json(result, { status: 404 });
|
|
211
249
|
return Response.json(result, { status: 422 });
|
|
212
250
|
}
|
|
251
|
+
#refuse(faults) {
|
|
252
|
+
const message = limitRefusal(`an answer body is { id, values }, where values maps each field name to a string, a finite number, a boolean, or a list of strings; correct each line and send it again:\n${describeFaults(faults, this.#answer.schema)}`);
|
|
253
|
+
return Response.json({
|
|
254
|
+
success: false,
|
|
255
|
+
error: {
|
|
256
|
+
reason: "malformed",
|
|
257
|
+
message,
|
|
258
|
+
faults
|
|
259
|
+
}
|
|
260
|
+
}, { status: 422 });
|
|
261
|
+
}
|
|
213
262
|
async #readBody(request) {
|
|
214
263
|
try {
|
|
215
|
-
return await collectRequestBody(request,
|
|
264
|
+
return await collectRequestBody(request, this.#limit);
|
|
216
265
|
} catch (error) {
|
|
217
266
|
if (isInstance(error, ContentTooLargeError)) return new Response(null, { status: 413 });
|
|
218
267
|
throw error;
|
|
@@ -228,13 +277,18 @@ var TerminalBridge = class {
|
|
|
228
277
|
* streams, the same POST url answers, the same `{ id, values }` request body, the same JSON answer
|
|
229
278
|
* `Result` response body, and the same `x-orkestrel-token` header.
|
|
230
279
|
*
|
|
231
|
-
* Both routes share the configured `:name` path and
|
|
280
|
+
* Both routes share the configured `:name` path and the required token gate. The GET route replays
|
|
232
281
|
* pending prompts, forwards live pending/expire events, and owns abort/keepalive teardown. The
|
|
233
|
-
* POST route bounds the request body before parsing
|
|
282
|
+
* POST route bounds the request body before parsing, refuses a body outside
|
|
283
|
+
* {@link import('./shapers.js').terminalAnswerShape} with its faults, and maps answer outcomes to
|
|
284
|
+
* HTTP statuses.
|
|
234
285
|
*
|
|
235
286
|
* @param manager - The terminal manager whose endpoints are bridged
|
|
236
|
-
* @param options -
|
|
287
|
+
* @param options - The required token, plus route path, keepalive, timer, and body-limit options
|
|
237
288
|
* @returns The GET route followed by the POST route
|
|
289
|
+
* @throws {import('@src/core').ToolboxError} Thrown with code `TOOL` for a missing or malformed
|
|
290
|
+
* `token`, a `keepalive` that is not a positive finite number, or a `limit` that is not a positive
|
|
291
|
+
* safe integer; `token: () => true` is the explicit open
|
|
238
292
|
*
|
|
239
293
|
* @example
|
|
240
294
|
* ```ts
|
|
@@ -249,6 +303,6 @@ var TerminalBridge = class {
|
|
|
249
303
|
function createTerminalRoutes(manager, options) {
|
|
250
304
|
return new TerminalBridge(manager, options).routes();
|
|
251
305
|
}
|
|
252
|
-
export { TERMINAL_KEEPALIVE_MS, TERMINAL_ROUTES_PATH, createTerminalRoutes };
|
|
306
|
+
export { TERMINAL_KEEPALIVE_MS, TERMINAL_ROUTES_PATH, createTerminalRoutes, terminalAnswerShape };
|
|
253
307
|
|
|
254
308
|
//# sourceMappingURL=index.js.map
|