@jini-ai/http-kit 0.2.1
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/LICENSE +201 -0
- package/README.md +107 -0
- package/dist/active-context.d.ts +99 -0
- package/dist/active-context.d.ts.map +1 -0
- package/dist/active-context.js +77 -0
- package/dist/active-context.js.map +1 -0
- package/dist/adapter.d.ts +36 -0
- package/dist/adapter.d.ts.map +1 -0
- package/dist/adapter.js +92 -0
- package/dist/adapter.js.map +1 -0
- package/dist/agents.d.ts +55 -0
- package/dist/agents.d.ts.map +1 -0
- package/dist/agents.js +23 -0
- package/dist/agents.js.map +1 -0
- package/dist/api-security-middleware.d.ts +146 -0
- package/dist/api-security-middleware.d.ts.map +1 -0
- package/dist/api-security-middleware.js +276 -0
- package/dist/api-security-middleware.js.map +1 -0
- package/dist/attachments.d.ts +317 -0
- package/dist/attachments.d.ts.map +1 -0
- package/dist/attachments.js +664 -0
- package/dist/attachments.js.map +1 -0
- package/dist/cancel-owned-runs.d.ts +44 -0
- package/dist/cancel-owned-runs.d.ts.map +1 -0
- package/dist/cancel-owned-runs.js +35 -0
- package/dist/cancel-owned-runs.js.map +1 -0
- package/dist/compat.d.ts +22 -0
- package/dist/compat.d.ts.map +1 -0
- package/dist/compat.js +17 -0
- package/dist/compat.js.map +1 -0
- package/dist/component-catalog.d.ts +74 -0
- package/dist/component-catalog.d.ts.map +1 -0
- package/dist/component-catalog.js +74 -0
- package/dist/component-catalog.js.map +1 -0
- package/dist/connectors.d.ts +165 -0
- package/dist/connectors.d.ts.map +1 -0
- package/dist/connectors.js +474 -0
- package/dist/connectors.js.map +1 -0
- package/dist/daemon-status.d.ts +72 -0
- package/dist/daemon-status.d.ts.map +1 -0
- package/dist/daemon-status.js +60 -0
- package/dist/daemon-status.js.map +1 -0
- package/dist/db-ops.d.ts +116 -0
- package/dist/db-ops.d.ts.map +1 -0
- package/dist/db-ops.js +191 -0
- package/dist/db-ops.js.map +1 -0
- package/dist/delegated-tools.d.ts +46 -0
- package/dist/delegated-tools.d.ts.map +1 -0
- package/dist/delegated-tools.js +155 -0
- package/dist/delegated-tools.js.map +1 -0
- package/dist/express/run-stream.d.ts +15 -0
- package/dist/express/run-stream.d.ts.map +1 -0
- package/dist/express/run-stream.js +11 -0
- package/dist/express/run-stream.js.map +1 -0
- package/dist/express-index.d.ts +32 -0
- package/dist/express-index.d.ts.map +1 -0
- package/dist/express-index.js +14 -0
- package/dist/express-index.js.map +1 -0
- package/dist/fastify/adapter.d.ts +31 -0
- package/dist/fastify/adapter.d.ts.map +1 -0
- package/dist/fastify/adapter.js +64 -0
- package/dist/fastify/adapter.js.map +1 -0
- package/dist/fastify/agents.d.ts +13 -0
- package/dist/fastify/agents.d.ts.map +1 -0
- package/dist/fastify/agents.js +7 -0
- package/dist/fastify/agents.js.map +1 -0
- package/dist/fastify/api-security-middleware.d.ts +64 -0
- package/dist/fastify/api-security-middleware.d.ts.map +1 -0
- package/dist/fastify/api-security-middleware.js +139 -0
- package/dist/fastify/api-security-middleware.js.map +1 -0
- package/dist/fastify/compat.d.ts +22 -0
- package/dist/fastify/compat.d.ts.map +1 -0
- package/dist/fastify/compat.js +16 -0
- package/dist/fastify/compat.js.map +1 -0
- package/dist/fastify/daemon-status.d.ts +22 -0
- package/dist/fastify/daemon-status.d.ts.map +1 -0
- package/dist/fastify/daemon-status.js +9 -0
- package/dist/fastify/daemon-status.js.map +1 -0
- package/dist/fastify/host-tools.d.ts +13 -0
- package/dist/fastify/host-tools.d.ts.map +1 -0
- package/dist/fastify/host-tools.js +8 -0
- package/dist/fastify/host-tools.js.map +1 -0
- package/dist/fastify/index.d.ts +36 -0
- package/dist/fastify/index.d.ts.map +1 -0
- package/dist/fastify/index.js +18 -0
- package/dist/fastify/index.js.map +1 -0
- package/dist/fastify/local-daemon-request.d.ts +43 -0
- package/dist/fastify/local-daemon-request.d.ts.map +1 -0
- package/dist/fastify/local-daemon-request.js +155 -0
- package/dist/fastify/local-daemon-request.js.map +1 -0
- package/dist/fastify/origin.d.ts +21 -0
- package/dist/fastify/origin.d.ts.map +1 -0
- package/dist/fastify/origin.js +14 -0
- package/dist/fastify/origin.js.map +1 -0
- package/dist/fastify/request.d.ts +20 -0
- package/dist/fastify/request.d.ts.map +1 -0
- package/dist/fastify/request.js +25 -0
- package/dist/fastify/request.js.map +1 -0
- package/dist/fastify/response.d.ts +20 -0
- package/dist/fastify/response.d.ts.map +1 -0
- package/dist/fastify/response.js +41 -0
- package/dist/fastify/response.js.map +1 -0
- package/dist/fastify/route-registration-guard.d.ts +70 -0
- package/dist/fastify/route-registration-guard.d.ts.map +1 -0
- package/dist/fastify/route-registration-guard.js +69 -0
- package/dist/fastify/route-registration-guard.js.map +1 -0
- package/dist/fastify/run-stream.d.ts +18 -0
- package/dist/fastify/run-stream.d.ts.map +1 -0
- package/dist/fastify/run-stream.js +10 -0
- package/dist/fastify/run-stream.js.map +1 -0
- package/dist/fastify/runs.d.ts +17 -0
- package/dist/fastify/runs.d.ts.map +1 -0
- package/dist/fastify/runs.js +33 -0
- package/dist/fastify/runs.js.map +1 -0
- package/dist/frontend-control.d.ts +131 -0
- package/dist/frontend-control.d.ts.map +1 -0
- package/dist/frontend-control.js +77 -0
- package/dist/frontend-control.js.map +1 -0
- package/dist/frontend-sessions.d.ts +100 -0
- package/dist/frontend-sessions.d.ts.map +1 -0
- package/dist/frontend-sessions.js +180 -0
- package/dist/frontend-sessions.js.map +1 -0
- package/dist/health.d.ts +91 -0
- package/dist/health.d.ts.map +1 -0
- package/dist/health.js +53 -0
- package/dist/health.js.map +1 -0
- package/dist/host-tools.d.ts +174 -0
- package/dist/host-tools.d.ts.map +1 -0
- package/dist/host-tools.js +338 -0
- package/dist/host-tools.js.map +1 -0
- package/dist/index.d.ts +86 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +40 -0
- package/dist/index.js.map +1 -0
- package/dist/local-daemon-request.d.ts +44 -0
- package/dist/local-daemon-request.d.ts.map +1 -0
- package/dist/local-daemon-request.js +145 -0
- package/dist/local-daemon-request.js.map +1 -0
- package/dist/media.d.ts +131 -0
- package/dist/media.d.ts.map +1 -0
- package/dist/media.js +352 -0
- package/dist/media.js.map +1 -0
- package/dist/memory.d.ts +230 -0
- package/dist/memory.d.ts.map +1 -0
- package/dist/memory.js +331 -0
- package/dist/memory.js.map +1 -0
- package/dist/model-proxy.d.ts +45 -0
- package/dist/model-proxy.d.ts.map +1 -0
- package/dist/model-proxy.js +619 -0
- package/dist/model-proxy.js.map +1 -0
- package/dist/origin-validation.d.ts +64 -0
- package/dist/origin-validation.d.ts.map +1 -0
- package/dist/origin-validation.js +226 -0
- package/dist/origin-validation.js.map +1 -0
- package/dist/origin.d.ts +32 -0
- package/dist/origin.d.ts.map +1 -0
- package/dist/origin.js +14 -0
- package/dist/origin.js.map +1 -0
- package/dist/pack-http.d.ts +30 -0
- package/dist/pack-http.d.ts.map +1 -0
- package/dist/pack-http.js +12 -0
- package/dist/pack-http.js.map +1 -0
- package/dist/raw-sse.d.ts +44 -0
- package/dist/raw-sse.d.ts.map +1 -0
- package/dist/raw-sse.js +93 -0
- package/dist/raw-sse.js.map +1 -0
- package/dist/remote-run-events.d.ts +66 -0
- package/dist/remote-run-events.d.ts.map +1 -0
- package/dist/remote-run-events.js +230 -0
- package/dist/remote-run-events.js.map +1 -0
- package/dist/request.d.ts +19 -0
- package/dist/request.d.ts.map +1 -0
- package/dist/request.js +25 -0
- package/dist/request.js.map +1 -0
- package/dist/research.d.ts +42 -0
- package/dist/research.d.ts.map +1 -0
- package/dist/research.js +316 -0
- package/dist/research.js.map +1 -0
- package/dist/response.d.ts +14 -0
- package/dist/response.d.ts.map +1 -0
- package/dist/response.js +45 -0
- package/dist/response.js.map +1 -0
- package/dist/route-manifest.d.ts +31 -0
- package/dist/route-manifest.d.ts.map +1 -0
- package/dist/route-manifest.js +103 -0
- package/dist/route-manifest.js.map +1 -0
- package/dist/route-registration-guard.d.ts +65 -0
- package/dist/route-registration-guard.d.ts.map +1 -0
- package/dist/route-registration-guard.js +71 -0
- package/dist/route-registration-guard.js.map +1 -0
- package/dist/routines.d.ts +114 -0
- package/dist/routines.d.ts.map +1 -0
- package/dist/routines.js +320 -0
- package/dist/routines.js.map +1 -0
- package/dist/run-stream.d.ts +60 -0
- package/dist/run-stream.d.ts.map +1 -0
- package/dist/run-stream.js +108 -0
- package/dist/run-stream.js.map +1 -0
- package/dist/runs.d.ts +79 -0
- package/dist/runs.d.ts.map +1 -0
- package/dist/runs.js +232 -0
- package/dist/runs.js.map +1 -0
- package/dist/sse.d.ts +139 -0
- package/dist/sse.d.ts.map +1 -0
- package/dist/sse.js +157 -0
- package/dist/sse.js.map +1 -0
- package/dist/terminals.d.ts +125 -0
- package/dist/terminals.d.ts.map +1 -0
- package/dist/terminals.js +349 -0
- package/dist/terminals.js.map +1 -0
- package/dist/tool-catalog.d.ts +68 -0
- package/dist/tool-catalog.d.ts.map +1 -0
- package/dist/tool-catalog.js +68 -0
- package/dist/tool-catalog.js.map +1 -0
- package/dist/types.d.ts +58 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +5 -0
- package/dist/types.js.map +1 -0
- package/dist/workspace-root.d.ts +69 -0
- package/dist/workspace-root.d.ts.map +1 -0
- package/dist/workspace-root.js +63 -0
- package/dist/workspace-root.js.map +1 -0
- package/dist/xai.d.ts +86 -0
- package/dist/xai.d.ts.map +1 -0
- package/dist/xai.js +668 -0
- package/dist/xai.js.map +1 -0
- package/package.json +58 -0
package/dist/sse.js
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { createApiErrorResponse } from '@jini-ai/protocol';
|
|
2
|
+
/**
|
|
3
|
+
* Cap on events queued for one client — covers both a pre-header replay
|
|
4
|
+
* burst and any backlog built up while `res.write` reports backpressure
|
|
5
|
+
* (`write() === false`, awaiting `'drain'`). A stalled or malicious client
|
|
6
|
+
* would otherwise let an unbounded producer grow this array without limit
|
|
7
|
+
* (SEC-006, carried forward from `runs.ts`'s original constant). Once
|
|
8
|
+
* exceeded, the connection is dropped rather than accepting unbounded
|
|
9
|
+
* memory growth.
|
|
10
|
+
*/
|
|
11
|
+
export const DEFAULT_MAX_QUEUED_SSE_EVENTS = 1000;
|
|
12
|
+
/** Default wire format: `id: <cursor>\nevent: <kind>\ndata: <json>\n\n`, matching the SSE spec's field syntax. */
|
|
13
|
+
function defaultFormatEvent(event) {
|
|
14
|
+
return `id: ${event.opaqueCursor}\nevent: ${event.kind}\ndata: ${JSON.stringify(event)}\n\n`;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Builds a bounded, backpressure-aware SSE channel over an Express `Response`.
|
|
18
|
+
* The channel does not know where events come from — a route wires its own
|
|
19
|
+
* event source (a `RunLifecycle.stream` subscription, an `EventEmitter`
|
|
20
|
+
* fan-in, a PTY's `onData`, …) to {@link SseChannel.enqueue} and lets the
|
|
21
|
+
* channel own delivery, ordering, and backpressure.
|
|
22
|
+
*
|
|
23
|
+
* The response's own `'close'` event (the client disconnecting) is observed
|
|
24
|
+
* from construction time, before {@link SseChannel.open} is ever called —
|
|
25
|
+
* this lets a caller `await` some asynchronous subscribe step (as `runs.ts`
|
|
26
|
+
* does against `RunLifecycle.stream`) and still detect "the client already
|
|
27
|
+
* disappeared while we were waiting" via {@link SseChannel.isClosed} once
|
|
28
|
+
* that await resolves, without the disconnect handler itself trying to
|
|
29
|
+
* re-end an already-gone response (see {@link SseChannel.end}'s doc for why
|
|
30
|
+
* that distinction doesn't actually need special-casing).
|
|
31
|
+
*
|
|
32
|
+
* @param res - The response to stream over, typed against the raw `node:http` `ServerResponse`
|
|
33
|
+
* rather than Express's own `Response` — Express's `Response` extends `ServerResponse` directly,
|
|
34
|
+
* so every existing Express caller still satisfies this signature unchanged. Never read from — only
|
|
35
|
+
* `write`/`statusCode`/`setHeader`/`flushHeaders`/`end`/`on('close'|'drain')` are used, all of
|
|
36
|
+
* which exist identically on the raw type (the one genuinely Express-specific call this function
|
|
37
|
+
* used to make, `res.status(200)`, is `res.statusCode = 200` here instead — the same assignment
|
|
38
|
+
* Express's own `.status()` performs internally).
|
|
39
|
+
* @param options - See {@link CreateSseChannelOptions}.
|
|
40
|
+
* @complexity `enqueue`/`open`/`end` are O(1) amortized; a full queue drain is O(events written).
|
|
41
|
+
* @overallScore 100/100
|
|
42
|
+
*/
|
|
43
|
+
export function createSseChannel(res, options = {}) {
|
|
44
|
+
const maxQueuedEvents = options.maxQueuedEvents ?? DEFAULT_MAX_QUEUED_SSE_EVENTS;
|
|
45
|
+
const formatEvent = options.formatEvent ?? defaultFormatEvent;
|
|
46
|
+
const queue = [];
|
|
47
|
+
let flowing = false;
|
|
48
|
+
let writable = true;
|
|
49
|
+
let closed = false;
|
|
50
|
+
const closeCallbacks = [];
|
|
51
|
+
const markClosed = () => {
|
|
52
|
+
if (closed)
|
|
53
|
+
return;
|
|
54
|
+
closed = true;
|
|
55
|
+
// Copy first: a callback that itself calls `onClose` during this loop must not be invoked
|
|
56
|
+
// twice or mutate the array being iterated.
|
|
57
|
+
const callbacks = closeCallbacks.splice(0, closeCallbacks.length);
|
|
58
|
+
for (const callback of callbacks)
|
|
59
|
+
callback();
|
|
60
|
+
};
|
|
61
|
+
const end = () => {
|
|
62
|
+
markClosed();
|
|
63
|
+
if (!res.writableEnded)
|
|
64
|
+
res.end();
|
|
65
|
+
};
|
|
66
|
+
const pump = () => {
|
|
67
|
+
if (!flowing || closed)
|
|
68
|
+
return;
|
|
69
|
+
while (writable && queue.length > 0) {
|
|
70
|
+
const event = queue.shift();
|
|
71
|
+
let wroteOk;
|
|
72
|
+
try {
|
|
73
|
+
wroteOk = res.write(formatEvent(event));
|
|
74
|
+
}
|
|
75
|
+
catch (error) {
|
|
76
|
+
// A dead/broken transport must never throw back through whatever is producing events —
|
|
77
|
+
// stop this channel only.
|
|
78
|
+
options.onWriteError?.(error);
|
|
79
|
+
end();
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
if (wroteOk === false)
|
|
83
|
+
writable = false;
|
|
84
|
+
if (options.isEndEvent?.(event)) {
|
|
85
|
+
end();
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
const enqueue = (event) => {
|
|
91
|
+
if (closed)
|
|
92
|
+
return;
|
|
93
|
+
if (queue.length >= maxQueuedEvents) {
|
|
94
|
+
// Slow/stalled consumer — disconnect rather than grow memory without bound.
|
|
95
|
+
end();
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
queue.push(event);
|
|
99
|
+
pump();
|
|
100
|
+
};
|
|
101
|
+
const open = () => {
|
|
102
|
+
if (closed || flowing)
|
|
103
|
+
return;
|
|
104
|
+
res.statusCode = 200;
|
|
105
|
+
res.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
|
|
106
|
+
res.setHeader('Cache-Control', 'no-cache, no-transform');
|
|
107
|
+
res.setHeader('Connection', 'keep-alive');
|
|
108
|
+
res.flushHeaders();
|
|
109
|
+
flowing = true;
|
|
110
|
+
pump();
|
|
111
|
+
};
|
|
112
|
+
res.on('drain', () => {
|
|
113
|
+
writable = true;
|
|
114
|
+
pump();
|
|
115
|
+
});
|
|
116
|
+
// The client disconnecting only marks the channel closed — it must not itself call `res.end()`
|
|
117
|
+
// (see `end()`'s doc for why calling it a second time would be safe anyway, but a route that
|
|
118
|
+
// still wants to send a *non-SSE* response — e.g. a JSON error — after discovering a subscribe
|
|
119
|
+
// failed needs `isClosed()` to be observable without the response having been touched yet).
|
|
120
|
+
res.on('close', markClosed);
|
|
121
|
+
const onClose = (callback) => {
|
|
122
|
+
if (closed) {
|
|
123
|
+
callback();
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
closeCallbacks.push(callback);
|
|
127
|
+
};
|
|
128
|
+
return {
|
|
129
|
+
enqueue,
|
|
130
|
+
open,
|
|
131
|
+
isClosed: () => closed,
|
|
132
|
+
end,
|
|
133
|
+
abandon: markClosed,
|
|
134
|
+
onClose,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
/** Reads a reconnect cursor from the standard `Last-Event-ID` header, falling back to an `afterCursor` query parameter. Shared by any SSE route that supports reconnect replay (the header takes precedence since it is what browsers set automatically on `EventSource` reconnect). */
|
|
138
|
+
export function requestedAfterCursor(req) {
|
|
139
|
+
const header = req.get('last-event-id');
|
|
140
|
+
if (header && header.length > 0)
|
|
141
|
+
return header;
|
|
142
|
+
const query = req.query.afterCursor;
|
|
143
|
+
return typeof query === 'string' && query.length > 0 ? query : null;
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Writes an `ApiError`, wrapped in the standard `{ error }` envelope, directly onto a raw
|
|
147
|
+
* `ServerResponse` — for the narrow window before an `SseChannel` has opened (so Express's own
|
|
148
|
+
* `res.json()` response wrapper isn't safe to use yet). Produces the identical
|
|
149
|
+
* `createApiErrorResponse(error)` envelope shape `response.ts`'s `sendApiError`/`sendJson` do —
|
|
150
|
+
* this just writes that same shape without going through Express's response helper.
|
|
151
|
+
*/
|
|
152
|
+
export function sendRawApiError(res, status, error) {
|
|
153
|
+
res.statusCode = status;
|
|
154
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
155
|
+
res.end(JSON.stringify(createApiErrorResponse(error)));
|
|
156
|
+
}
|
|
157
|
+
//# sourceMappingURL=sse.js.map
|
package/dist/sse.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sse.js","sourceRoot":"","sources":["../src/sse.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import type { Express } from 'express';
|
|
2
|
+
import type { Principal } from '@jini-ai/core';
|
|
3
|
+
import type { TerminalSessionActionResult, TerminalSessionInfo, TerminalSessionManager } from '@jini-ai/daemon';
|
|
4
|
+
import type { ToolExecutor } from '@jini-ai/daemon';
|
|
5
|
+
import { type AdapterContext } from './adapter.js';
|
|
6
|
+
import { type WorkspaceRootResolver } from './workspace-root.js';
|
|
7
|
+
import { type Result } from './types.js';
|
|
8
|
+
/** Diagnostic detail for an internal-error response the public API deliberately does not disclose (SEC-005, matching `runs.ts`/`db-ops.ts`): a spawn failure (e.g. a missing/uncompiled native addon) can embed executable paths or host detail. */
|
|
9
|
+
export interface TerminalsInternalErrorContext {
|
|
10
|
+
readonly source: 'terminal-create';
|
|
11
|
+
readonly correlationId: string;
|
|
12
|
+
readonly error: unknown;
|
|
13
|
+
}
|
|
14
|
+
/** Everything this route pack needs from the host. */
|
|
15
|
+
export interface TerminalsHttpDeps {
|
|
16
|
+
readonly manager: TerminalSessionManager;
|
|
17
|
+
readonly toolExecutor: ToolExecutor;
|
|
18
|
+
/** The identity these routes act as — same fixed, host-supplied-value shape as `db-ops.ts`'s `DaemonDbHttpDeps.principal` (this transport has no session/identity subsystem of its own). */
|
|
19
|
+
readonly principal: Principal;
|
|
20
|
+
/** Resolves a `resourceRef` to a spawn working directory. Defaults to {@link denyAllWorkspaceRoots} — a host that never wires a real resolver gets a 404 on every create call, never a guessed path (see `workspace-root.ts`). */
|
|
21
|
+
readonly resolveRoot?: WorkspaceRootResolver;
|
|
22
|
+
/** Host-owned sink for the real exception behind a generic `INTERNAL_ERROR` response (SEC-005). Defaults to `console.error`. */
|
|
23
|
+
readonly onInternalError?: (context: TerminalsInternalErrorContext) => void;
|
|
24
|
+
}
|
|
25
|
+
export interface TerminalCreateRequest {
|
|
26
|
+
readonly resourceRef: string;
|
|
27
|
+
readonly detail?: string;
|
|
28
|
+
readonly cols?: number;
|
|
29
|
+
readonly rows?: number;
|
|
30
|
+
readonly shell?: string;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* `POST /api/terminals` — the one gated call (see module doc). Resolves
|
|
34
|
+
* `resourceRef` to a working directory the same way `host-tools.ts`'s
|
|
35
|
+
* open-in route does, then authorizes+spawns the session through
|
|
36
|
+
* `ToolExecutor.execute(..., 'terminal.create', ...)`.
|
|
37
|
+
*/
|
|
38
|
+
export declare const terminalCreateRoute: import("./types.js").JsonRouteSpec<TerminalCreateRequest, TerminalSessionInfo, TerminalsHttpDeps>;
|
|
39
|
+
export interface TerminalListResponse {
|
|
40
|
+
readonly terminals: readonly TerminalSessionInfo[];
|
|
41
|
+
}
|
|
42
|
+
/** `GET /api/terminals` — sessions the calling principal owns, optionally narrowed by `resourceRef`. Never gated through `ToolExecutor` — a read scoped to the caller's own sessions, matching `runs.ts`'s `runListRoute` precedent. */
|
|
43
|
+
export declare const terminalListRoute: import("./types.js").JsonRouteSpec<{
|
|
44
|
+
resourceRef?: string;
|
|
45
|
+
}, TerminalListResponse, TerminalsHttpDeps>;
|
|
46
|
+
export interface TerminalActionResponse {
|
|
47
|
+
readonly ok: boolean;
|
|
48
|
+
readonly terminal?: TerminalSessionInfo;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Exported (not just internal) so the `result.session === null` branch — a real race
|
|
52
|
+
* (`write`/`resize`/`kill` finding metadata for an id whose underlying session was concurrently
|
|
53
|
+
* killed between the ownership check and the critical section, per `@jini-ai/daemon`'s
|
|
54
|
+
* `terminal-session.ts` `currentSnapshot`) — is directly unit-testable against a synthetic
|
|
55
|
+
* `TerminalSessionActionResult` rather than requiring a real, hard-to-deterministically-force race
|
|
56
|
+
* through the full `TerminalSessionManager`. Matches this file's own `createDeferredEndGate`
|
|
57
|
+
* precedent for the same "extract into a directly-testable pure function" shape.
|
|
58
|
+
*/
|
|
59
|
+
export declare function actionResultToApiResult(result: TerminalSessionActionResult, notFoundMessage: string): Result<TerminalActionResponse>;
|
|
60
|
+
/** `POST /api/terminals/:id/stdin` — writes `data` to the session's pty. Ownership-checked by `deps.manager.write` directly, not re-gated through `ToolExecutor` (see module doc). */
|
|
61
|
+
export declare const terminalStdinRoute: import("./types.js").JsonRouteSpec<{
|
|
62
|
+
id: string;
|
|
63
|
+
data: string;
|
|
64
|
+
}, TerminalActionResponse, TerminalsHttpDeps>;
|
|
65
|
+
/** `POST /api/terminals/:id/resize` — resizes the session's pty. */
|
|
66
|
+
export declare const terminalResizeRoute: import("./types.js").JsonRouteSpec<{
|
|
67
|
+
id: string;
|
|
68
|
+
cols: number;
|
|
69
|
+
rows: number;
|
|
70
|
+
}, TerminalActionResponse, TerminalsHttpDeps>;
|
|
71
|
+
/** `POST /api/terminals/:id/kill` — sends `SIGTERM` to the session's pty. */
|
|
72
|
+
export declare const terminalKillRoute: import("./types.js").JsonRouteSpec<string, TerminalActionResponse, TerminalsHttpDeps>;
|
|
73
|
+
/** `DELETE /api/terminals/:id` — alias for `kill`, matching OD's dual `POST .../kill` / `DELETE` routes. */
|
|
74
|
+
export declare const terminalDeleteRoute: import("./types.js").JsonRouteSpec<string, TerminalActionResponse, TerminalsHttpDeps>;
|
|
75
|
+
/** The one method {@link createDeferredEndGate} needs from `sse.ts`'s channel — narrowed so the gate is unit-testable against a bare fake, not a real `SseChannel`. */
|
|
76
|
+
interface DeferredEndChannel {
|
|
77
|
+
end(): void;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* The `channelOpened`/`endRequestedBeforeOpen` state machine `registerTerminalEventStream`'s
|
|
81
|
+
* `TerminalSseSink.end()` needs, extracted into its own directly-testable unit — see that
|
|
82
|
+
* function's inline comment for *why* the deferral exists (an already-exited session's replay
|
|
83
|
+
* path can call `end()` before `open()` has ever run).
|
|
84
|
+
*
|
|
85
|
+
* **On the `markOpened()`-then-`end()` ordering (opened=true when `end()` runs):** this is the
|
|
86
|
+
* ordering every *currently reachable* production call graph in this repo takes for a live
|
|
87
|
+
* session that exits while already streaming — but it is unreachable through that graph, not
|
|
88
|
+
* unreachable in principle. `@jini-ai/platform`'s `TerminalService.finish()` (`packages/platform/src/
|
|
89
|
+
* terminal.ts`) calls `sink.send('exit', ...)` immediately before `sink.end()` for every live
|
|
90
|
+
* client; `send()` here forwards straight to `channel.enqueue()`, whose `isEndEvent: (e) => e.kind
|
|
91
|
+
* === 'exit'` match auto-closes the channel *synchronously inside that same `enqueue()` call* —
|
|
92
|
+
* which synchronously runs `registerTerminalEventStream`'s own `channel.onClose` callback, which
|
|
93
|
+
* detaches this sink from `TerminalService`'s `session.clients` before `finish()`'s own, separate
|
|
94
|
+
* `sink.end()` loop ever runs. That loop iterates `session.clients` fresh at call time (not a
|
|
95
|
+
* snapshot taken before `emit()`), so by the time it runs, this sink has already removed itself —
|
|
96
|
+
* `sink.end()` is consequently never invoked while `channelOpened` is `true` via any call graph
|
|
97
|
+
* this repo's own `@jini-ai/platform` + `@jini-ai/http-kit` composition can currently produce (confirmed by
|
|
98
|
+
* instrumenting the real call path directly, not inferred from reading the source — a synchronous
|
|
99
|
+
* console probe in `end()` never fired across this file's full "live-exit-while-streaming" test).
|
|
100
|
+
* That is a fact about today's specific `TerminalService`/`SseChannel` wiring, not a proof that no
|
|
101
|
+
* future caller of `TerminalSseSink` could ever call `end()` post-open (a hypothetical non-SSE
|
|
102
|
+
* sink, or a future `TerminalService` exit reason not preceded by a matching `send()`, would hit
|
|
103
|
+
* exactly this branch) — so the branch stays as real, intentional defensive code, tested directly
|
|
104
|
+
* here rather than deleted for being unreachable *today* or left silently uncovered.
|
|
105
|
+
*/
|
|
106
|
+
export declare function createDeferredEndGate(channel: DeferredEndChannel): {
|
|
107
|
+
markOpened(): void;
|
|
108
|
+
end(): void;
|
|
109
|
+
};
|
|
110
|
+
/**
|
|
111
|
+
* `GET /api/terminals/:id/stream` — SSE, with `Last-Event-ID`/`afterCursor`
|
|
112
|
+
* reconnect replay (via `sse.ts`'s `requestedAfterCursor`, the same helper
|
|
113
|
+
* `runs.ts` uses). Adapts `deps.manager.attach`'s push/end
|
|
114
|
+
* `TerminalSseSink` to `sse.ts`'s generic channel, mirroring
|
|
115
|
+
* `registerRunEventStream`'s adaptation of `RunLifecycle.stream`.
|
|
116
|
+
*
|
|
117
|
+
* Bypasses `mountJsonRoute` (raw `app.get`, for the SSE response shape), so it does not get
|
|
118
|
+
* `requireSameOrigin` for free the way the JSON routes above do — the guard is applied here
|
|
119
|
+
* directly, before the SSE channel is opened, so a cross-origin request never gets a stream.
|
|
120
|
+
*/
|
|
121
|
+
export declare function registerTerminalEventStream(app: Express, deps: TerminalsHttpDeps, adapter: AdapterContext): void;
|
|
122
|
+
/** Mounts every terminal route (`list`/`create`/`stdin`/`resize`/`kill`/`delete`/`stream`) on `app`. A pack's `http(app, services)` calls this directly. */
|
|
123
|
+
export declare function registerTerminalRoutes(app: Express, deps: TerminalsHttpDeps, adapter: AdapterContext): void;
|
|
124
|
+
export {};
|
|
125
|
+
//# sourceMappingURL=terminals.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"terminals.d.ts","sourceRoot":"","sources":["../src/terminals.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module terminals
|
|
3
|
+
*
|
|
4
|
+
* Interactive-terminal HTTP surface, mirroring OD's
|
|
5
|
+
* `apps/daemon/src/routes/terminal.ts` (`/api/projects/:id/terminals` +
|
|
6
|
+
* `.../:tid/{stream,stdin,resize,kill}`) generalized under `/api/terminals`.
|
|
7
|
+
* See `ADS-memory/reports/proposals/PROP-http-route-packs-terminal-pty-2026-07-21.md`
|
|
8
|
+
* for the design discussion and `@jini-ai/daemon`'s `terminal-session.ts` module
|
|
9
|
+
* doc for the session-ownership/gating decisions this route pack calls into.
|
|
10
|
+
*
|
|
11
|
+
* This file is deliberately thin — no PTY spawning, no session registry, no
|
|
12
|
+
* ownership logic lives here (that is `@jini-ai/daemon`'s `TerminalSessionManager`,
|
|
13
|
+
* injected as `deps.manager`). This package only:
|
|
14
|
+
*
|
|
15
|
+
* - Resolves a `POST /api/terminals` request's `resourceRef` to a spawn `cwd`
|
|
16
|
+
* via `workspace-root.ts` (the same port `host-tools.ts`'s open-in route
|
|
17
|
+
* uses), then routes creation through `deps.toolExecutor.execute(...,
|
|
18
|
+
* 'terminal.create', ...)` — the one call `@jini-ai/daemon`'s module gates by
|
|
19
|
+
* policy (matching `db-ops.ts`'s tool-execution-boundary precedent).
|
|
20
|
+
* - Routes `stdin`/`resize`/`kill`/`stream` directly to `deps.manager`'s
|
|
21
|
+
* lighter, session-ownership-checked methods — deliberately **not** through
|
|
22
|
+
* `ToolExecutor` again (see the daemon module's doc for why a full
|
|
23
|
+
* authorize/confirm round-trip per keystroke would make a terminal
|
|
24
|
+
* unusable).
|
|
25
|
+
* - Adapts `deps.manager.attach`'s push/end sink to `sse.ts`'s generic SSE
|
|
26
|
+
* channel, the same way `runs.ts` adapts `RunLifecycle.stream`.
|
|
27
|
+
*/
|
|
28
|
+
import { randomUUID } from 'node:crypto';
|
|
29
|
+
import { TERMINAL_CREATE_TOOL_ID } from '@jini-ai/daemon';
|
|
30
|
+
import { createApiError } from '@jini-ai/protocol';
|
|
31
|
+
import { defineJsonRoute, mountJsonRoute } from './adapter.js';
|
|
32
|
+
import { guardSameOrigin } from './origin.js';
|
|
33
|
+
import { validationError } from './request.js';
|
|
34
|
+
import { sendApiError, statusForError } from './response.js';
|
|
35
|
+
import { createSseChannel, requestedAfterCursor } from './sse.js';
|
|
36
|
+
import { denyAllWorkspaceRoots, resolveWorkspaceRoot, WorkspaceRootDeniedError } from './workspace-root.js';
|
|
37
|
+
import { err, ok } from './types.js';
|
|
38
|
+
function defaultTerminalsInternalErrorSink(context) {
|
|
39
|
+
// eslint-disable-next-line no-console
|
|
40
|
+
console.error(`[@jini-ai/http-kit] internal error (${context.source}, correlationId=${context.correlationId})`, context.error);
|
|
41
|
+
}
|
|
42
|
+
function isRecord(value) {
|
|
43
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
44
|
+
}
|
|
45
|
+
/** Every one-off `terminal.create` tool call gets its own opaque `RunRef` — matching `db-ops.ts`'s `freshRun()`: these are single requests, not steps of a longer-lived agent run. */
|
|
46
|
+
function freshRun() {
|
|
47
|
+
return { id: randomUUID() };
|
|
48
|
+
}
|
|
49
|
+
function toolResultToApiResult(deps, result) {
|
|
50
|
+
switch (result.status) {
|
|
51
|
+
case 'completed':
|
|
52
|
+
return ok(result.output);
|
|
53
|
+
case 'denied':
|
|
54
|
+
return err(createApiError('TOOL_OPERATION_DENIED', 'this operation was denied by policy'));
|
|
55
|
+
case 'confirmation-denied':
|
|
56
|
+
return err(createApiError('TOOL_OPERATION_DENIED', 'this operation was denied during confirmation'));
|
|
57
|
+
case 'timed-out':
|
|
58
|
+
case 'cancelled':
|
|
59
|
+
case 'failed': {
|
|
60
|
+
const correlationId = randomUUID();
|
|
61
|
+
const sink = deps.onInternalError ?? defaultTerminalsInternalErrorSink;
|
|
62
|
+
sink({ source: 'terminal-create', correlationId, error: result.status === 'failed' ? result.error : result.status });
|
|
63
|
+
return err(createApiError('INTERNAL_ERROR', 'an internal error occurred', { requestId: correlationId }));
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
function parseTerminalCreate(input) {
|
|
68
|
+
if (!isRecord(input.body))
|
|
69
|
+
return err(validationError('body must be a JSON object'));
|
|
70
|
+
const resourceRef = input.body.resourceRef;
|
|
71
|
+
if (typeof resourceRef !== 'string' || resourceRef.length === 0) {
|
|
72
|
+
return err(validationError('resourceRef is required', [{ path: 'resourceRef', message: 'required non-empty string' }]));
|
|
73
|
+
}
|
|
74
|
+
const detail = input.body.detail;
|
|
75
|
+
if (detail !== undefined && (typeof detail !== 'string' || detail.length === 0)) {
|
|
76
|
+
return err(validationError('detail must be a non-empty string when provided'));
|
|
77
|
+
}
|
|
78
|
+
const cols = input.body.cols;
|
|
79
|
+
if (cols !== undefined && typeof cols !== 'number') {
|
|
80
|
+
return err(validationError('cols must be a number when provided'));
|
|
81
|
+
}
|
|
82
|
+
const rows = input.body.rows;
|
|
83
|
+
if (rows !== undefined && typeof rows !== 'number') {
|
|
84
|
+
return err(validationError('rows must be a number when provided'));
|
|
85
|
+
}
|
|
86
|
+
const shell = input.body.shell;
|
|
87
|
+
if (shell !== undefined && (typeof shell !== 'string' || shell.length === 0)) {
|
|
88
|
+
return err(validationError('shell must be a non-empty string when provided'));
|
|
89
|
+
}
|
|
90
|
+
return ok({
|
|
91
|
+
resourceRef,
|
|
92
|
+
...(detail === undefined ? {} : { detail }),
|
|
93
|
+
...(cols === undefined ? {} : { cols }),
|
|
94
|
+
...(rows === undefined ? {} : { rows }),
|
|
95
|
+
...(shell === undefined ? {} : { shell }),
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* `POST /api/terminals` — the one gated call (see module doc). Resolves
|
|
100
|
+
* `resourceRef` to a working directory the same way `host-tools.ts`'s
|
|
101
|
+
* open-in route does, then authorizes+spawns the session through
|
|
102
|
+
* `ToolExecutor.execute(..., 'terminal.create', ...)`.
|
|
103
|
+
*/
|
|
104
|
+
export const terminalCreateRoute = defineJsonRoute({
|
|
105
|
+
method: 'post',
|
|
106
|
+
path: '/api/terminals',
|
|
107
|
+
requireSameOrigin: true,
|
|
108
|
+
parse: parseTerminalCreate,
|
|
109
|
+
handle: async (input, deps) => {
|
|
110
|
+
let cwd;
|
|
111
|
+
try {
|
|
112
|
+
cwd = await resolveWorkspaceRoot({ resourceRef: input.resourceRef, ...(input.detail === undefined ? {} : { detail: input.detail }) }, { resolver: deps.resolveRoot ?? denyAllWorkspaceRoots });
|
|
113
|
+
}
|
|
114
|
+
catch (error) {
|
|
115
|
+
if (error instanceof WorkspaceRootDeniedError) {
|
|
116
|
+
return err(createApiError('NOT_FOUND', `resource "${input.resourceRef}" was not found`));
|
|
117
|
+
}
|
|
118
|
+
throw error;
|
|
119
|
+
}
|
|
120
|
+
const toolInput = {
|
|
121
|
+
resourceRef: input.resourceRef,
|
|
122
|
+
cwd,
|
|
123
|
+
...(input.cols === undefined ? {} : { cols: input.cols }),
|
|
124
|
+
...(input.rows === undefined ? {} : { rows: input.rows }),
|
|
125
|
+
...(input.shell === undefined ? {} : { shell: input.shell }),
|
|
126
|
+
};
|
|
127
|
+
const result = await deps.toolExecutor.execute(deps.principal, freshRun(), TERMINAL_CREATE_TOOL_ID, toolInput);
|
|
128
|
+
return toolResultToApiResult(deps, result);
|
|
129
|
+
},
|
|
130
|
+
successStatus: 201,
|
|
131
|
+
});
|
|
132
|
+
function parseTerminalList(input) {
|
|
133
|
+
const value = input.query.resourceRef;
|
|
134
|
+
if (value === undefined)
|
|
135
|
+
return ok({});
|
|
136
|
+
if (typeof value !== 'string' || value.length === 0) {
|
|
137
|
+
return err(validationError('resourceRef must be a non-empty string when provided'));
|
|
138
|
+
}
|
|
139
|
+
return ok({ resourceRef: value });
|
|
140
|
+
}
|
|
141
|
+
/** `GET /api/terminals` — sessions the calling principal owns, optionally narrowed by `resourceRef`. Never gated through `ToolExecutor` — a read scoped to the caller's own sessions, matching `runs.ts`'s `runListRoute` precedent. */
|
|
142
|
+
export const terminalListRoute = defineJsonRoute({
|
|
143
|
+
method: 'get',
|
|
144
|
+
path: '/api/terminals',
|
|
145
|
+
parse: parseTerminalList,
|
|
146
|
+
handle: (input, deps) => ok({ terminals: deps.manager.list(deps.principal, input.resourceRef === undefined ? {} : { resourceRef: input.resourceRef }) }),
|
|
147
|
+
});
|
|
148
|
+
function parseTerminalId(input) {
|
|
149
|
+
const id = input.params.id;
|
|
150
|
+
return typeof id === 'string' && id.length > 0 ? ok(id) : err(validationError('id must be a non-empty path parameter'));
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Exported (not just internal) so the `result.session === null` branch — a real race
|
|
154
|
+
* (`write`/`resize`/`kill` finding metadata for an id whose underlying session was concurrently
|
|
155
|
+
* killed between the ownership check and the critical section, per `@jini-ai/daemon`'s
|
|
156
|
+
* `terminal-session.ts` `currentSnapshot`) — is directly unit-testable against a synthetic
|
|
157
|
+
* `TerminalSessionActionResult` rather than requiring a real, hard-to-deterministically-force race
|
|
158
|
+
* through the full `TerminalSessionManager`. Matches this file's own `createDeferredEndGate`
|
|
159
|
+
* precedent for the same "extract into a directly-testable pure function" shape.
|
|
160
|
+
*/
|
|
161
|
+
export function actionResultToApiResult(result, notFoundMessage) {
|
|
162
|
+
if (result.status === 'not-found')
|
|
163
|
+
return err(createApiError('NOT_FOUND', notFoundMessage));
|
|
164
|
+
return ok({ ok: result.ok, ...(result.session ? { terminal: result.session } : {}) });
|
|
165
|
+
}
|
|
166
|
+
function parseStdinInput(input) {
|
|
167
|
+
const parsedId = parseTerminalId(input);
|
|
168
|
+
if (!parsedId.ok)
|
|
169
|
+
return parsedId;
|
|
170
|
+
if (!isRecord(input.body))
|
|
171
|
+
return err(validationError('body must be a JSON object'));
|
|
172
|
+
const data = input.body.data;
|
|
173
|
+
if (typeof data !== 'string') {
|
|
174
|
+
return err(validationError('data (string) is required', [{ path: 'data', message: 'required string' }]));
|
|
175
|
+
}
|
|
176
|
+
return ok({ id: parsedId.value, data });
|
|
177
|
+
}
|
|
178
|
+
/** `POST /api/terminals/:id/stdin` — writes `data` to the session's pty. Ownership-checked by `deps.manager.write` directly, not re-gated through `ToolExecutor` (see module doc). */
|
|
179
|
+
export const terminalStdinRoute = defineJsonRoute({
|
|
180
|
+
method: 'post',
|
|
181
|
+
path: '/api/terminals/:id/stdin',
|
|
182
|
+
requireSameOrigin: true,
|
|
183
|
+
parse: parseStdinInput,
|
|
184
|
+
handle: async (input, deps) => {
|
|
185
|
+
const result = await deps.manager.write(deps.principal, input.id, input.data);
|
|
186
|
+
return actionResultToApiResult(result, `terminal "${input.id}" was not found`);
|
|
187
|
+
},
|
|
188
|
+
});
|
|
189
|
+
function parseResizeInput(input) {
|
|
190
|
+
const parsedId = parseTerminalId(input);
|
|
191
|
+
if (!parsedId.ok)
|
|
192
|
+
return parsedId;
|
|
193
|
+
if (!isRecord(input.body))
|
|
194
|
+
return err(validationError('body must be a JSON object'));
|
|
195
|
+
const cols = Number(input.body.cols);
|
|
196
|
+
const rows = Number(input.body.rows);
|
|
197
|
+
if (!Number.isFinite(cols) || !Number.isFinite(rows)) {
|
|
198
|
+
return err(validationError('cols and rows (numbers) are required'));
|
|
199
|
+
}
|
|
200
|
+
return ok({ id: parsedId.value, cols, rows });
|
|
201
|
+
}
|
|
202
|
+
/** `POST /api/terminals/:id/resize` — resizes the session's pty. */
|
|
203
|
+
export const terminalResizeRoute = defineJsonRoute({
|
|
204
|
+
method: 'post',
|
|
205
|
+
path: '/api/terminals/:id/resize',
|
|
206
|
+
requireSameOrigin: true,
|
|
207
|
+
parse: parseResizeInput,
|
|
208
|
+
handle: async (input, deps) => {
|
|
209
|
+
const result = await deps.manager.resize(deps.principal, input.id, input.cols, input.rows);
|
|
210
|
+
return actionResultToApiResult(result, `terminal "${input.id}" was not found`);
|
|
211
|
+
},
|
|
212
|
+
});
|
|
213
|
+
function handleKill(id, deps) {
|
|
214
|
+
return deps.manager.kill(deps.principal, id, 'SIGTERM').then((result) => actionResultToApiResult(result, `terminal "${id}" was not found`));
|
|
215
|
+
}
|
|
216
|
+
/** `POST /api/terminals/:id/kill` — sends `SIGTERM` to the session's pty. */
|
|
217
|
+
export const terminalKillRoute = defineJsonRoute({
|
|
218
|
+
method: 'post',
|
|
219
|
+
path: '/api/terminals/:id/kill',
|
|
220
|
+
requireSameOrigin: true,
|
|
221
|
+
parse: parseTerminalId,
|
|
222
|
+
handle: (id, deps) => handleKill(id, deps),
|
|
223
|
+
});
|
|
224
|
+
/** `DELETE /api/terminals/:id` — alias for `kill`, matching OD's dual `POST .../kill` / `DELETE` routes. */
|
|
225
|
+
export const terminalDeleteRoute = defineJsonRoute({
|
|
226
|
+
method: 'delete',
|
|
227
|
+
path: '/api/terminals/:id',
|
|
228
|
+
requireSameOrigin: true,
|
|
229
|
+
parse: parseTerminalId,
|
|
230
|
+
handle: (id, deps) => handleKill(id, deps),
|
|
231
|
+
});
|
|
232
|
+
/**
|
|
233
|
+
* The `channelOpened`/`endRequestedBeforeOpen` state machine `registerTerminalEventStream`'s
|
|
234
|
+
* `TerminalSseSink.end()` needs, extracted into its own directly-testable unit — see that
|
|
235
|
+
* function's inline comment for *why* the deferral exists (an already-exited session's replay
|
|
236
|
+
* path can call `end()` before `open()` has ever run).
|
|
237
|
+
*
|
|
238
|
+
* **On the `markOpened()`-then-`end()` ordering (opened=true when `end()` runs):** this is the
|
|
239
|
+
* ordering every *currently reachable* production call graph in this repo takes for a live
|
|
240
|
+
* session that exits while already streaming — but it is unreachable through that graph, not
|
|
241
|
+
* unreachable in principle. `@jini-ai/platform`'s `TerminalService.finish()` (`packages/platform/src/
|
|
242
|
+
* terminal.ts`) calls `sink.send('exit', ...)` immediately before `sink.end()` for every live
|
|
243
|
+
* client; `send()` here forwards straight to `channel.enqueue()`, whose `isEndEvent: (e) => e.kind
|
|
244
|
+
* === 'exit'` match auto-closes the channel *synchronously inside that same `enqueue()` call* —
|
|
245
|
+
* which synchronously runs `registerTerminalEventStream`'s own `channel.onClose` callback, which
|
|
246
|
+
* detaches this sink from `TerminalService`'s `session.clients` before `finish()`'s own, separate
|
|
247
|
+
* `sink.end()` loop ever runs. That loop iterates `session.clients` fresh at call time (not a
|
|
248
|
+
* snapshot taken before `emit()`), so by the time it runs, this sink has already removed itself —
|
|
249
|
+
* `sink.end()` is consequently never invoked while `channelOpened` is `true` via any call graph
|
|
250
|
+
* this repo's own `@jini-ai/platform` + `@jini-ai/http-kit` composition can currently produce (confirmed by
|
|
251
|
+
* instrumenting the real call path directly, not inferred from reading the source — a synchronous
|
|
252
|
+
* console probe in `end()` never fired across this file's full "live-exit-while-streaming" test).
|
|
253
|
+
* That is a fact about today's specific `TerminalService`/`SseChannel` wiring, not a proof that no
|
|
254
|
+
* future caller of `TerminalSseSink` could ever call `end()` post-open (a hypothetical non-SSE
|
|
255
|
+
* sink, or a future `TerminalService` exit reason not preceded by a matching `send()`, would hit
|
|
256
|
+
* exactly this branch) — so the branch stays as real, intentional defensive code, tested directly
|
|
257
|
+
* here rather than deleted for being unreachable *today* or left silently uncovered.
|
|
258
|
+
*/
|
|
259
|
+
export function createDeferredEndGate(channel) {
|
|
260
|
+
let opened = false;
|
|
261
|
+
let endRequestedBeforeOpen = false;
|
|
262
|
+
return {
|
|
263
|
+
markOpened() {
|
|
264
|
+
opened = true;
|
|
265
|
+
// Idempotent-safe even if the queued 'exit' event already auto-closed the channel via
|
|
266
|
+
// `isEndEvent` during `open()`'s own drain (`channel.end()` is documented safe to call twice).
|
|
267
|
+
if (endRequestedBeforeOpen)
|
|
268
|
+
channel.end();
|
|
269
|
+
},
|
|
270
|
+
end() {
|
|
271
|
+
if (opened) {
|
|
272
|
+
channel.end();
|
|
273
|
+
}
|
|
274
|
+
else {
|
|
275
|
+
endRequestedBeforeOpen = true;
|
|
276
|
+
}
|
|
277
|
+
},
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* `GET /api/terminals/:id/stream` — SSE, with `Last-Event-ID`/`afterCursor`
|
|
282
|
+
* reconnect replay (via `sse.ts`'s `requestedAfterCursor`, the same helper
|
|
283
|
+
* `runs.ts` uses). Adapts `deps.manager.attach`'s push/end
|
|
284
|
+
* `TerminalSseSink` to `sse.ts`'s generic channel, mirroring
|
|
285
|
+
* `registerRunEventStream`'s adaptation of `RunLifecycle.stream`.
|
|
286
|
+
*
|
|
287
|
+
* Bypasses `mountJsonRoute` (raw `app.get`, for the SSE response shape), so it does not get
|
|
288
|
+
* `requireSameOrigin` for free the way the JSON routes above do — the guard is applied here
|
|
289
|
+
* directly, before the SSE channel is opened, so a cross-origin request never gets a stream.
|
|
290
|
+
*/
|
|
291
|
+
export function registerTerminalEventStream(app, deps, adapter) {
|
|
292
|
+
app.get('/api/terminals/:id/stream', (req, res) => {
|
|
293
|
+
const origin = guardSameOrigin(req, adapter);
|
|
294
|
+
if (!origin.ok) {
|
|
295
|
+
sendApiError(res, statusForError(origin.error), origin.error);
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
const id = req.params.id;
|
|
299
|
+
if (typeof id !== 'string' || id.length === 0) {
|
|
300
|
+
sendApiError(res, 400, createApiError('BAD_REQUEST', 'id must be a non-empty path parameter'));
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
const cursor = requestedAfterCursor(req);
|
|
304
|
+
const lastEventId = cursor === null ? 0 : Number(cursor);
|
|
305
|
+
const channel = createSseChannel(res, { isEndEvent: (event) => event.kind === 'exit' });
|
|
306
|
+
// `deps.manager.attach` can call `sink.end()` synchronously, from inside the very call below
|
|
307
|
+
// (an already-exited session's replay path) — before the channel has ever been `open()`ed. If
|
|
308
|
+
// `end()` mapped straight to `channel.end()`, that would end the response with the queued
|
|
309
|
+
// backlog never flushed (headers never even sent). `createDeferredEndGate` defers the actual
|
|
310
|
+
// `channel.end()` call until after `open()` has drained whatever `send()` already queued — see
|
|
311
|
+
// that function's own doc for the full reachability analysis of its other branch.
|
|
312
|
+
const deferredEnd = createDeferredEndGate(channel);
|
|
313
|
+
const sink = {
|
|
314
|
+
send(event, data, eventId) {
|
|
315
|
+
channel.enqueue({ opaqueCursor: String(eventId), kind: event, data });
|
|
316
|
+
},
|
|
317
|
+
end() {
|
|
318
|
+
deferredEnd.end();
|
|
319
|
+
},
|
|
320
|
+
};
|
|
321
|
+
let attachedSink = null;
|
|
322
|
+
channel.onClose(() => {
|
|
323
|
+
if (attachedSink)
|
|
324
|
+
deps.manager.detach(id, attachedSink);
|
|
325
|
+
});
|
|
326
|
+
const result = deps.manager.attach(deps.principal, id, lastEventId, sink);
|
|
327
|
+
if (result === 'not-found') {
|
|
328
|
+
channel.abandon();
|
|
329
|
+
sendApiError(res, 404, createApiError('NOT_FOUND', `terminal "${id}" was not found`));
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
if (result === 'attached') {
|
|
333
|
+
attachedSink = sink;
|
|
334
|
+
}
|
|
335
|
+
channel.open();
|
|
336
|
+
deferredEnd.markOpened();
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
/** Mounts every terminal route (`list`/`create`/`stdin`/`resize`/`kill`/`delete`/`stream`) on `app`. A pack's `http(app, services)` calls this directly. */
|
|
340
|
+
export function registerTerminalRoutes(app, deps, adapter) {
|
|
341
|
+
mountJsonRoute(app, terminalListRoute, deps, adapter);
|
|
342
|
+
mountJsonRoute(app, terminalCreateRoute, deps, adapter);
|
|
343
|
+
mountJsonRoute(app, terminalStdinRoute, deps, adapter);
|
|
344
|
+
mountJsonRoute(app, terminalResizeRoute, deps, adapter);
|
|
345
|
+
mountJsonRoute(app, terminalKillRoute, deps, adapter);
|
|
346
|
+
mountJsonRoute(app, terminalDeleteRoute, deps, adapter);
|
|
347
|
+
registerTerminalEventStream(app, deps, adapter);
|
|
348
|
+
}
|
|
349
|
+
//# sourceMappingURL=terminals.js.map
|