@ggui-ai/mcp-server 0.2.0-alpha.4 → 0.4.0-rc.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 (152) hide show
  1. package/dist/admin-blueprints-transport.d.ts.map +1 -1
  2. package/dist/admin-blueprints-transport.js +2 -1
  3. package/dist/admin-oauth-providers-transport.d.ts.map +1 -1
  4. package/dist/admin-oauth-providers-transport.js +7 -5
  5. package/dist/api-renders-routes.d.ts +85 -0
  6. package/dist/api-renders-routes.d.ts.map +1 -0
  7. package/dist/api-renders-routes.js +372 -0
  8. package/dist/build-mcp.d.ts +2 -2
  9. package/dist/build-mcp.d.ts.map +1 -1
  10. package/dist/build-mcp.js +64 -5
  11. package/dist/code-routes.d.ts +47 -0
  12. package/dist/code-routes.d.ts.map +1 -0
  13. package/dist/code-routes.js +81 -0
  14. package/dist/code-store-fs.js +2 -2
  15. package/dist/console-auth.d.ts +10 -10
  16. package/dist/console-auth.d.ts.map +1 -1
  17. package/dist/console-auth.js +5 -5
  18. package/dist/console-blueprint-routes.d.ts +71 -0
  19. package/dist/console-blueprint-routes.d.ts.map +1 -0
  20. package/dist/console-blueprint-routes.js +348 -0
  21. package/dist/console-chat-routes.d.ts +80 -0
  22. package/dist/console-chat-routes.d.ts.map +1 -0
  23. package/dist/console-chat-routes.js +204 -0
  24. package/dist/console-config-routes.d.ts +37 -0
  25. package/dist/console-config-routes.d.ts.map +1 -0
  26. package/dist/console-config-routes.js +91 -0
  27. package/dist/console-headers.d.ts +1 -1
  28. package/dist/console-info-routes.d.ts +84 -0
  29. package/dist/console-info-routes.d.ts.map +1 -0
  30. package/dist/console-info-routes.js +135 -0
  31. package/dist/console-keys-routes.d.ts +50 -0
  32. package/dist/console-keys-routes.d.ts.map +1 -0
  33. package/dist/console-keys-routes.js +222 -0
  34. package/dist/console-llm-keys-routes.d.ts +47 -0
  35. package/dist/console-llm-keys-routes.d.ts.map +1 -0
  36. package/dist/console-llm-keys-routes.js +443 -0
  37. package/dist/console-mcp-tools-routes.d.ts +41 -0
  38. package/dist/console-mcp-tools-routes.d.ts.map +1 -0
  39. package/dist/console-mcp-tools-routes.js +60 -0
  40. package/dist/console-registry-routes.d.ts +66 -0
  41. package/dist/console-registry-routes.d.ts.map +1 -0
  42. package/dist/console-registry-routes.js +276 -0
  43. package/dist/console-session-routes.d.ts +89 -0
  44. package/dist/console-session-routes.d.ts.map +1 -0
  45. package/dist/console-session-routes.js +385 -0
  46. package/dist/console-sessions-routes.d.ts +52 -0
  47. package/dist/console-sessions-routes.d.ts.map +1 -0
  48. package/dist/console-sessions-routes.js +106 -0
  49. package/dist/console-static-routes.d.ts +54 -0
  50. package/dist/console-static-routes.d.ts.map +1 -0
  51. package/dist/console-static-routes.js +190 -0
  52. package/dist/console-theme-routes.d.ts +3 -3
  53. package/dist/console-theme-routes.js +1 -1
  54. package/dist/console-timeline.d.ts +5 -5
  55. package/dist/console-timeline.d.ts.map +1 -1
  56. package/dist/console-timeline.js +27 -26
  57. package/dist/console-welcome.js +2 -2
  58. package/dist/email-login.d.ts.map +1 -1
  59. package/dist/email-login.js +2 -3
  60. package/dist/ggui-session-channel/action-ingress.d.ts +54 -0
  61. package/dist/ggui-session-channel/action-ingress.d.ts.map +1 -0
  62. package/dist/ggui-session-channel/action-ingress.js +228 -0
  63. package/dist/ggui-session-channel/channel-subscriptions.d.ts +97 -0
  64. package/dist/ggui-session-channel/channel-subscriptions.d.ts.map +1 -0
  65. package/dist/ggui-session-channel/channel-subscriptions.js +224 -0
  66. package/dist/ggui-session-channel/internal-types.d.ts +102 -0
  67. package/dist/ggui-session-channel/internal-types.d.ts.map +1 -0
  68. package/dist/ggui-session-channel/internal-types.js +6 -0
  69. package/dist/ggui-session-channel/outbound.d.ts +81 -0
  70. package/dist/ggui-session-channel/outbound.d.ts.map +1 -0
  71. package/dist/ggui-session-channel/outbound.js +174 -0
  72. package/dist/ggui-session-channel/socket-router.d.ts +38 -0
  73. package/dist/ggui-session-channel/socket-router.d.ts.map +1 -0
  74. package/dist/ggui-session-channel/socket-router.js +213 -0
  75. package/dist/ggui-session-channel/subscribe.d.ts +165 -0
  76. package/dist/ggui-session-channel/subscribe.d.ts.map +1 -0
  77. package/dist/ggui-session-channel/subscribe.js +370 -0
  78. package/dist/ggui-session-channel/subscriber-lifecycle.d.ts +40 -0
  79. package/dist/ggui-session-channel/subscriber-lifecycle.d.ts.map +1 -0
  80. package/dist/ggui-session-channel/subscriber-lifecycle.js +123 -0
  81. package/dist/ggui-session-channel.d.ts +425 -0
  82. package/dist/ggui-session-channel.d.ts.map +1 -0
  83. package/dist/ggui-session-channel.js +262 -0
  84. package/dist/health-routes.d.ts +76 -0
  85. package/dist/health-routes.d.ts.map +1 -0
  86. package/dist/health-routes.js +145 -0
  87. package/dist/index.d.ts +10 -11
  88. package/dist/index.d.ts.map +1 -1
  89. package/dist/index.js +8 -9
  90. package/dist/instructions-presets.d.ts +3 -3
  91. package/dist/instructions-presets.js +24 -24
  92. package/dist/llm-backed-negotiator.d.ts +68 -67
  93. package/dist/llm-backed-negotiator.d.ts.map +1 -1
  94. package/dist/llm-backed-negotiator.js +82 -221
  95. package/dist/mcp-apps-outbound.d.ts +47 -48
  96. package/dist/mcp-apps-outbound.d.ts.map +1 -1
  97. package/dist/mcp-apps-outbound.js +154 -177
  98. package/dist/mcp-endpoint-routes.d.ts +88 -0
  99. package/dist/mcp-endpoint-routes.d.ts.map +1 -0
  100. package/dist/mcp-endpoint-routes.js +359 -0
  101. package/dist/mcp-mounts.d.ts +2 -76
  102. package/dist/mcp-mounts.d.ts.map +1 -1
  103. package/dist/mcp-mounts.js +0 -76
  104. package/dist/oauth-as-routes.d.ts +60 -0
  105. package/dist/oauth-as-routes.d.ts.map +1 -0
  106. package/dist/oauth-as-routes.js +82 -0
  107. package/dist/oauth-clients-routes.d.ts +39 -0
  108. package/dist/oauth-clients-routes.d.ts.map +1 -0
  109. package/dist/oauth-clients-routes.js +87 -0
  110. package/dist/oauth-login-types.d.ts +1 -20
  111. package/dist/oauth-login-types.d.ts.map +1 -1
  112. package/dist/oauth-login-types.js +30 -7
  113. package/dist/oauth-login.d.ts.map +1 -1
  114. package/dist/oauth-login.js +3 -2
  115. package/dist/oauth-providers-store.d.ts.map +1 -1
  116. package/dist/oauth-providers-store.js +5 -5
  117. package/dist/oauth.d.ts +9 -8
  118. package/dist/oauth.d.ts.map +1 -1
  119. package/dist/oauth.js +41 -19
  120. package/dist/pairing-transport.d.ts.map +1 -1
  121. package/dist/pairing-transport.js +2 -1
  122. package/dist/request-context.d.ts +2 -2
  123. package/dist/request-context.js +2 -2
  124. package/dist/reserved-validators.d.ts.map +1 -1
  125. package/dist/reserved-validators.js +9 -1
  126. package/dist/route-param.d.ts +9 -0
  127. package/dist/route-param.d.ts.map +1 -0
  128. package/dist/route-param.js +10 -0
  129. package/dist/runtime-bundle-route.d.ts +43 -0
  130. package/dist/runtime-bundle-route.d.ts.map +1 -0
  131. package/dist/runtime-bundle-route.js +80 -0
  132. package/dist/schema-compat.d.ts +64 -62
  133. package/dist/schema-compat.d.ts.map +1 -1
  134. package/dist/schema-compat.js +23 -51
  135. package/dist/server.d.ts +179 -193
  136. package/dist/server.d.ts.map +1 -1
  137. package/dist/server.js +644 -3759
  138. package/dist/storage.d.ts +5 -5
  139. package/dist/storage.d.ts.map +1 -1
  140. package/dist/storage.js +5 -5
  141. package/dist/thread-transport.d.ts.map +1 -1
  142. package/dist/thread-transport.js +4 -3
  143. package/dist/user-session-auth.d.ts +7 -21
  144. package/dist/user-session-auth.d.ts.map +1 -1
  145. package/dist/user-session-auth.js +7 -28
  146. package/package.json +16 -15
  147. package/dist/mcp-apps-inbound.d.ts +0 -86
  148. package/dist/mcp-apps-inbound.d.ts.map +0 -1
  149. package/dist/mcp-apps-inbound.js +0 -283
  150. package/dist/render-channel.d.ts +0 -694
  151. package/dist/render-channel.d.ts.map +0 -1
  152. package/dist/render-channel.js +0 -1775
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Outbound send / fan-out family for the live channel — the wire-write
3
+ * primitives every handler module shares (`send`, `sendError`,
4
+ * `sendChannelError`) plus the public fan-out surfaces
5
+ * (`sendToGguiSession`, `notifyGguiSessionCommit`, `sendPropsUpdate`,
6
+ * `sendDrainAck`, `externalBroadcast`) the channel server object
7
+ * delegates to.
8
+ *
9
+ * The behavior contracts for the public surfaces (validation posture,
10
+ * replay-buffer stamping, best-effort delivery) are documented on the
11
+ * `GguiSessionChannelServer` interface in `../ggui-session-channel.ts`;
12
+ * inline comments here cover impl-level decisions only.
13
+ */
14
+ import type { GguiSessionStore, GguiSessionStreamBuffer, StreamEnvelopeInput, StreamFanout } from "@ggui-ai/mcp-server-core";
15
+ import type { ErrorPayload, GguiSession, JsonObject, ReservedChannelValidator } from "@ggui-ai/protocol";
16
+ import type { WebSocketMessage } from "@ggui-ai/protocol/transport/websocket";
17
+ import type { WebSocket } from "ws";
18
+ import type { Logger } from "../logger.js";
19
+ import type { Subscriber } from "./internal-types.js";
20
+ /** Channel-scoped error codes carried on `channel_error` frames. */
21
+ export type ChannelErrorCode = "CHANNEL_UNKNOWN" | "CHANNEL_NOT_LOCAL" | "SESSION_NOT_FOUND" | "SUBSCRIBE_UNAUTHORIZED" | "POLL_FAILED";
22
+ export interface OutboundDeps {
23
+ readonly logger: Logger;
24
+ /** GguiSession backing store — streamSpec lookups for fan-out validation. */
25
+ readonly renderStore: GguiSessionStore;
26
+ /** Replay buffer — owns seq assignment + bounded replay storage. */
27
+ readonly streamBuffer: GguiSessionStreamBuffer;
28
+ /** Live-tail pub/sub seam for stream-envelope fan-out. */
29
+ readonly streamFanout: StreamFanout;
30
+ /**
31
+ * Flat set of all live WS subscribers — shared with the
32
+ * subscriber-lifecycle module (which owns membership); read-only
33
+ * here. Direct-to-WS frames (`props_update`, `render`, `drain_ack`,
34
+ * external broadcasts) iterate + filter by `sessionId`.
35
+ */
36
+ readonly wsSubscribers: ReadonlySet<Subscriber>;
37
+ /**
38
+ * Reserved-channel payload validators threaded from
39
+ * `GguiSessionChannelOptions.extraReservedValidators` — consulted by
40
+ * `assertStreamContract` ahead of the protocol built-ins.
41
+ */
42
+ readonly extraReservedValidators?: ReadonlyMap<string, ReservedChannelValidator>;
43
+ }
44
+ /** The send/fan-out helpers, bound to one channel instance. */
45
+ export interface Outbound {
46
+ /** Low-level wire write — skips closed sockets, warn-logs send failures. */
47
+ send(ws: WebSocket, msg: WebSocketMessage): void;
48
+ /** Emit an `error` frame with the canonical payload shape. */
49
+ sendError(ws: WebSocket, code: string, message: string, requestId?: string, details?: ErrorPayload["details"]): void;
50
+ /**
51
+ * Emit a `channel_error` frame to a specific subscriber. Used by the
52
+ * `channel_subscribe` handler for both subscribe-time rejections
53
+ * (`CHANNEL_UNKNOWN`, `CHANNEL_NOT_LOCAL`, `SESSION_NOT_FOUND`,
54
+ * `SUBSCRIBE_UNAUTHORIZED`) AND poll-time failures (`POLL_FAILED`).
55
+ *
56
+ * Direct-to-WS, not via fanOut — channel_error frames are
57
+ * per-subscriber and not stored in the replay buffer. A new
58
+ * subscriber on the same render will re-subscribe and discover the
59
+ * same error itself.
60
+ */
61
+ sendChannelError(ws: WebSocket, sessionId: string, channelName: string, code: ChannelErrorCode, message: string, requestId?: string, details?: ErrorPayload["details"]): void;
62
+ /** Impl behind {@link GguiSessionChannelServer.sendToGguiSession}. */
63
+ sendToGguiSession(delivery: StreamEnvelopeInput): Promise<{
64
+ seq: number;
65
+ }>;
66
+ /** Impl behind {@link GguiSessionChannelServer.notifyGguiSessionCommit}. */
67
+ notifyGguiSessionCommit(sessionId: string, render: GguiSession, matchType?: string): void;
68
+ /** Impl behind {@link GguiSessionChannelServer.sendPropsUpdate}. */
69
+ sendPropsUpdate(sessionId: string, props: JsonObject): Promise<void>;
70
+ /** Impl behind {@link GguiSessionChannelServer.sendDrainAck}. */
71
+ sendDrainAck(args: {
72
+ readonly sessionId: string;
73
+ readonly appId: string;
74
+ readonly eventId: string;
75
+ readonly drainedAt: string;
76
+ }): void;
77
+ /** Impl behind {@link GguiSessionChannelServer.externalBroadcast}. */
78
+ externalBroadcast(sessionId: string, frame: WebSocketMessage): void;
79
+ }
80
+ export declare function createOutbound(deps: OutboundDeps): Outbound;
81
+ //# sourceMappingURL=outbound.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"outbound.d.ts","sourceRoot":"","sources":["../../src/ggui-session-channel/outbound.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,KAAK,EACV,gBAAgB,EAChB,uBAAuB,EACvB,mBAAmB,EACnB,YAAY,EACb,MAAM,0BAA0B,CAAC;AAElC,OAAO,KAAK,EACV,YAAY,EACZ,WAAW,EACX,UAAU,EACV,wBAAwB,EAEzB,MAAM,mBAAmB,CAAC;AAC3B,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,uCAAuC,CAAC;AAC9E,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,IAAI,CAAC;AACpC,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,cAAc,CAAC;AAC3C,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,qBAAqB,CAAC;AAEtD,oEAAoE;AACpE,MAAM,MAAM,gBAAgB,GACxB,iBAAiB,GACjB,mBAAmB,GACnB,mBAAmB,GACnB,wBAAwB,GACxB,aAAa,CAAC;AAElB,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,6EAA6E;IAC7E,QAAQ,CAAC,WAAW,EAAE,gBAAgB,CAAC;IACvC,oEAAoE;IACpE,QAAQ,CAAC,YAAY,EAAE,uBAAuB,CAAC;IAC/C,0DAA0D;IAC1D,QAAQ,CAAC,YAAY,EAAE,YAAY,CAAC;IACpC;;;;;OAKG;IACH,QAAQ,CAAC,aAAa,EAAE,WAAW,CAAC,UAAU,CAAC,CAAC;IAChD;;;;OAIG;IACH,QAAQ,CAAC,uBAAuB,CAAC,EAAE,WAAW,CAAC,MAAM,EAAE,wBAAwB,CAAC,CAAC;CAClF;AAED,+DAA+D;AAC/D,MAAM,WAAW,QAAQ;IACvB,4EAA4E;IAC5E,IAAI,CAAC,EAAE,EAAE,SAAS,EAAE,GAAG,EAAE,gBAAgB,GAAG,IAAI,CAAC;IACjD,8DAA8D;IAC9D,SAAS,CACP,EAAE,EAAE,SAAS,EACb,IAAI,EAAE,MAAM,EACZ,OAAO,EAAE,MAAM,EACf,SAAS,CAAC,EAAE,MAAM,EAClB,OAAO,CAAC,EAAE,YAAY,CAAC,SAAS,CAAC,GAChC,IAAI,CAAC;IACR;;;;;;;;;;OAUG;IACH,gBAAgB,CACd,EAAE,EAAE,SAAS,EACb,SAAS,EAAE,MAAM,EACjB,WAAW,EAAE,MAAM,EACnB,IAAI,EAAE,gBAAgB,EACtB,OAAO,EAAE,MAAM,EACf,SAAS,CAAC,EAAE,MAAM,EAClB,OAAO,CAAC,EAAE,YAAY,CAAC,SAAS,CAAC,GAChC,IAAI,CAAC;IACR,sEAAsE;IACtE,iBAAiB,CAAC,QAAQ,EAAE,mBAAmB,GAAG,OAAO,CAAC;QAAE,GAAG,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAC3E,4EAA4E;IAC5E,uBAAuB,CAAC,SAAS,EAAE,MAAM,EAAE,MAAM,EAAE,WAAW,EAAE,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1F,oEAAoE;IACpE,eAAe,CAAC,SAAS,EAAE,MAAM,EAAE,KAAK,EAAE,UAAU,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACrE,iEAAiE;IACjE,YAAY,CAAC,IAAI,EAAE;QACjB,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;QAC3B,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;QACvB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;QACzB,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;KAC5B,GAAG,IAAI,CAAC;IACT,sEAAsE;IACtE,iBAAiB,CAAC,SAAS,EAAE,MAAM,EAAE,KAAK,EAAE,gBAAgB,GAAG,IAAI,CAAC;CACrE;AAED,wBAAgB,cAAc,CAAC,IAAI,EAAE,YAAY,GAAG,QAAQ,CAsM3D"}
@@ -0,0 +1,174 @@
1
+ /**
2
+ * Outbound send / fan-out family for the live channel — the wire-write
3
+ * primitives every handler module shares (`send`, `sendError`,
4
+ * `sendChannelError`) plus the public fan-out surfaces
5
+ * (`sendToGguiSession`, `notifyGguiSessionCommit`, `sendPropsUpdate`,
6
+ * `sendDrainAck`, `externalBroadcast`) the channel server object
7
+ * delegates to.
8
+ *
9
+ * The behavior contracts for the public surfaces (validation posture,
10
+ * replay-buffer stamping, best-effort delivery) are documented on the
11
+ * `GguiSessionChannelServer` interface in `../ggui-session-channel.ts`;
12
+ * inline comments here cover impl-level decisions only.
13
+ */
14
+ import { assertStreamContract } from "@ggui-ai/mcp-server-handlers/renders";
15
+ export function createOutbound(deps) {
16
+ function send(ws, msg) {
17
+ if (ws.readyState !== ws.OPEN)
18
+ return;
19
+ try {
20
+ ws.send(JSON.stringify(msg));
21
+ }
22
+ catch (err) {
23
+ deps.logger.warn("render_channel_send_failed", { error: String(err) });
24
+ }
25
+ }
26
+ function sendError(ws, code, message, requestId, details) {
27
+ send(ws, {
28
+ type: "error",
29
+ payload: { code, message, ...(details !== undefined ? { details } : {}) },
30
+ ...(requestId ? { requestId } : {}),
31
+ });
32
+ }
33
+ function sendChannelError(ws, sessionId, channelName, code, message, requestId, details) {
34
+ send(ws, {
35
+ type: "channel_error",
36
+ payload: {
37
+ sessionId,
38
+ channelName,
39
+ code,
40
+ message,
41
+ ...(details !== undefined ? { details } : {}),
42
+ },
43
+ ...(requestId ? { requestId } : {}),
44
+ });
45
+ }
46
+ /**
47
+ * Stamp a delivery through the replay buffer and fan it out to every
48
+ * subscriber of the render, honoring the per-subscriber replay
49
+ * cursor. Backs the public `sendToGguiSession` entry point.
50
+ *
51
+ * Caller is responsible for validating `delivery.payload` against
52
+ * the active streamSpec BEFORE calling — the fan-out here trusts
53
+ * its input. Reserved-channel deliveries (e.g. `_ggui:lifecycle`)
54
+ * bypass the streamSpec check upstream via
55
+ * `assertStreamContract`.
56
+ */
57
+ async function fanOut(delivery, activeStreamSpec) {
58
+ const { envelope } = await deps.streamBuffer.record(delivery, activeStreamSpec);
59
+ // Publish to the seam — InProcessStreamFanout walks its subscriber
60
+ // queues synchronously inside publish(), so no real async hop. The
61
+ // pump loop on each WS subscriber yields the envelope, applies the
62
+ // per-sub replay-cursor filter, and sends to the WS. Fire-and-forget
63
+ // because publish() never throws on the in-process impl, and an
64
+ // external pubsub-fanout failure here would already be persisted to
65
+ // the GguiSessionStreamBuffer for replay-recovery on reconnect.
66
+ void deps.streamFanout.publish({ sessionId: envelope.sessionId, envelope });
67
+ return { seq: envelope.seq };
68
+ }
69
+ async function sendToGguiSession(delivery) {
70
+ // Outbound fan-out enforcement (defense-in-depth parity with
71
+ // hosted `handle-data.ts`). Re-validates the delivery's payload
72
+ // against the render's streamSpec BEFORE delivery — so a future
73
+ // OSS mutation handler that bypasses the emit-side check can't
74
+ // fan out malformed data to subscribers. Throws
75
+ // ContractViolationError{tool:'ggui_emit'} on violation;
76
+ // caller decides what to do (log, rethrow, wrap).
77
+ const stored = await deps.renderStore.get(delivery.sessionId);
78
+ const activeEntry = stored?.render;
79
+ const streamSpec = activeEntry !== undefined && activeEntry.type !== "mcpApps" && activeEntry.type !== "system"
80
+ ? activeEntry.streamSpec
81
+ : undefined;
82
+ assertStreamContract(streamSpec, delivery.channel, delivery.payload, deps.extraReservedValidators);
83
+ return fanOut(delivery, streamSpec);
84
+ }
85
+ function notifyGguiSessionCommit(sessionId, render, matchType) {
86
+ // Best-effort fan-out to every live subscriber bound to this
87
+ // render. NOT routed through the replay buffer — see the
88
+ // `notifyGguiSessionCommit` JSDoc on the public interface for why
89
+ // fresh subscribers rely on `ack.render` instead of a replay frame.
90
+ // NOT routed through StreamFanout either — `type: 'render'` is a
91
+ // distinct WebSocket message type. Filter the flat WS-subscriber
92
+ // set by sessionId; N is typically 1-2 (multi-tab render sharing).
93
+ const payload = matchType !== undefined ? { session: render, matchType } : { session: render };
94
+ for (const sub of deps.wsSubscribers) {
95
+ if (sub.sessionId !== sessionId)
96
+ continue;
97
+ send(sub.ws, { type: "render", payload });
98
+ }
99
+ }
100
+ /**
101
+ * Best-effort + orphan-tolerant per the docstring on the public
102
+ * `sendPropsUpdate` method.
103
+ */
104
+ async function sendPropsUpdate(sessionId, props) {
105
+ let stored;
106
+ try {
107
+ stored = await deps.renderStore.get(sessionId);
108
+ }
109
+ catch (err) {
110
+ deps.logger.warn("render_channel_props_update_lookup_failed", {
111
+ sessionId,
112
+ error: String(err),
113
+ });
114
+ return;
115
+ }
116
+ if (!stored) {
117
+ deps.logger.warn("render_channel_props_update_orphan", {
118
+ sessionId,
119
+ });
120
+ return;
121
+ }
122
+ // Filter the flat WS-subscriber set by sessionId; same posture as
123
+ // `notifyGguiSessionCommit`. `send()` already silently skips closed sockets
124
+ // and logs (but doesn't throw on) per-subscriber send failures, so
125
+ // the calling handler can't be made to fail by a dead WebSocket.
126
+ for (const sub of deps.wsSubscribers) {
127
+ if (sub.sessionId !== sessionId)
128
+ continue;
129
+ send(sub.ws, {
130
+ type: "props_update",
131
+ payload: { sessionId, props },
132
+ });
133
+ }
134
+ }
135
+ function sendDrainAck({ sessionId, appId, eventId, drainedAt, }) {
136
+ // Server-side fan-out for the action-drain ack.
137
+ // Filter the flat WS-subscriber set by sessionId (same posture
138
+ // as `sendPropsUpdate`). No persistence; subscribers that
139
+ // missed the frame fall back to their 10s claim timer, which
140
+ // the atomic pop resolves cleanly.
141
+ for (const sub of deps.wsSubscribers) {
142
+ if (sub.sessionId !== sessionId)
143
+ continue;
144
+ send(sub.ws, {
145
+ type: "drain_ack",
146
+ payload: { sessionId, appId, eventId, drainedAt },
147
+ });
148
+ }
149
+ }
150
+ function externalBroadcast(sessionId, frame) {
151
+ // Walk the flat subscriber set; filter to matching sessionId.
152
+ // `send()` already guards closed sockets and logs (but doesn't
153
+ // throw on) per-subscriber failures, so the caller (an external
154
+ // pubsub on-message handler) can't be made to fail by a dead
155
+ // WebSocket. No GguiSessionStore lookup — the publisher already
156
+ // validated; this seam is the cross-process delivery path, not
157
+ // the re-validation point.
158
+ for (const sub of deps.wsSubscribers) {
159
+ if (sub.sessionId !== sessionId)
160
+ continue;
161
+ send(sub.ws, frame);
162
+ }
163
+ }
164
+ return {
165
+ send,
166
+ sendError,
167
+ sendChannelError,
168
+ sendToGguiSession,
169
+ notifyGguiSessionCommit,
170
+ sendPropsUpdate,
171
+ sendDrainAck,
172
+ externalBroadcast,
173
+ };
174
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Per-socket message routing + WS lifecycle wiring for the live
3
+ * channel — the `connection` handler (with the per-socket inbound
4
+ * ordering chain), the pre-subscribe identity / cookie bindings, the
5
+ * `onMessage` type dispatcher, and the observation-message ingress
6
+ * (`host_context_observed`) it routes.
7
+ */
8
+ import type { GguiSessionStore } from "@ggui-ai/mcp-server-core";
9
+ import type { WebSocket, WebSocketServer } from "ws";
10
+ import type { Logger } from "../logger.js";
11
+ import type { ActionIngress } from "./action-ingress.js";
12
+ import type { ChannelSubscriptions } from "./channel-subscriptions.js";
13
+ import type { Subscriber } from "./internal-types.js";
14
+ import type { Outbound } from "./outbound.js";
15
+ import type { SubscribeHandlers } from "./subscribe.js";
16
+ import type { SubscriberLifecycle } from "./subscriber-lifecycle.js";
17
+ export interface SocketRouterDeps {
18
+ readonly logger: Logger;
19
+ /** GguiSession backing store — observation-message patches persist here. */
20
+ readonly renderStore: GguiSessionStore;
21
+ /** ws → subscriber reverse index — the dispatcher's per-frame lookup. */
22
+ readonly subscribersByWs: WeakMap<WebSocket, Subscriber>;
23
+ readonly send: Outbound["send"];
24
+ readonly sendError: Outbound["sendError"];
25
+ readonly unregister: SubscriberLifecycle["unregister"];
26
+ readonly handleSubscribe: SubscribeHandlers["handleSubscribe"];
27
+ readonly handleInboundAction: ActionIngress["handleInboundAction"];
28
+ readonly handleChannelSubscribe: ChannelSubscriptions["handleChannelSubscribe"];
29
+ readonly handleChannelUnsubscribe: ChannelSubscriptions["handleChannelUnsubscribe"];
30
+ }
31
+ /**
32
+ * Wire the channel's `connection` handling onto `wss`. Reads the
33
+ * upgrade-time identity / cookie bindings the upgrade phase stashed on
34
+ * the request (see {@link UpgradeBindings}) and serializes inbound
35
+ * frame processing per socket.
36
+ */
37
+ export declare function attachSocketRouter(wss: WebSocketServer, deps: SocketRouterDeps): void;
38
+ //# sourceMappingURL=socket-router.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"socket-router.d.ts","sourceRoot":"","sources":["../../src/ggui-session-channel/socket-router.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,KAAK,EAAoB,gBAAgB,EAAE,MAAM,0BAA0B,CAAC;AAInF,OAAO,KAAK,EAAE,SAAS,EAAE,eAAe,EAAE,MAAM,IAAI,CAAC;AACrD,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,cAAc,CAAC;AAC3C,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AACzD,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,4BAA4B,CAAC;AACvE,OAAO,KAAK,EAAE,UAAU,EAAmB,MAAM,qBAAqB,CAAC;AACvE,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,eAAe,CAAC;AAC9C,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,gBAAgB,CAAC;AACxD,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,2BAA2B,CAAC;AAErE,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,4EAA4E;IAC5E,QAAQ,CAAC,WAAW,EAAE,gBAAgB,CAAC;IACvC,yEAAyE;IACzE,QAAQ,CAAC,eAAe,EAAE,OAAO,CAAC,SAAS,EAAE,UAAU,CAAC,CAAC;IACzD,QAAQ,CAAC,IAAI,EAAE,QAAQ,CAAC,MAAM,CAAC,CAAC;IAChC,QAAQ,CAAC,SAAS,EAAE,QAAQ,CAAC,WAAW,CAAC,CAAC;IAC1C,QAAQ,CAAC,UAAU,EAAE,mBAAmB,CAAC,YAAY,CAAC,CAAC;IACvD,QAAQ,CAAC,eAAe,EAAE,iBAAiB,CAAC,iBAAiB,CAAC,CAAC;IAC/D,QAAQ,CAAC,mBAAmB,EAAE,aAAa,CAAC,qBAAqB,CAAC,CAAC;IACnE,QAAQ,CAAC,sBAAsB,EAAE,oBAAoB,CAAC,wBAAwB,CAAC,CAAC;IAChF,QAAQ,CAAC,wBAAwB,EAAE,oBAAoB,CAAC,0BAA0B,CAAC,CAAC;CACrF;AAED;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,eAAe,EAAE,IAAI,EAAE,gBAAgB,GAAG,IAAI,CAkQrF"}
@@ -0,0 +1,213 @@
1
+ /**
2
+ * Per-socket message routing + WS lifecycle wiring for the live
3
+ * channel — the `connection` handler (with the per-socket inbound
4
+ * ordering chain), the pre-subscribe identity / cookie bindings, the
5
+ * `onMessage` type dispatcher, and the observation-message ingress
6
+ * (`host_context_observed`) it routes.
7
+ */
8
+ /**
9
+ * Wire the channel's `connection` handling onto `wss`. Reads the
10
+ * upgrade-time identity / cookie bindings the upgrade phase stashed on
11
+ * the request (see {@link UpgradeBindings}) and serializes inbound
12
+ * frame processing per socket.
13
+ */
14
+ export function attachSocketRouter(wss, deps) {
15
+ /**
16
+ * Tenancy guard for client-emitted observation messages
17
+ * (`host_context_observed` today). Returns `false`
18
+ * AND emits the appropriate error frame when:
19
+ *
20
+ * - the socket has no bound subscriber (NOT_SUBSCRIBED)
21
+ * - payload.sessionId doesn't match the subscriber binding
22
+ * (SESSION_MISMATCH)
23
+ *
24
+ * Subscriber binding is the authoritative tenancy scope. The wire
25
+ * payload's sessionId is belt-and-suspenders so the error message
26
+ * can be specific; appId narrows transparently via the binding.
27
+ */
28
+ function checkSubscriberTenancy(ws, sub, payload, messageType, requestId) {
29
+ if (!sub) {
30
+ deps.sendError(ws, "NOT_SUBSCRIBED", `Send a 'subscribe' message first before '${messageType}'`, requestId);
31
+ return false;
32
+ }
33
+ if (payload.sessionId !== sub.sessionId) {
34
+ deps.sendError(ws, "SESSION_MISMATCH", `${messageType} payload id '${payload.sessionId ?? "<missing>"}' does not match subscriber render '${sub.sessionId}'`, requestId);
35
+ return false;
36
+ }
37
+ return true;
38
+ }
39
+ /**
40
+ * Persist an observation-message-driven render patch. Fire-and-
41
+ * forget at the wire layer (no response frame); warn-logs persistence
42
+ * errors so transient store failures stay observable without
43
+ * disrupting the iframe. The iframe's local state is already in the
44
+ * new shape; the next round-trip re-emits whatever the persistence
45
+ * layer lost.
46
+ */
47
+ async function applyGguiSessionPatch(sessionId, appId, messageType, patch) {
48
+ try {
49
+ await deps.renderStore.update(sessionId, patch);
50
+ }
51
+ catch (err) {
52
+ deps.logger.warn("render_channel_observation_persist_failed", {
53
+ messageType,
54
+ sessionId,
55
+ appId,
56
+ error: err instanceof Error ? err.message : String(err),
57
+ });
58
+ }
59
+ }
60
+ async function onMessage(ws, raw) {
61
+ const sub = deps.subscribersByWs.get(ws);
62
+ let message;
63
+ try {
64
+ message = JSON.parse(raw);
65
+ }
66
+ catch {
67
+ deps.sendError(ws, "INVALID_JSON", "Message is not valid JSON");
68
+ return;
69
+ }
70
+ if (!message || typeof message !== "object" || typeof message.type !== "string") {
71
+ deps.sendError(ws, "INVALID_MESSAGE", "Message is missing a `type` discriminator");
72
+ return;
73
+ }
74
+ switch (message.type) {
75
+ case "subscribe": {
76
+ // `subscribe` is the only message allowed before identity is
77
+ // bound to a render. Identity was already resolved at upgrade
78
+ // time; we just need to register the subscriber.
79
+ const identity = pendingIdentity.get(ws);
80
+ if (!identity) {
81
+ deps.sendError(ws, "UNAUTHENTICATED", "No identity bound to this socket", message.requestId);
82
+ return;
83
+ }
84
+ // Cookie-scope enforcement: when the upgrade was authenticated
85
+ // via an console cookie, the subscribe payload MUST target
86
+ // the render the cookie was issued for. A valid cookie for
87
+ // render A can't be used to open render B.
88
+ const cookieBound = pendingCookieBinding.get(ws);
89
+ if (cookieBound) {
90
+ if (message.payload.sessionId !== cookieBound.sessionId) {
91
+ deps.sendError(ws, "DEVTOOL_COOKIE_SESSION_MISMATCH", `Embedded-ui cookie is bound to render '${cookieBound.sessionId}' but subscribe targets '${message.payload.sessionId}'`, message.requestId);
92
+ return;
93
+ }
94
+ // `appId` is optional on the wire (SPEC §12.2): absent
95
+ // resolves to the cookie's bound appId inside
96
+ // `handleSubscribe` — only a PRESENT contradicting value is
97
+ // a mismatch.
98
+ if (message.payload.appId !== undefined && message.payload.appId !== cookieBound.appId) {
99
+ deps.sendError(ws, "DEVTOOL_COOKIE_APP_MISMATCH", `Embedded-ui cookie is bound to app '${cookieBound.appId}' but subscribe targets '${message.payload.appId}'`, message.requestId);
100
+ return;
101
+ }
102
+ }
103
+ await deps.handleSubscribe(ws, identity, message, cookieBound);
104
+ pendingIdentity.delete(ws);
105
+ pendingCookieBinding.delete(ws);
106
+ return;
107
+ }
108
+ case "ping":
109
+ deps.send(ws, {
110
+ type: "pong",
111
+ payload: {},
112
+ ...(message.requestId ? { requestId: message.requestId } : {}),
113
+ });
114
+ return;
115
+ case "action":
116
+ if (!sub) {
117
+ deps.sendError(ws, "NOT_SUBSCRIBED", "Send a 'subscribe' message first before 'action'", message.requestId);
118
+ return;
119
+ }
120
+ await deps.handleInboundAction(ws, sub, message);
121
+ return;
122
+ case "channel_subscribe":
123
+ if (!sub) {
124
+ deps.sendError(ws, "NOT_SUBSCRIBED", "Send a 'subscribe' message first before 'channel_subscribe'", message.requestId);
125
+ return;
126
+ }
127
+ await deps.handleChannelSubscribe(ws, sub, message);
128
+ return;
129
+ case "channel_unsubscribe":
130
+ if (!sub) {
131
+ // No subscriber → nothing was subscribed → no-op silently.
132
+ // Returning an error would leak "is this socket subscribed"
133
+ // state for unauthenticated clients.
134
+ return;
135
+ }
136
+ deps.handleChannelUnsubscribe(ws, sub, message);
137
+ return;
138
+ case "host_context_observed":
139
+ // The iframe-runtime echoes its captured `McpUiHostContext`
140
+ // after `ui/initialize` resolves and on every
141
+ // `ui/notifications/host-context-changed` notification. Persist
142
+ // on `GguiSession.hostContext` so `ggui_handshake` and
143
+ // `ggui_consume` can surface it to the agent on subsequent
144
+ // turns. Fire-and-forget on the client side; no response.
145
+ if (!checkSubscriberTenancy(ws, sub, message.payload, message.type, message.requestId)) {
146
+ return;
147
+ }
148
+ await applyGguiSessionPatch(sub.sessionId, sub.appId, message.type, {
149
+ hostContext: message.payload.hostContext,
150
+ lastActivityAt: Date.now(),
151
+ });
152
+ return;
153
+ default:
154
+ deps.sendError(ws, "UNSUPPORTED_MESSAGE", `Unsupported message type: ${String(message.type)}`, message.requestId);
155
+ }
156
+ }
157
+ /**
158
+ * During the pre-subscribe window, a ws has a resolved identity but
159
+ * no render-bound subscriber yet. We hold the identity here until
160
+ * the first `subscribe` lands; once it does, the subscriber record
161
+ * owns the identity and this entry is cleared.
162
+ */
163
+ const pendingIdentity = new WeakMap();
164
+ /**
165
+ * Embedded-ui cookie binding established at upgrade. When present,
166
+ * `handleSubscribe` enforces `subscribe.sessionId === bound.sessionId`
167
+ * so a valid cookie can't be used to open a render it wasn't
168
+ * issued for. Parallel to {@link pendingIdentity} — same lifetime,
169
+ * same WeakMap rationale.
170
+ */
171
+ const pendingCookieBinding = new WeakMap();
172
+ wss.on("connection", (ws, req) => {
173
+ // Bind the resolved identity from the upgrade phase. It was
174
+ // attached to the request object in handleUpgrade.
175
+ const identity = req.__gguiIdentity;
176
+ if (identity)
177
+ pendingIdentity.set(ws, identity);
178
+ // Likewise for any cookie binding.
179
+ const cookieBound = req.__gguiCookieBound;
180
+ if (cookieBound)
181
+ pendingCookieBinding.set(ws, cookieBound);
182
+ // Per-socket inbound processing chain. The WebSocket wire is an
183
+ // ORDERED frame stream, so inbound handling must observe arrival
184
+ // order even though the handlers are async: without the chain, a
185
+ // client that pipelines `subscribe` + `action` in one TCP segment
186
+ // (both `message` events fire in the same macrotask) gets the
187
+ // action handled while `handleSubscribe` is parked at its first
188
+ // `await` — the socket has no bound subscriber yet, and a
189
+ // correctly-ordered client is rejected with NOT_SUBSCRIBED.
190
+ // Serializing per socket restores the wire's ordering on the
191
+ // processing side; distinct sockets stay fully concurrent.
192
+ let inboundChain = Promise.resolve();
193
+ ws.on("message", (raw) => {
194
+ // `ws.on('message')` delivers Buffer/ArrayBuffer/Buffer[] depending
195
+ // on frame type; normalize to string.
196
+ const text = typeof raw === "string" ? raw : raw.toString("utf8");
197
+ inboundChain = inboundChain.then(() => onMessage(ws, text).catch((err) => {
198
+ // Catch INSIDE the chain link so one failed message never
199
+ // poisons the chain for subsequent frames.
200
+ deps.logger.error("render_channel_message_failed", {
201
+ error: String(err),
202
+ });
203
+ }));
204
+ });
205
+ ws.on("close", () => {
206
+ deps.unregister(ws);
207
+ pendingIdentity.delete(ws);
208
+ });
209
+ ws.on("error", (err) => {
210
+ deps.logger.warn("render_channel_socket_error", { error: String(err) });
211
+ });
212
+ });
213
+ }