@ggui-ai/mcp-server 0.8.0 → 0.10.0
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/api-renders-routes.d.ts +21 -0
- package/dist/api-renders-routes.d.ts.map +1 -1
- package/dist/api-renders-routes.js +54 -28
- package/dist/api-renders-stream-route.d.ts +80 -0
- package/dist/api-renders-stream-route.d.ts.map +1 -0
- package/dist/api-renders-stream-route.js +311 -0
- package/dist/build-mcp.d.ts +48 -7
- package/dist/build-mcp.d.ts.map +1 -1
- package/dist/build-mcp.js +87 -6
- package/dist/code-module-variant.d.ts +150 -0
- package/dist/code-module-variant.d.ts.map +1 -0
- package/dist/code-module-variant.js +243 -0
- package/dist/code-routes.d.ts +12 -2
- package/dist/code-routes.d.ts.map +1 -1
- package/dist/code-routes.js +12 -2
- package/dist/console-session-routes.d.ts.map +1 -1
- package/dist/console-session-routes.js +11 -0
- package/dist/control-service.d.ts +29 -3
- package/dist/control-service.d.ts.map +1 -1
- package/dist/control-service.js +26 -2
- package/dist/ggui-session-channel/action-ingress.d.ts +2 -2
- package/dist/ggui-session-channel/action-ingress.d.ts.map +1 -1
- package/dist/ggui-session-channel/channel-subscriptions.d.ts +4 -4
- package/dist/ggui-session-channel/channel-subscriptions.d.ts.map +1 -1
- package/dist/ggui-session-channel/internal-types.d.ts +63 -9
- package/dist/ggui-session-channel/internal-types.d.ts.map +1 -1
- package/dist/ggui-session-channel/outbound.d.ts +15 -5
- package/dist/ggui-session-channel/outbound.d.ts.map +1 -1
- package/dist/ggui-session-channel/outbound.js +64 -24
- package/dist/ggui-session-channel/socket-router.d.ts +8 -3
- package/dist/ggui-session-channel/socket-router.d.ts.map +1 -1
- package/dist/ggui-session-channel/socket-router.js +6 -1
- package/dist/ggui-session-channel/subscribe.d.ts +48 -3
- package/dist/ggui-session-channel/subscribe.d.ts.map +1 -1
- package/dist/ggui-session-channel/subscribe.js +97 -36
- package/dist/ggui-session-channel/subscriber-lifecycle.d.ts +23 -13
- package/dist/ggui-session-channel/subscriber-lifecycle.d.ts.map +1 -1
- package/dist/ggui-session-channel/subscriber-lifecycle.js +24 -11
- package/dist/ggui-session-channel.d.ts +58 -11
- package/dist/ggui-session-channel.d.ts.map +1 -1
- package/dist/ggui-session-channel.js +55 -19
- package/dist/health-routes.d.ts +19 -3
- package/dist/health-routes.d.ts.map +1 -1
- package/dist/health-routes.js +26 -18
- package/dist/index.d.ts +6 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +13 -1
- package/dist/instructions-presets.js +10 -10
- package/dist/mcp-apps-outbound.d.ts +88 -11
- package/dist/mcp-apps-outbound.d.ts.map +1 -1
- package/dist/mcp-apps-outbound.js +470 -77
- package/dist/mcp-endpoint-routes.d.ts +23 -5
- package/dist/mcp-endpoint-routes.d.ts.map +1 -1
- package/dist/mcp-endpoint-routes.js +69 -1
- package/dist/oauth-as-routes.d.ts +11 -0
- package/dist/oauth-as-routes.d.ts.map +1 -1
- package/dist/oauth-as-routes.js +45 -1
- package/dist/oauth.d.ts.map +1 -1
- package/dist/oauth.js +8 -1
- package/dist/runtime-bundle-hash.d.ts +55 -0
- package/dist/runtime-bundle-hash.d.ts.map +1 -0
- package/dist/runtime-bundle-hash.js +85 -0
- package/dist/runtime-bundle-route.js +1 -1
- package/dist/server.d.ts +239 -61
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +355 -143
- package/package.json +13 -12
|
@@ -64,6 +64,27 @@ interface MountOptions {
|
|
|
64
64
|
readonly codeStore?: CodeStore;
|
|
65
65
|
/** Operator-configured public origin for absolute URL composition. */
|
|
66
66
|
readonly publicBaseUrl?: string;
|
|
67
|
+
/**
|
|
68
|
+
* Origin the content-addressable routes (`/code/*`, `/contract/*`)
|
|
69
|
+
* are reached at — an edge-cached asset host when the deployment
|
|
70
|
+
* fronts static paths separately (ggui#522). Defaults to
|
|
71
|
+
* {@link publicBaseUrl}, then the request host. Session-API URLs
|
|
72
|
+
* (`/events`, `/stream`) never use it: those are dynamic and stay on
|
|
73
|
+
* the public origin.
|
|
74
|
+
*/
|
|
75
|
+
readonly codeBaseUrl?: string;
|
|
76
|
+
/**
|
|
77
|
+
* Strict-CSP module-variant minter (ggui#522 slice 2) — the /state
|
|
78
|
+
* read re-mints the `codeModuleUrl` twin alongside `codeUrl`. Only
|
|
79
|
+
* consulted when an explicit {@link codeBaseUrl}/{@link publicBaseUrl}
|
|
80
|
+
* base resolved: the variant embeds absolute shim URLs on the SAME
|
|
81
|
+
* origin, and a request-derived base has no shim family behind it.
|
|
82
|
+
*/
|
|
83
|
+
readonly mintCodeModuleUrl?: (args: {
|
|
84
|
+
readonly code: string;
|
|
85
|
+
readonly hash: string;
|
|
86
|
+
readonly base: string;
|
|
87
|
+
}) => string | undefined;
|
|
67
88
|
/** Live-mode credential minter — fresh trio on every /state read. */
|
|
68
89
|
readonly mintBootstrap?: (sessionId: string, appId: string) => {
|
|
69
90
|
wsUrl: string;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"api-renders-routes.d.ts","sourceRoot":"","sources":["../src/api-renders-routes.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4CG;AAEH,OAAO,KAAK,EAAE,gBAAgB,EAAE,SAAS,EAAE,gBAAgB,EAAE,MAAM,0BAA0B,CAAC;
|
|
1
|
+
{"version":3,"file":"api-renders-routes.d.ts","sourceRoot":"","sources":["../src/api-renders-routes.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4CG;AAEH,OAAO,KAAK,EAAE,gBAAgB,EAAE,SAAS,EAAE,gBAAgB,EAAE,MAAM,0BAA0B,CAAC;AAQ9F,OAAO,EAGL,KAAK,sBAAsB,EAC5B,MAAM,yCAAyC,CAAC;AACjD,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,SAAS,CAAC;AACvC,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,aAAa,CAAC;AAE1C,UAAU,YAAY;IACpB,iCAAiC;IACjC,QAAQ,CAAC,GAAG,EAAE,OAAO,CAAC;IACtB,+CAA+C;IAC/C,QAAQ,CAAC,WAAW,EAAE,gBAAgB,CAAC;IACvC,wEAAwE;IACxE,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,sEAAsE;IACtE,QAAQ,CAAC,gBAAgB,CAAC,EAAE,gBAAgB,CAAC;IAC7C,+DAA+D;IAC/D,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAC;IAC1B,0DAA0D;IAC1D,QAAQ,CAAC,SAAS,CAAC,EAAE,sBAAsB,CAAC,WAAW,CAAC,CAAC;IACzD,sEAAsE;IACtE,QAAQ,CAAC,SAAS,CAAC,EAAE,SAAS,CAAC;IAC/B,sEAAsE;IACtE,QAAQ,CAAC,aAAa,CAAC,EAAE,MAAM,CAAC;IAChC;;;;;;;OAOG;IACH,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAC9B;;;;;;OAMG;IACH,QAAQ,CAAC,iBAAiB,CAAC,EAAE,CAAC,IAAI,EAAE;QAClC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;QACtB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;QACtB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;KACvB,KAAK,MAAM,GAAG,SAAS,CAAC;IACzB,qEAAqE;IACrE,QAAQ,CAAC,aAAa,CAAC,EAAE,CACvB,SAAS,EAAE,MAAM,EACjB,KAAK,EAAE,MAAM,KACV;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,MAAM,CAAA;KAAE,CAAC;IACzD,oEAAoE;IACpE,QAAQ,CAAC,iBAAiB,EAAE,MAAM,MAAM,CAAC;IACzC,iDAAiD;IACjD,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;CACzB;AAED;;;;GAIG;AACH,wBAAgB,qBAAqB,CAAC,IAAI,EAAE,YAAY,GAAG,IAAI,CA8W9D"}
|
|
@@ -44,16 +44,25 @@
|
|
|
44
44
|
* or the render was reset). Clients re-mount from /state.
|
|
45
45
|
*/
|
|
46
46
|
import { verifyToken } from "@ggui-ai/mcp-server-core";
|
|
47
|
-
import { deriveContractBundle, derivePublicEnvProjection, deriveRenderMeta, } from "@ggui-ai/mcp-server-handlers/renders";
|
|
48
|
-
import { MCP_APP_AI_GGUI_RENDER_META_KEY, } from "@ggui-ai/protocol/integrations/mcp-apps";
|
|
47
|
+
import { deriveContractBundle, derivePublicEnvProjection, deriveRenderMeta, spreadRenderMetaViewOntoSlice, } from "@ggui-ai/mcp-server-handlers/renders";
|
|
48
|
+
import { composeSessionApiUrls, MCP_APP_AI_GGUI_RENDER_META_KEY, } from "@ggui-ai/protocol/integrations/mcp-apps";
|
|
49
49
|
/**
|
|
50
50
|
* Mount `GET /api/sessions/:sessionId/state` +
|
|
51
51
|
* `GET /api/sessions/:sessionId/events` onto the express app.
|
|
52
52
|
* Returns nothing — the routes self-register.
|
|
53
53
|
*/
|
|
54
54
|
export function mountApiRendersRoutes(opts) {
|
|
55
|
-
const { app, renderStore, secret, appMetadataStore, themeId, themeMode, codeStore, publicBaseUrl, mintBootstrap, resolveRuntimeUrl, logger, } = opts;
|
|
55
|
+
const { app, renderStore, secret, appMetadataStore, themeId, themeMode, codeStore, publicBaseUrl, codeBaseUrl, mintBootstrap, resolveRuntimeUrl, logger, } = opts;
|
|
56
|
+
// The origin the content-addressable routes are reached at (asset
|
|
57
|
+
// host if fronted separately, else the public origin); the request
|
|
58
|
+
// host is the last resort for local/tunnel deployments.
|
|
59
|
+
const staticBase = codeBaseUrl ?? publicBaseUrl;
|
|
56
60
|
app.get("/api/sessions/:sessionId/state", async (req, res) => {
|
|
61
|
+
// CORS on EVERY response, gates included: cross-origin frames can
|
|
62
|
+
// only read a status when the response carries ACAO. Without it a
|
|
63
|
+
// 410 (refresh the wsToken) is an opaque network error — the rung
|
|
64
|
+
// demotes as "structurally unreachable" instead of refreshing.
|
|
65
|
+
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
57
66
|
const sessionId = req.params["sessionId"];
|
|
58
67
|
if (typeof sessionId !== "string" || sessionId.length === 0) {
|
|
59
68
|
res.status(400).type("text/plain").send("sessionId required");
|
|
@@ -149,20 +158,26 @@ export function mountApiRendersRoutes(opts) {
|
|
|
149
158
|
// stamps this trio on its resultMeta; mirroring here closes the
|
|
150
159
|
// drift.
|
|
151
160
|
const liveTrio = mintBootstrap ? mintBootstrap(stored.id, stored.appId) : undefined;
|
|
152
|
-
//
|
|
153
|
-
//
|
|
154
|
-
//
|
|
155
|
-
//
|
|
156
|
-
|
|
157
|
-
|
|
161
|
+
// Token-bearing session-API URL pair (pollingUrl → /events,
|
|
162
|
+
// sseUrl → /stream), composed via the protocol's ONE composer so
|
|
163
|
+
// this surface cannot drift from the render/update resultMeta
|
|
164
|
+
// stamping. Stamped ONLY when the live trio above was minted —
|
|
165
|
+
// both URLs embed the fresh long-TTL token, so a minter-less
|
|
166
|
+
// deployment honestly omits them (the old unconditional
|
|
167
|
+
// token-less `/state`-shaped pollingUrl could only 401 through
|
|
168
|
+
// the iframe-runtime's /events composer).
|
|
169
|
+
const sessionApiBase = publicBaseUrl
|
|
158
170
|
? publicBaseUrl.replace(/\/$/, "")
|
|
159
|
-
: `${req.protocol}://${
|
|
160
|
-
const
|
|
171
|
+
: `${req.protocol}://${req.get("host") ?? ""}`;
|
|
172
|
+
const sessionApiUrls = liveTrio !== undefined
|
|
173
|
+
? composeSessionApiUrls(sessionApiBase, stored.id, liveTrio.token)
|
|
174
|
+
: undefined;
|
|
161
175
|
// Static-component delivery via codeUrl (the same content-addressable
|
|
162
176
|
// channel the code routes serve). Polling clients are render-capable
|
|
163
177
|
// and need the URL to mount/refresh the static-component variant.
|
|
164
178
|
let renderCodeUrl;
|
|
165
179
|
let renderCodeHash;
|
|
180
|
+
let renderCodeModuleUrl;
|
|
166
181
|
let renderContractHash;
|
|
167
182
|
let renderValidatorsUrl;
|
|
168
183
|
if (!isSystem && !isMcpApps && codeStore) {
|
|
@@ -173,10 +188,19 @@ export function mountApiRendersRoutes(opts) {
|
|
|
173
188
|
await codeStore.put(hash, code);
|
|
174
189
|
renderCodeHash = hash;
|
|
175
190
|
const requestHost = req.get("host") ?? "";
|
|
176
|
-
const base =
|
|
177
|
-
?
|
|
191
|
+
const base = staticBase !== undefined
|
|
192
|
+
? staticBase.replace(/\/$/, "")
|
|
178
193
|
: `${req.protocol}://${requestHost}`;
|
|
179
194
|
renderCodeUrl = `${base}/code/${hash}.js`;
|
|
195
|
+
// Strict-CSP module-variant twin — only on an explicit base
|
|
196
|
+
// (see MountOptions.mintCodeModuleUrl for why).
|
|
197
|
+
if (staticBase !== undefined) {
|
|
198
|
+
renderCodeModuleUrl = opts.mintCodeModuleUrl?.({
|
|
199
|
+
code,
|
|
200
|
+
hash,
|
|
201
|
+
base,
|
|
202
|
+
});
|
|
203
|
+
}
|
|
180
204
|
}
|
|
181
205
|
catch {
|
|
182
206
|
// Silent — the caller falls back to live-mode delivery, and
|
|
@@ -195,8 +219,8 @@ export function mountApiRendersRoutes(opts) {
|
|
|
195
219
|
await codeStore.put(bundle.contractHash, bundle.bundleSource);
|
|
196
220
|
renderContractHash = bundle.contractHash;
|
|
197
221
|
const requestHost = req.get("host") ?? "";
|
|
198
|
-
const base =
|
|
199
|
-
?
|
|
222
|
+
const base = staticBase !== undefined
|
|
223
|
+
? staticBase.replace(/\/$/, "")
|
|
200
224
|
: `${req.protocol}://${requestHost}`;
|
|
201
225
|
renderValidatorsUrl = `${base}/contract/${bundle.contractHash}.js`;
|
|
202
226
|
}
|
|
@@ -219,20 +243,19 @@ export function mountApiRendersRoutes(opts) {
|
|
|
219
243
|
expiresAt: liveTrio.expiresAt,
|
|
220
244
|
}
|
|
221
245
|
: {}),
|
|
222
|
-
|
|
246
|
+
...(sessionApiUrls !== undefined
|
|
247
|
+
? { pollingUrl: sessionApiUrls.pollingUrl, sseUrl: sessionApiUrls.sseUrl }
|
|
248
|
+
: {}),
|
|
223
249
|
...(themeId !== undefined ? { themeId } : {}),
|
|
224
250
|
...(themeMode !== undefined ? { themeMode } : {}),
|
|
225
|
-
// Per-app theme overlay projected by `deriveRenderMeta` from the
|
|
226
|
-
// render's `theme` sidecar — same field the render result-meta
|
|
227
|
-
// carries, so a /state read returns the identical overlay.
|
|
228
|
-
...(view?.theme !== undefined ? { theme: view.theme } : {}),
|
|
229
|
-
...(view?.gadgets !== undefined && view.gadgets.length > 0 ? { gadgets: view.gadgets } : {}),
|
|
230
251
|
...(statePublicEnv !== undefined && Object.keys(statePublicEnv).length > 0
|
|
231
252
|
? { publicEnv: statePublicEnv }
|
|
232
253
|
: {}),
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
254
|
+
// State + policy view fields (theme overlay, gadgets,
|
|
255
|
+
// permissionsPolicy, propsJson, contextSlots, #483 epoch) — ONE
|
|
256
|
+
// shared spread so /state cannot drift from the result-meta
|
|
257
|
+
// emitters (a hand-copied list once silently dropped `epoch`).
|
|
258
|
+
...spreadRenderMetaViewOntoSlice(view),
|
|
236
259
|
// R6 — load-bearing ledger cursor. Always stamped on /state
|
|
237
260
|
// reads so polling clients can position the R7 /events cursor.
|
|
238
261
|
lastSequence: stored.eventSequence,
|
|
@@ -242,10 +265,11 @@ export function mountApiRendersRoutes(opts) {
|
|
|
242
265
|
? {
|
|
243
266
|
codeUrl: renderCodeUrl,
|
|
244
267
|
...(renderCodeHash !== undefined ? { codeHash: renderCodeHash } : {}),
|
|
268
|
+
...(renderCodeModuleUrl !== undefined
|
|
269
|
+
? { codeModuleUrl: renderCodeModuleUrl }
|
|
270
|
+
: {}),
|
|
245
271
|
}
|
|
246
272
|
: {}),
|
|
247
|
-
...(view?.propsJson !== undefined ? { propsJson: view.propsJson } : {}),
|
|
248
|
-
...(view?.contextSlots !== undefined ? { contextSlots: view.contextSlots } : {}),
|
|
249
273
|
...(renderContractHash !== undefined && renderValidatorsUrl !== undefined
|
|
250
274
|
? {
|
|
251
275
|
contractHash: renderContractHash,
|
|
@@ -253,7 +277,6 @@ export function mountApiRendersRoutes(opts) {
|
|
|
253
277
|
}
|
|
254
278
|
: {}),
|
|
255
279
|
};
|
|
256
|
-
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
257
280
|
res.setHeader("Cache-Control", "no-store, no-cache, must-revalidate");
|
|
258
281
|
res.setHeader("Content-Type", "application/json; charset=utf-8");
|
|
259
282
|
res.status(200).json({
|
|
@@ -261,6 +284,10 @@ export function mountApiRendersRoutes(opts) {
|
|
|
261
284
|
});
|
|
262
285
|
});
|
|
263
286
|
app.get("/api/sessions/:sessionId/events", async (req, res) => {
|
|
287
|
+
// CORS pre-gates — same contract as /state: the 410/401 verdicts
|
|
288
|
+
// are part of the polling rung's protocol and must be readable
|
|
289
|
+
// from cross-origin frames.
|
|
290
|
+
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
264
291
|
const sessionId = req.params["sessionId"];
|
|
265
292
|
if (typeof sessionId !== "string" || sessionId.length === 0) {
|
|
266
293
|
res.status(400).type("text/plain").send("sessionId required");
|
|
@@ -368,7 +395,6 @@ export function mountApiRendersRoutes(opts) {
|
|
|
368
395
|
// (Wave 7 of flatten-render-identity, 2026-05-28). The store
|
|
369
396
|
// returns events in protocol-canonical shape (seq + type +
|
|
370
397
|
// timestamp[ISO] + data); no projection needed.
|
|
371
|
-
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
372
398
|
res.setHeader("Cache-Control", "no-store, no-cache, must-revalidate");
|
|
373
399
|
res.setHeader("Content-Type", "application/json; charset=utf-8");
|
|
374
400
|
res.status(200).json({
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* wsToken-gated SSE live stream (the ladder's middle rung).
|
|
3
|
+
*
|
|
4
|
+
* GET /api/sessions/:sessionId/stream?wsToken=<token>[&sinceSequence=N][&fromSeq=M]
|
|
5
|
+
*
|
|
6
|
+
* Server-Sent Events delivery of the SAME ChannelFrame `{type,
|
|
7
|
+
* payload}` JSON the live-channel WS pushes — for hosts whose CSP
|
|
8
|
+
* blocks WebSocket upgrades but allows same-origin HTTP. The route
|
|
9
|
+
* registers a transport-neutral subscriber into the channel server via
|
|
10
|
+
* `attachExternalSubscriber`, so every fan-out plane WS subscribers
|
|
11
|
+
* ride (StreamFanout live tail, `props_update` / `render` /
|
|
12
|
+
* `drain_ack` walks, external broadcasts) reaches SSE subscribers with
|
|
13
|
+
* zero per-frame branching.
|
|
14
|
+
*
|
|
15
|
+
* Framing contract:
|
|
16
|
+
* - Every `data:` line is EXACTLY one ChannelFrame JSON — byte-same
|
|
17
|
+
* shape as the WS push (default event type, so `EventSource`'s
|
|
18
|
+
* `onmessage` fires).
|
|
19
|
+
* - `id:` = decimal GguiSessionEvent ledger seq, stamped ONLY on
|
|
20
|
+
* ledger-backed `render_event` replay frames (and the
|
|
21
|
+
* REPLAY_HORIZON_PASSED error frame, which carries the fresh
|
|
22
|
+
* high-water mark). Live frames are id-less — same ephemeral
|
|
23
|
+
* posture as WS; reconnects re-mount state from the ack snapshot.
|
|
24
|
+
* The browser's `Last-Event-ID` therefore lands on the exact
|
|
25
|
+
* cursor space `/events?sinceSequence=N` reads.
|
|
26
|
+
* - First write after headers: `retry: 3000`.
|
|
27
|
+
* - Heartbeat: comment line `: hb` every {@link SSE_HEARTBEAT_MS}
|
|
28
|
+
* (under ALB's 60s idle default); each tick doubles as a render-
|
|
29
|
+
* expiry probe.
|
|
30
|
+
*
|
|
31
|
+
* Cursor spaces: `Last-Event-ID` (browser-stamped on auto-reconnect)
|
|
32
|
+
* WINS over `?sinceSequence=`; the query seeds the first connect
|
|
33
|
+
* (EventSource cannot set headers). `?fromSeq=` mirrors
|
|
34
|
+
* `SubscribePayload.fromSeq` for the stream-buffer plane — note it is
|
|
35
|
+
* frozen in the EventSource URL, so duplicate `data` frames after an
|
|
36
|
+
* auto-reconnect are expected; ledger + stream deliveries are
|
|
37
|
+
* documented at-least-once and clients dedupe by seq.
|
|
38
|
+
*
|
|
39
|
+
* Auth: wsToken query gate cloned verbatim from `/state`
|
|
40
|
+
* (`api-renders-routes.ts`) — 401 missing/invalid/wrong-scope, 410
|
|
41
|
+
* expired, 404 unknown render, plus 503 when the channel is not yet
|
|
42
|
+
* constructed (pre-listen only). All gates run BEFORE any event-stream
|
|
43
|
+
* byte: EventSource fails the connection permanently on non-200, which
|
|
44
|
+
* is the client ladder's SSE→polling demotion signal.
|
|
45
|
+
*/
|
|
46
|
+
import type { GguiSessionStore } from "@ggui-ai/mcp-server-core";
|
|
47
|
+
import type { Express } from "express";
|
|
48
|
+
import type { GguiSessionChannelServer } from "./ggui-session-channel.js";
|
|
49
|
+
import type { Logger } from "./logger.js";
|
|
50
|
+
/**
|
|
51
|
+
* Heartbeat cadence (25s). Under ALB's 60s idle default with 2x+
|
|
52
|
+
* headroom; each tick also probes render expiry so an evicted /
|
|
53
|
+
* expired render ends the stream instead of idling forever.
|
|
54
|
+
*/
|
|
55
|
+
export declare const SSE_HEARTBEAT_MS = 25000;
|
|
56
|
+
export interface MountApiRendersStreamRouteOptions {
|
|
57
|
+
/** Express app to mount onto. */
|
|
58
|
+
readonly app: Express;
|
|
59
|
+
/** GguiSession store the gate + expiry probe read from. */
|
|
60
|
+
readonly renderStore: GguiSessionStore;
|
|
61
|
+
/** Shared HMAC secret the wsToken query credential verifies against. */
|
|
62
|
+
readonly secret: string;
|
|
63
|
+
/**
|
|
64
|
+
* Late-bound channel accessor — `createGguiSessionChannelServer`
|
|
65
|
+
* runs after route mounting, so the route resolves the channel per
|
|
66
|
+
* request (same pattern as `stream.channelProvider`). `null` is
|
|
67
|
+
* possible pre-listen only; the route answers 503.
|
|
68
|
+
*/
|
|
69
|
+
readonly channelProvider: () => GguiSessionChannelServer | null;
|
|
70
|
+
/** Heartbeat override for tests. Defaults to {@link SSE_HEARTBEAT_MS}. */
|
|
71
|
+
readonly heartbeatMs?: number;
|
|
72
|
+
/** Structured logger. */
|
|
73
|
+
readonly logger: Logger;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Mount `GET /api/sessions/:sessionId/stream` onto the express app.
|
|
77
|
+
* Returns nothing — the route self-registers.
|
|
78
|
+
*/
|
|
79
|
+
export declare function mountApiRendersStreamRoute(opts: MountApiRendersStreamRouteOptions): void;
|
|
80
|
+
//# sourceMappingURL=api-renders-stream-route.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"api-renders-stream-route.d.ts","sourceRoot":"","sources":["../src/api-renders-stream-route.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4CG;AAEH,OAAO,KAAK,EAAc,gBAAgB,EAAE,MAAM,0BAA0B,CAAC;AAG7E,OAAO,KAAK,EAAE,OAAO,EAAY,MAAM,SAAS,CAAC;AACjD,OAAO,KAAK,EAAE,wBAAwB,EAAE,MAAM,2BAA2B,CAAC;AAE1E,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,aAAa,CAAC;AAE1C;;;;GAIG;AACH,eAAO,MAAM,gBAAgB,QAAS,CAAC;AA2DvC,MAAM,WAAW,iCAAiC;IAChD,iCAAiC;IACjC,QAAQ,CAAC,GAAG,EAAE,OAAO,CAAC;IACtB,2DAA2D;IAC3D,QAAQ,CAAC,WAAW,EAAE,gBAAgB,CAAC;IACvC,wEAAwE;IACxE,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB;;;;;OAKG;IACH,QAAQ,CAAC,eAAe,EAAE,MAAM,wBAAwB,GAAG,IAAI,CAAC;IAChE,0EAA0E;IAC1E,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAC9B,yBAAyB;IACzB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;CACzB;AAED;;;GAGG;AACH,wBAAgB,0BAA0B,CAAC,IAAI,EAAE,iCAAiC,GAAG,IAAI,CA0MxF"}
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* wsToken-gated SSE live stream (the ladder's middle rung).
|
|
3
|
+
*
|
|
4
|
+
* GET /api/sessions/:sessionId/stream?wsToken=<token>[&sinceSequence=N][&fromSeq=M]
|
|
5
|
+
*
|
|
6
|
+
* Server-Sent Events delivery of the SAME ChannelFrame `{type,
|
|
7
|
+
* payload}` JSON the live-channel WS pushes — for hosts whose CSP
|
|
8
|
+
* blocks WebSocket upgrades but allows same-origin HTTP. The route
|
|
9
|
+
* registers a transport-neutral subscriber into the channel server via
|
|
10
|
+
* `attachExternalSubscriber`, so every fan-out plane WS subscribers
|
|
11
|
+
* ride (StreamFanout live tail, `props_update` / `render` /
|
|
12
|
+
* `drain_ack` walks, external broadcasts) reaches SSE subscribers with
|
|
13
|
+
* zero per-frame branching.
|
|
14
|
+
*
|
|
15
|
+
* Framing contract:
|
|
16
|
+
* - Every `data:` line is EXACTLY one ChannelFrame JSON — byte-same
|
|
17
|
+
* shape as the WS push (default event type, so `EventSource`'s
|
|
18
|
+
* `onmessage` fires).
|
|
19
|
+
* - `id:` = decimal GguiSessionEvent ledger seq, stamped ONLY on
|
|
20
|
+
* ledger-backed `render_event` replay frames (and the
|
|
21
|
+
* REPLAY_HORIZON_PASSED error frame, which carries the fresh
|
|
22
|
+
* high-water mark). Live frames are id-less — same ephemeral
|
|
23
|
+
* posture as WS; reconnects re-mount state from the ack snapshot.
|
|
24
|
+
* The browser's `Last-Event-ID` therefore lands on the exact
|
|
25
|
+
* cursor space `/events?sinceSequence=N` reads.
|
|
26
|
+
* - First write after headers: `retry: 3000`.
|
|
27
|
+
* - Heartbeat: comment line `: hb` every {@link SSE_HEARTBEAT_MS}
|
|
28
|
+
* (under ALB's 60s idle default); each tick doubles as a render-
|
|
29
|
+
* expiry probe.
|
|
30
|
+
*
|
|
31
|
+
* Cursor spaces: `Last-Event-ID` (browser-stamped on auto-reconnect)
|
|
32
|
+
* WINS over `?sinceSequence=`; the query seeds the first connect
|
|
33
|
+
* (EventSource cannot set headers). `?fromSeq=` mirrors
|
|
34
|
+
* `SubscribePayload.fromSeq` for the stream-buffer plane — note it is
|
|
35
|
+
* frozen in the EventSource URL, so duplicate `data` frames after an
|
|
36
|
+
* auto-reconnect are expected; ledger + stream deliveries are
|
|
37
|
+
* documented at-least-once and clients dedupe by seq.
|
|
38
|
+
*
|
|
39
|
+
* Auth: wsToken query gate cloned verbatim from `/state`
|
|
40
|
+
* (`api-renders-routes.ts`) — 401 missing/invalid/wrong-scope, 410
|
|
41
|
+
* expired, 404 unknown render, plus 503 when the channel is not yet
|
|
42
|
+
* constructed (pre-listen only). All gates run BEFORE any event-stream
|
|
43
|
+
* byte: EventSource fails the connection permanently on non-200, which
|
|
44
|
+
* is the client ladder's SSE→polling demotion signal.
|
|
45
|
+
*/
|
|
46
|
+
import { verifyToken } from "@ggui-ai/mcp-server-core";
|
|
47
|
+
/**
|
|
48
|
+
* Heartbeat cadence (25s). Under ALB's 60s idle default with 2x+
|
|
49
|
+
* headroom; each tick also probes render expiry so an evicted /
|
|
50
|
+
* expired render ends the stream instead of idling forever.
|
|
51
|
+
*/
|
|
52
|
+
export const SSE_HEARTBEAT_MS = 25_000;
|
|
53
|
+
/**
|
|
54
|
+
* SSE-backed {@link SubscriberSink}. Frames serialize to
|
|
55
|
+
* `data: <ChannelFrame JSON>\n\n` (JSON.stringify never emits raw
|
|
56
|
+
* newlines, so a single `data:` line is safe); `resumeId` prepends an
|
|
57
|
+
* `id: <seq>\n` line so the browser's `Last-Event-ID` advances on
|
|
58
|
+
* ledger-backed frames only.
|
|
59
|
+
*/
|
|
60
|
+
class SseSink {
|
|
61
|
+
res;
|
|
62
|
+
logger;
|
|
63
|
+
/**
|
|
64
|
+
* Invoked exactly once, from `end()`, BEFORE the response closes —
|
|
65
|
+
* the route assigns its teardown (clear heartbeat + detach) here so
|
|
66
|
+
* a channel-initiated `end` (pod shutdown, expiry probe) tears the
|
|
67
|
+
* subscriber down through the same single path as a client close.
|
|
68
|
+
*/
|
|
69
|
+
onEnd;
|
|
70
|
+
constructor(res, logger) {
|
|
71
|
+
this.res = res;
|
|
72
|
+
this.logger = logger;
|
|
73
|
+
}
|
|
74
|
+
isOpen() {
|
|
75
|
+
return !this.res.writableEnded && !this.res.destroyed;
|
|
76
|
+
}
|
|
77
|
+
write(frame, opts) {
|
|
78
|
+
if (!this.isOpen())
|
|
79
|
+
return;
|
|
80
|
+
try {
|
|
81
|
+
const idLine = opts?.resumeId !== undefined ? `id: ${opts.resumeId}\n` : "";
|
|
82
|
+
this.res.write(`${idLine}data: ${JSON.stringify(frame)}\n\n`);
|
|
83
|
+
}
|
|
84
|
+
catch (err) {
|
|
85
|
+
// Same posture as the WS send guard: per-frame write failures
|
|
86
|
+
// are logged, never propagated — a dead transport must not fail
|
|
87
|
+
// the fan-out caller.
|
|
88
|
+
this.logger.warn("sse_write_failed", { error: String(err) });
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
end(reason) {
|
|
92
|
+
const teardown = this.onEnd;
|
|
93
|
+
this.onEnd = undefined;
|
|
94
|
+
teardown?.();
|
|
95
|
+
this.logger.info("sse_stream_ended", { reason });
|
|
96
|
+
if (!this.res.writableEnded && !this.res.destroyed) {
|
|
97
|
+
this.res.end();
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
/** Strict non-negative decimal integer parse; `null` on anything else. */
|
|
102
|
+
function parseNonNegativeInt(raw) {
|
|
103
|
+
const n = Number(raw);
|
|
104
|
+
return Number.isInteger(n) && n >= 0 ? n : null;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Mount `GET /api/sessions/:sessionId/stream` onto the express app.
|
|
108
|
+
* Returns nothing — the route self-registers.
|
|
109
|
+
*/
|
|
110
|
+
export function mountApiRendersStreamRoute(opts) {
|
|
111
|
+
const { app, renderStore, secret, channelProvider, logger } = opts;
|
|
112
|
+
const heartbeatMs = opts.heartbeatMs ?? SSE_HEARTBEAT_MS;
|
|
113
|
+
app.get("/api/sessions/:sessionId/stream", async (req, res) => {
|
|
114
|
+
// ── Phase 1: plain-HTTP gates, BEFORE any event-stream byte ──
|
|
115
|
+
// (EventSource fails permanently on non-200 — the demotion signal.)
|
|
116
|
+
// CORS pre-gates: a gate verdict without ACAO is an OPAQUE error
|
|
117
|
+
// to a cross-origin frame — the 410 refresh-your-token signal
|
|
118
|
+
// becomes indistinguishable from a CSP block, so the runtime
|
|
119
|
+
// demotes off a perfectly healthy rung instead of refreshing.
|
|
120
|
+
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
121
|
+
const sessionId = req.params["sessionId"];
|
|
122
|
+
if (typeof sessionId !== "string" || sessionId.length === 0) {
|
|
123
|
+
res.status(400).type("text/plain").send("sessionId required");
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
const wsTokenRaw = req.query["wsToken"];
|
|
127
|
+
const wsToken = typeof wsTokenRaw === "string" ? wsTokenRaw : "";
|
|
128
|
+
if (wsToken.length === 0) {
|
|
129
|
+
res.status(401).type("text/plain").send("wsToken query required");
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
const verify = verifyToken(wsToken, secret, "ws");
|
|
133
|
+
if (!verify.ok) {
|
|
134
|
+
// 410 Gone for expired (matches `BOOTSTRAP_EXPIRED` semantics on
|
|
135
|
+
// the WS upgrade): once-valid but aged out — refresh, don't
|
|
136
|
+
// treat as hostile. 401 for tamper / wrong-kind / malformed.
|
|
137
|
+
if (verify.reason === "expired") {
|
|
138
|
+
res.status(410).type("text/plain").send("wsToken expired");
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
res.status(401).type("text/plain").send("wsToken invalid");
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
// Tenancy gate: the wsToken's claimed sessionId MUST match the
|
|
145
|
+
// URL's sessionId.
|
|
146
|
+
if (verify.claims.sessionId !== sessionId) {
|
|
147
|
+
res.status(401).type("text/plain").send("wsToken scope mismatch");
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
// Cursor queries — validated pre-headers so a caller bug is an
|
|
151
|
+
// honest 400, not a silently-ignored replay.
|
|
152
|
+
const sinceSequenceRaw = req.query["sinceSequence"];
|
|
153
|
+
let sinceSequence;
|
|
154
|
+
if (typeof sinceSequenceRaw === "string" && sinceSequenceRaw.length > 0) {
|
|
155
|
+
const parsed = parseNonNegativeInt(sinceSequenceRaw);
|
|
156
|
+
if (parsed === null) {
|
|
157
|
+
res.status(400).type("text/plain").send("sinceSequence must be a non-negative integer");
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
sinceSequence = parsed;
|
|
161
|
+
}
|
|
162
|
+
const fromSeqRaw = req.query["fromSeq"];
|
|
163
|
+
let fromSeq;
|
|
164
|
+
if (typeof fromSeqRaw === "string" && fromSeqRaw.length > 0) {
|
|
165
|
+
const parsed = parseNonNegativeInt(fromSeqRaw);
|
|
166
|
+
if (parsed === null) {
|
|
167
|
+
res.status(400).type("text/plain").send("fromSeq must be a non-negative integer");
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
fromSeq = parsed;
|
|
171
|
+
}
|
|
172
|
+
// Last-Event-ID (browser-stamped on auto-reconnect) WINS over the
|
|
173
|
+
// query seed. Malformed header → warn + fall back to the query
|
|
174
|
+
// (never a 4xx: the header is browser-controlled, and failing the
|
|
175
|
+
// reconnect permanently over a polyfill quirk would strand the
|
|
176
|
+
// client on a working credential).
|
|
177
|
+
const lastEventIdRaw = req.get("last-event-id");
|
|
178
|
+
if (typeof lastEventIdRaw === "string" && lastEventIdRaw.length > 0) {
|
|
179
|
+
const parsed = parseNonNegativeInt(lastEventIdRaw);
|
|
180
|
+
if (parsed === null) {
|
|
181
|
+
logger.warn("sse_last_event_id_malformed", {
|
|
182
|
+
sessionId,
|
|
183
|
+
lastEventId: lastEventIdRaw,
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
else {
|
|
187
|
+
sinceSequence = parsed;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
let stored;
|
|
191
|
+
try {
|
|
192
|
+
stored = await renderStore.get(sessionId);
|
|
193
|
+
}
|
|
194
|
+
catch (err) {
|
|
195
|
+
logger.warn("sse_stream_read_failed", { sessionId, error: String(err) });
|
|
196
|
+
res.status(500).type("text/plain").send("internal error");
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
if (!stored) {
|
|
200
|
+
// 404: render evicted / never existed — the browser's
|
|
201
|
+
// auto-reconnect lands here after an expiry-probe stream end,
|
|
202
|
+
// failing the EventSource permanently (clean rung exit).
|
|
203
|
+
res.status(404).type("text/plain").send("render not found");
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
// Tenancy gate (round 2): the wsToken's appId MUST match the
|
|
207
|
+
// render's appId.
|
|
208
|
+
if (verify.claims.appId !== stored.appId) {
|
|
209
|
+
res.status(401).type("text/plain").send("wsToken scope mismatch");
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
const channel = channelProvider();
|
|
213
|
+
if (channel === null) {
|
|
214
|
+
// Pre-listen only: mcpApps ⇒ renderChannel is enforced at
|
|
215
|
+
// composition, so a running server always has a channel. Honest
|
|
216
|
+
// guard rather than a crash on a boot-race request.
|
|
217
|
+
logger.warn("sse_stream_channel_unavailable", { sessionId });
|
|
218
|
+
res.status(503).type("text/plain").send("live channel not ready");
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
// ── Phase 2: open the event stream ──
|
|
222
|
+
res.setHeader("Content-Type", "text/event-stream; charset=utf-8");
|
|
223
|
+
res.setHeader("Cache-Control", "no-store, no-cache, must-revalidate");
|
|
224
|
+
res.setHeader("Connection", "keep-alive");
|
|
225
|
+
// Tell nginx-family proxies not to buffer the stream. Self-hosters
|
|
226
|
+
// behind proxies that ignore this must configure pass-through.
|
|
227
|
+
res.setHeader("X-Accel-Buffering", "no");
|
|
228
|
+
res.flushHeaders?.();
|
|
229
|
+
res.write("retry: 3000\n\n");
|
|
230
|
+
const sink = new SseSink(res, logger);
|
|
231
|
+
// Single idempotent teardown shared by every exit path: client
|
|
232
|
+
// close, channel-initiated sink.end (pod shutdown / expiry probe),
|
|
233
|
+
// attach failure.
|
|
234
|
+
let detachFn = null;
|
|
235
|
+
let torn = false;
|
|
236
|
+
const teardown = () => {
|
|
237
|
+
if (torn)
|
|
238
|
+
return;
|
|
239
|
+
torn = true;
|
|
240
|
+
clearInterval(heartbeat);
|
|
241
|
+
detachFn?.();
|
|
242
|
+
};
|
|
243
|
+
sink.onEnd = teardown;
|
|
244
|
+
res.on("close", teardown);
|
|
245
|
+
// Heartbeat: `: hb` comment (invisible to the EventSource JS API,
|
|
246
|
+
// visible to proxies/ALBs) + render-expiry probe. A vanished or
|
|
247
|
+
// expired render ends the stream; the auto-reconnect then hits the
|
|
248
|
+
// 404 pre-gate for the permanent verdict. Store-read failure on a
|
|
249
|
+
// tick keeps streaming (availability over freshness; next tick
|
|
250
|
+
// retries).
|
|
251
|
+
const heartbeat = setInterval(() => {
|
|
252
|
+
if (!sink.isOpen()) {
|
|
253
|
+
teardown();
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
res.write(": hb\n\n");
|
|
257
|
+
void (async () => {
|
|
258
|
+
let probe;
|
|
259
|
+
try {
|
|
260
|
+
probe = await renderStore.get(sessionId);
|
|
261
|
+
}
|
|
262
|
+
catch (err) {
|
|
263
|
+
logger.warn("sse_expiry_probe_failed", { sessionId, error: String(err) });
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
if (!probe || probe.expiresAt <= Date.now()) {
|
|
267
|
+
sink.end("session_expired");
|
|
268
|
+
}
|
|
269
|
+
})();
|
|
270
|
+
}, heartbeatMs);
|
|
271
|
+
heartbeat.unref?.();
|
|
272
|
+
// Identity synthesized from the verified wsToken claims — the same
|
|
273
|
+
// bootstrap shape the WS subscribe path builds: a render-scoped
|
|
274
|
+
// credential, not a person (see subscribe.ts on why it must never
|
|
275
|
+
// become the row's subject).
|
|
276
|
+
const identity = {
|
|
277
|
+
identity: {
|
|
278
|
+
kind: "user",
|
|
279
|
+
userId: sessionId,
|
|
280
|
+
workspaceId: stored.appId,
|
|
281
|
+
roles: [],
|
|
282
|
+
},
|
|
283
|
+
source: "apikey",
|
|
284
|
+
};
|
|
285
|
+
try {
|
|
286
|
+
const { detach } = await channel.attachExternalSubscriber({
|
|
287
|
+
sessionId,
|
|
288
|
+
appId: stored.appId,
|
|
289
|
+
identity,
|
|
290
|
+
sink,
|
|
291
|
+
...(sinceSequence !== undefined ? { sinceSequence } : {}),
|
|
292
|
+
...(fromSeq !== undefined ? { fromSeq } : {}),
|
|
293
|
+
});
|
|
294
|
+
detachFn = detach;
|
|
295
|
+
if (torn) {
|
|
296
|
+
// Client disconnected while the attach was in flight — the
|
|
297
|
+
// teardown ran with no handle; undo the registration now.
|
|
298
|
+
detach();
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
catch (err) {
|
|
302
|
+
// Lost race with render eviction (the pre-gate passed moments
|
|
303
|
+
// ago) or a store failure inside the subscribe tail. Headers are
|
|
304
|
+
// out, so no status rewrite — end the stream; the reconnect gets
|
|
305
|
+
// the authoritative pre-gate verdict.
|
|
306
|
+
logger.warn("sse_attach_failed", { sessionId, error: String(err) });
|
|
307
|
+
teardown();
|
|
308
|
+
sink.end("service_restart");
|
|
309
|
+
}
|
|
310
|
+
});
|
|
311
|
+
}
|