@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.
Files changed (67) hide show
  1. package/dist/api-renders-routes.d.ts +21 -0
  2. package/dist/api-renders-routes.d.ts.map +1 -1
  3. package/dist/api-renders-routes.js +54 -28
  4. package/dist/api-renders-stream-route.d.ts +80 -0
  5. package/dist/api-renders-stream-route.d.ts.map +1 -0
  6. package/dist/api-renders-stream-route.js +311 -0
  7. package/dist/build-mcp.d.ts +48 -7
  8. package/dist/build-mcp.d.ts.map +1 -1
  9. package/dist/build-mcp.js +87 -6
  10. package/dist/code-module-variant.d.ts +150 -0
  11. package/dist/code-module-variant.d.ts.map +1 -0
  12. package/dist/code-module-variant.js +243 -0
  13. package/dist/code-routes.d.ts +12 -2
  14. package/dist/code-routes.d.ts.map +1 -1
  15. package/dist/code-routes.js +12 -2
  16. package/dist/console-session-routes.d.ts.map +1 -1
  17. package/dist/console-session-routes.js +11 -0
  18. package/dist/control-service.d.ts +29 -3
  19. package/dist/control-service.d.ts.map +1 -1
  20. package/dist/control-service.js +26 -2
  21. package/dist/ggui-session-channel/action-ingress.d.ts +2 -2
  22. package/dist/ggui-session-channel/action-ingress.d.ts.map +1 -1
  23. package/dist/ggui-session-channel/channel-subscriptions.d.ts +4 -4
  24. package/dist/ggui-session-channel/channel-subscriptions.d.ts.map +1 -1
  25. package/dist/ggui-session-channel/internal-types.d.ts +63 -9
  26. package/dist/ggui-session-channel/internal-types.d.ts.map +1 -1
  27. package/dist/ggui-session-channel/outbound.d.ts +15 -5
  28. package/dist/ggui-session-channel/outbound.d.ts.map +1 -1
  29. package/dist/ggui-session-channel/outbound.js +64 -24
  30. package/dist/ggui-session-channel/socket-router.d.ts +8 -3
  31. package/dist/ggui-session-channel/socket-router.d.ts.map +1 -1
  32. package/dist/ggui-session-channel/socket-router.js +6 -1
  33. package/dist/ggui-session-channel/subscribe.d.ts +48 -3
  34. package/dist/ggui-session-channel/subscribe.d.ts.map +1 -1
  35. package/dist/ggui-session-channel/subscribe.js +97 -36
  36. package/dist/ggui-session-channel/subscriber-lifecycle.d.ts +23 -13
  37. package/dist/ggui-session-channel/subscriber-lifecycle.d.ts.map +1 -1
  38. package/dist/ggui-session-channel/subscriber-lifecycle.js +24 -11
  39. package/dist/ggui-session-channel.d.ts +58 -11
  40. package/dist/ggui-session-channel.d.ts.map +1 -1
  41. package/dist/ggui-session-channel.js +55 -19
  42. package/dist/health-routes.d.ts +19 -3
  43. package/dist/health-routes.d.ts.map +1 -1
  44. package/dist/health-routes.js +26 -18
  45. package/dist/index.d.ts +6 -3
  46. package/dist/index.d.ts.map +1 -1
  47. package/dist/index.js +13 -1
  48. package/dist/instructions-presets.js +10 -10
  49. package/dist/mcp-apps-outbound.d.ts +88 -11
  50. package/dist/mcp-apps-outbound.d.ts.map +1 -1
  51. package/dist/mcp-apps-outbound.js +470 -77
  52. package/dist/mcp-endpoint-routes.d.ts +23 -5
  53. package/dist/mcp-endpoint-routes.d.ts.map +1 -1
  54. package/dist/mcp-endpoint-routes.js +69 -1
  55. package/dist/oauth-as-routes.d.ts +11 -0
  56. package/dist/oauth-as-routes.d.ts.map +1 -1
  57. package/dist/oauth-as-routes.js +45 -1
  58. package/dist/oauth.d.ts.map +1 -1
  59. package/dist/oauth.js +8 -1
  60. package/dist/runtime-bundle-hash.d.ts +55 -0
  61. package/dist/runtime-bundle-hash.d.ts.map +1 -0
  62. package/dist/runtime-bundle-hash.js +85 -0
  63. package/dist/runtime-bundle-route.js +1 -1
  64. package/dist/server.d.ts +239 -61
  65. package/dist/server.d.ts.map +1 -1
  66. package/dist/server.js +355 -143
  67. 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;AAO9F,OAAO,EAEL,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,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,CAgV9D"}
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
- // Polling URL — the iframe-runtime's R6 polling-fallback path
153
- // composes its fetch URL from `render.pollingUrl`. The /state
154
- // endpoint IS that URL, so we stamp it here; without it the
155
- // iframe-runtime can't fall back when the WS upgrade fails.
156
- const requestHostForPolling = req.get("host") ?? "";
157
- const pollingBase = publicBaseUrl
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}://${requestHostForPolling}`;
160
- const pollingUrl = `${pollingBase}/api/sessions/${encodeURIComponent(stored.id)}/state`;
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 = publicBaseUrl
177
- ? publicBaseUrl.replace(/\/$/, "")
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 = publicBaseUrl
199
- ? publicBaseUrl.replace(/\/$/, "")
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
- pollingUrl,
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
- ...(view?.permissionsPolicy !== undefined && view.permissionsPolicy.length > 0
234
- ? { permissionsPolicy: view.permissionsPolicy }
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
+ }