@ggui-ai/mcp-server 0.2.0-alpha.3 → 0.3.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.
- package/dist/admin-blueprints-transport.d.ts.map +1 -1
- package/dist/admin-blueprints-transport.js +2 -1
- package/dist/admin-oauth-providers-transport.d.ts.map +1 -1
- package/dist/admin-oauth-providers-transport.js +7 -5
- package/dist/api-renders-routes.d.ts +85 -0
- package/dist/api-renders-routes.d.ts.map +1 -0
- package/dist/api-renders-routes.js +372 -0
- package/dist/build-mcp.d.ts +1 -1
- package/dist/build-mcp.d.ts.map +1 -1
- package/dist/build-mcp.js +39 -5
- package/dist/code-routes.d.ts +47 -0
- package/dist/code-routes.d.ts.map +1 -0
- package/dist/code-routes.js +81 -0
- package/dist/code-store-fs.js +2 -2
- package/dist/console-auth.d.ts +10 -10
- package/dist/console-auth.d.ts.map +1 -1
- package/dist/console-auth.js +5 -5
- package/dist/console-blueprint-routes.d.ts +71 -0
- package/dist/console-blueprint-routes.d.ts.map +1 -0
- package/dist/console-blueprint-routes.js +348 -0
- package/dist/console-chat-routes.d.ts +80 -0
- package/dist/console-chat-routes.d.ts.map +1 -0
- package/dist/console-chat-routes.js +182 -0
- package/dist/console-config-routes.d.ts +37 -0
- package/dist/console-config-routes.d.ts.map +1 -0
- package/dist/console-config-routes.js +91 -0
- package/dist/console-headers.d.ts +1 -1
- package/dist/console-info-routes.d.ts +84 -0
- package/dist/console-info-routes.d.ts.map +1 -0
- package/dist/console-info-routes.js +135 -0
- package/dist/console-keys-routes.d.ts +50 -0
- package/dist/console-keys-routes.d.ts.map +1 -0
- package/dist/console-keys-routes.js +222 -0
- package/dist/console-llm-keys-routes.d.ts +47 -0
- package/dist/console-llm-keys-routes.d.ts.map +1 -0
- package/dist/console-llm-keys-routes.js +443 -0
- package/dist/console-mcp-tools-routes.d.ts +41 -0
- package/dist/console-mcp-tools-routes.d.ts.map +1 -0
- package/dist/console-mcp-tools-routes.js +60 -0
- package/dist/console-registry-routes.d.ts +66 -0
- package/dist/console-registry-routes.d.ts.map +1 -0
- package/dist/console-registry-routes.js +276 -0
- package/dist/console-session-routes.d.ts +89 -0
- package/dist/console-session-routes.d.ts.map +1 -0
- package/dist/console-session-routes.js +385 -0
- package/dist/console-sessions-routes.d.ts +52 -0
- package/dist/console-sessions-routes.d.ts.map +1 -0
- package/dist/console-sessions-routes.js +106 -0
- package/dist/console-static-routes.d.ts +54 -0
- package/dist/console-static-routes.d.ts.map +1 -0
- package/dist/console-static-routes.js +190 -0
- package/dist/console-theme-routes.d.ts +3 -3
- package/dist/console-theme-routes.js +1 -1
- package/dist/console-timeline.d.ts +5 -5
- package/dist/console-timeline.d.ts.map +1 -1
- package/dist/console-timeline.js +27 -26
- package/dist/console-welcome.js +2 -2
- package/dist/email-login.d.ts.map +1 -1
- package/dist/email-login.js +2 -3
- package/dist/ggui-session-channel/action-ingress.d.ts +54 -0
- package/dist/ggui-session-channel/action-ingress.d.ts.map +1 -0
- package/dist/ggui-session-channel/action-ingress.js +228 -0
- package/dist/ggui-session-channel/channel-subscriptions.d.ts +97 -0
- package/dist/ggui-session-channel/channel-subscriptions.d.ts.map +1 -0
- package/dist/ggui-session-channel/channel-subscriptions.js +224 -0
- package/dist/ggui-session-channel/internal-types.d.ts +102 -0
- package/dist/ggui-session-channel/internal-types.d.ts.map +1 -0
- package/dist/ggui-session-channel/internal-types.js +6 -0
- package/dist/ggui-session-channel/outbound.d.ts +81 -0
- package/dist/ggui-session-channel/outbound.d.ts.map +1 -0
- package/dist/ggui-session-channel/outbound.js +174 -0
- package/dist/ggui-session-channel/socket-router.d.ts +38 -0
- package/dist/ggui-session-channel/socket-router.d.ts.map +1 -0
- package/dist/ggui-session-channel/socket-router.js +213 -0
- package/dist/ggui-session-channel/subscribe.d.ts +165 -0
- package/dist/ggui-session-channel/subscribe.d.ts.map +1 -0
- package/dist/ggui-session-channel/subscribe.js +370 -0
- package/dist/ggui-session-channel/subscriber-lifecycle.d.ts +40 -0
- package/dist/ggui-session-channel/subscriber-lifecycle.d.ts.map +1 -0
- package/dist/ggui-session-channel/subscriber-lifecycle.js +123 -0
- package/dist/ggui-session-channel.d.ts +425 -0
- package/dist/ggui-session-channel.d.ts.map +1 -0
- package/dist/ggui-session-channel.js +262 -0
- package/dist/health-routes.d.ts +76 -0
- package/dist/health-routes.d.ts.map +1 -0
- package/dist/health-routes.js +145 -0
- package/dist/index.d.ts +10 -11
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -9
- package/dist/instructions-presets.d.ts +3 -3
- package/dist/instructions-presets.js +25 -25
- package/dist/llm-backed-negotiator.d.ts +68 -67
- package/dist/llm-backed-negotiator.d.ts.map +1 -1
- package/dist/llm-backed-negotiator.js +82 -248
- package/dist/mcp-apps-outbound.d.ts +47 -48
- package/dist/mcp-apps-outbound.d.ts.map +1 -1
- package/dist/mcp-apps-outbound.js +204 -183
- package/dist/mcp-endpoint-routes.d.ts +88 -0
- package/dist/mcp-endpoint-routes.d.ts.map +1 -0
- package/dist/mcp-endpoint-routes.js +359 -0
- package/dist/mcp-mounts.d.ts +2 -76
- package/dist/mcp-mounts.d.ts.map +1 -1
- package/dist/mcp-mounts.js +0 -76
- package/dist/oauth-as-routes.d.ts +60 -0
- package/dist/oauth-as-routes.d.ts.map +1 -0
- package/dist/oauth-as-routes.js +82 -0
- package/dist/oauth-clients-routes.d.ts +39 -0
- package/dist/oauth-clients-routes.d.ts.map +1 -0
- package/dist/oauth-clients-routes.js +87 -0
- package/dist/oauth-login-types.d.ts +1 -20
- package/dist/oauth-login-types.d.ts.map +1 -1
- package/dist/oauth-login-types.js +30 -7
- package/dist/oauth-login.d.ts.map +1 -1
- package/dist/oauth-login.js +3 -2
- package/dist/oauth-providers-store.d.ts.map +1 -1
- package/dist/oauth-providers-store.js +5 -5
- package/dist/oauth.d.ts +9 -8
- package/dist/oauth.d.ts.map +1 -1
- package/dist/oauth.js +41 -19
- package/dist/pairing-transport.d.ts.map +1 -1
- package/dist/pairing-transport.js +2 -1
- package/dist/request-context.d.ts +2 -2
- package/dist/request-context.js +2 -2
- package/dist/reserved-validators.d.ts.map +1 -1
- package/dist/reserved-validators.js +9 -1
- package/dist/route-param.d.ts +9 -0
- package/dist/route-param.d.ts.map +1 -0
- package/dist/route-param.js +10 -0
- package/dist/runtime-bundle-route.d.ts +43 -0
- package/dist/runtime-bundle-route.d.ts.map +1 -0
- package/dist/runtime-bundle-route.js +80 -0
- package/dist/schema-compat.d.ts +64 -62
- package/dist/schema-compat.d.ts.map +1 -1
- package/dist/schema-compat.js +23 -51
- package/dist/server.d.ts +179 -193
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +644 -3759
- package/dist/storage.d.ts +5 -5
- package/dist/storage.d.ts.map +1 -1
- package/dist/storage.js +5 -5
- package/dist/thread-transport.d.ts.map +1 -1
- package/dist/thread-transport.js +4 -3
- package/dist/user-session-auth.d.ts +7 -21
- package/dist/user-session-auth.d.ts.map +1 -1
- package/dist/user-session-auth.js +7 -28
- package/package.json +16 -15
- package/dist/mcp-apps-inbound.d.ts +0 -86
- package/dist/mcp-apps-inbound.d.ts.map +0 -1
- package/dist/mcp-apps-inbound.js +0 -283
- package/dist/render-channel.d.ts +0 -694
- package/dist/render-channel.d.ts.map +0 -1
- package/dist/render-channel.js +0 -1775
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inbound `action` ingress + consume bridge for the live channel —
|
|
3
|
+
* contract enforcement on user gestures arriving over WS, the
|
|
4
|
+
* dual-write onto the retained event ledger + the pending-events pipe
|
|
5
|
+
* (`ggui_consume`'s queue), and the ack carrying the ledger seq back
|
|
6
|
+
* to the client.
|
|
7
|
+
*/
|
|
8
|
+
import { assertActionContract } from "@ggui-ai/mcp-server-handlers/renders";
|
|
9
|
+
import { ContractViolationError } from "@ggui-ai/protocol";
|
|
10
|
+
import { randomBytes } from "node:crypto";
|
|
11
|
+
/**
|
|
12
|
+
* Resolve the active render variant for contract enforcement. Phase
|
|
13
|
+
* B collapsed the prior (stack, currentStackIndex) lookup — a render
|
|
14
|
+
* IS the addressable unit, so the active render is the stored render
|
|
15
|
+
* itself. MCP Apps / system variants narrow to `undefined` so
|
|
16
|
+
* upstream enforcement skips (allowlist + actionSpec checks are
|
|
17
|
+
* no-ops when no `ComponentGguiSession` is active).
|
|
18
|
+
*/
|
|
19
|
+
function resolveActiveGguiSession(render) {
|
|
20
|
+
if (!render)
|
|
21
|
+
return undefined;
|
|
22
|
+
if (render.type === "mcpApps" || render.type === "system")
|
|
23
|
+
return undefined;
|
|
24
|
+
return render;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Stamp the `tool` hint onto a `data:submit` envelope's
|
|
28
|
+
* `ActionEventValue` payload before it persists onto the retained
|
|
29
|
+
* event ledger (`user.submitted`).
|
|
30
|
+
*
|
|
31
|
+
* The hint derives server-side from the active render's
|
|
32
|
+
* `actionSpec[action].nextStep` — the single authoritative source —
|
|
33
|
+
* and only fills the gap when the inbound payload carries no `tool`
|
|
34
|
+
* of its own. It rides the LEDGER copy only (operator surfaces —
|
|
35
|
+
* console timeline, inspector feeds — read it); the consume-pipe
|
|
36
|
+
* entry is the relay-identical {@link ConsumeEventEntry}, which
|
|
37
|
+
* carries no tool slot — the agent reads `nextStep` from the
|
|
38
|
+
* contract it authored.
|
|
39
|
+
*
|
|
40
|
+
* Pass-through (returns the envelope unchanged) when:
|
|
41
|
+
* - the envelope is not `data:submit`,
|
|
42
|
+
* - no `ComponentGguiSession` is active (mcpApps / system),
|
|
43
|
+
* - the payload lacks a string `action` or already carries a
|
|
44
|
+
* non-empty `tool`,
|
|
45
|
+
* - the named action declares no `nextStep`.
|
|
46
|
+
*/
|
|
47
|
+
function withDerivedToolHint(envelope, activeItem) {
|
|
48
|
+
if (envelope.type !== "data:submit" || !activeItem)
|
|
49
|
+
return envelope;
|
|
50
|
+
if (activeItem.type === "mcpApps" || activeItem.type === "system")
|
|
51
|
+
return envelope;
|
|
52
|
+
const payload = envelope.payload;
|
|
53
|
+
if (payload === null ||
|
|
54
|
+
payload === undefined ||
|
|
55
|
+
typeof payload !== "object" ||
|
|
56
|
+
Array.isArray(payload)) {
|
|
57
|
+
return envelope;
|
|
58
|
+
}
|
|
59
|
+
if (typeof payload.action !== "string" || payload.action.length === 0)
|
|
60
|
+
return envelope;
|
|
61
|
+
if (typeof payload.tool === "string" && payload.tool.length > 0)
|
|
62
|
+
return envelope;
|
|
63
|
+
const nextStep = activeItem.actionSpec?.[payload.action]?.nextStep;
|
|
64
|
+
if (typeof nextStep !== "string" || nextStep.length === 0)
|
|
65
|
+
return envelope;
|
|
66
|
+
return { ...envelope, payload: { ...payload, tool: nextStep } };
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Project an accepted `data:submit` {@link ActionEnvelope} onto the
|
|
70
|
+
* canonical {@link ConsumeEventEntry} shape the pending-events pipe
|
|
71
|
+
* stores — the SAME shape `ggui_runtime_submit_action`'s dispatch
|
|
72
|
+
* branch appends, so `ggui_consume` drains WS-originated gestures
|
|
73
|
+
* and tools/call-relayed gestures identically.
|
|
74
|
+
*
|
|
75
|
+
* Field mapping:
|
|
76
|
+
* - `intent` ← `payload.action` (the actionSpec key).
|
|
77
|
+
* - `actionData` ← `payload.data ?? null` (already validated by
|
|
78
|
+
* {@link assertActionContract} when a spec is declared).
|
|
79
|
+
* - `uiContext` ← `{}` — WS clients don't mirror a contextSpec
|
|
80
|
+
* snapshot (that's the iframe-runtime observer's job); the empty
|
|
81
|
+
* object is the type's canonical "no slots mirrored" value.
|
|
82
|
+
* - `actionId` ← server-minted 8-hex correlation id. The WS wire
|
|
83
|
+
* envelope carries none (only the iframe-runtime computes a
|
|
84
|
+
* gesture-side FNV-1a hash); minting here keeps the pipe entry's
|
|
85
|
+
* `drain_ack` keying well-formed.
|
|
86
|
+
* - `firedAt` ← server clock — the WS envelope deliberately
|
|
87
|
+
* carries no client timestamp (see {@link ActionEnvelope}).
|
|
88
|
+
*
|
|
89
|
+
* Returns `null` when the payload lacks a non-empty string `action`
|
|
90
|
+
* (possible only on spec-less renders, where the contract gate is
|
|
91
|
+
* permissive) — there is no intent to key the entry on, so the
|
|
92
|
+
* gesture stays ledger-only.
|
|
93
|
+
*/
|
|
94
|
+
function toConsumeEventEntry(envelope, sessionId) {
|
|
95
|
+
const payload = envelope.payload;
|
|
96
|
+
if (payload === null ||
|
|
97
|
+
payload === undefined ||
|
|
98
|
+
typeof payload !== "object" ||
|
|
99
|
+
Array.isArray(payload)) {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
const action = payload.action;
|
|
103
|
+
if (typeof action !== "string" || action.length === 0)
|
|
104
|
+
return null;
|
|
105
|
+
return {
|
|
106
|
+
type: "action",
|
|
107
|
+
sessionId,
|
|
108
|
+
intent: action,
|
|
109
|
+
actionData: payload.data ?? null,
|
|
110
|
+
uiContext: {},
|
|
111
|
+
actionId: randomBytes(4).toString("hex"),
|
|
112
|
+
firedAt: new Date().toISOString(),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
export function createActionIngress(deps) {
|
|
116
|
+
async function handleInboundAction(ws, sub, message) {
|
|
117
|
+
const envelope = message.payload;
|
|
118
|
+
// Spoof guard — envelope.sessionId is REQUIRED on the wire and
|
|
119
|
+
// MUST match the subscriber's bound render.
|
|
120
|
+
if (envelope.sessionId !== sub.sessionId) {
|
|
121
|
+
deps.sendError(ws, "SESSION_MISMATCH", `Action targets render '${envelope.sessionId}' but this socket is subscribed to '${sub.sessionId}'`, message.requestId);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
const stored = await deps.renderStore.get(sub.sessionId);
|
|
125
|
+
if (!stored) {
|
|
126
|
+
deps.sendError(ws, "SESSION_NOT_FOUND", `GguiSession ${sub.sessionId} no longer exists`, message.requestId);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
// Phase B: a render IS the addressable unit. The prior stack
|
|
130
|
+
// routing (stackIndex / cross-stack pickIds) collapses — the
|
|
131
|
+
// resolved render itself is the active item.
|
|
132
|
+
const activeItem = resolveActiveGguiSession(stored.render);
|
|
133
|
+
// Contract enforcement: actionSpec payload check via
|
|
134
|
+
// assertActionContract (data:submit only). Envelope.payload for
|
|
135
|
+
// data:submit carries the ActionEventValue shape
|
|
136
|
+
// (`{action, data?, tool?}`).
|
|
137
|
+
if (envelope.type === "data:submit") {
|
|
138
|
+
try {
|
|
139
|
+
const activeActionSpec = activeItem && activeItem.type !== "mcpApps" && activeItem.type !== "system"
|
|
140
|
+
? activeItem.actionSpec
|
|
141
|
+
: undefined;
|
|
142
|
+
assertActionContract(activeActionSpec, envelope.payload);
|
|
143
|
+
}
|
|
144
|
+
catch (err) {
|
|
145
|
+
if (err instanceof ContractViolationError) {
|
|
146
|
+
deps.logger.warn("render_channel_contract_violation", {
|
|
147
|
+
sessionId: sub.sessionId,
|
|
148
|
+
violations: err.violations,
|
|
149
|
+
envelope: "action",
|
|
150
|
+
});
|
|
151
|
+
deps.sendError(ws, "CONTRACT_VIOLATION", err.message, message.requestId, err.toErrorData());
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
throw err;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
// Dual-write, mirroring `ggui_runtime_submit_action`'s dispatch
|
|
158
|
+
// branch (`createGguiSubmitActionHandler`):
|
|
159
|
+
//
|
|
160
|
+
// 1. Ledger — `GguiSessionStore.appendEvent` assigns a monotonic
|
|
161
|
+
// seq the client acks back with so reconnects can resume via
|
|
162
|
+
// `fromSeq`. This retained copy is also the single build site
|
|
163
|
+
// for the operator-facing `tool` hint — see
|
|
164
|
+
// {@link withDerivedToolHint}.
|
|
165
|
+
// 2. Pipe — for `data:submit` envelopes, the consume-entry
|
|
166
|
+
// projection ({@link toConsumeEventEntry}) lands on the
|
|
167
|
+
// pending-events pipe so the agent's `ggui_consume` long-poll
|
|
168
|
+
// drains it mid-turn. The ledger and the pipe are two
|
|
169
|
+
// different streams (queue vs append-only retained — see
|
|
170
|
+
// `pending-event-consumer.ts`); without this write a WS
|
|
171
|
+
// gesture would never reach the agent.
|
|
172
|
+
//
|
|
173
|
+
// Both writes fire concurrently via `Promise.allSettled` so each
|
|
174
|
+
// outcome is inspected independently: a ledger rejection is the
|
|
175
|
+
// load-bearing `APPEND_FAILED` error path (unchanged ack
|
|
176
|
+
// semantics); a pipe rejection (pipe never opened / already
|
|
177
|
+
// reaped) degrades to ledger-only with a warn — the WS client has
|
|
178
|
+
// no `ui/message` fallback to branch on, so a new error frame
|
|
179
|
+
// would be vocabulary without a consumer.
|
|
180
|
+
const consumeWrite = (() => {
|
|
181
|
+
if (deps.pendingEventConsumer === undefined || envelope.type !== "data:submit") {
|
|
182
|
+
return Promise.resolve();
|
|
183
|
+
}
|
|
184
|
+
const entry = toConsumeEventEntry(envelope, sub.sessionId);
|
|
185
|
+
if (entry === null)
|
|
186
|
+
return Promise.resolve();
|
|
187
|
+
return deps.pendingEventConsumer.append(sub.sessionId, {
|
|
188
|
+
// The pipe entry's stable id doubles as the `drain_ack` key —
|
|
189
|
+
// same convention as the relay path's iframe-supplied id.
|
|
190
|
+
id: entry.actionId,
|
|
191
|
+
envelope: entry,
|
|
192
|
+
createdAt: entry.firedAt,
|
|
193
|
+
});
|
|
194
|
+
})();
|
|
195
|
+
const [ledgerResult, pipeResult] = await Promise.allSettled([
|
|
196
|
+
deps.renderStore.appendEvent({
|
|
197
|
+
sessionId: sub.sessionId,
|
|
198
|
+
type: "user.submitted",
|
|
199
|
+
data: withDerivedToolHint(envelope, activeItem),
|
|
200
|
+
}),
|
|
201
|
+
consumeWrite,
|
|
202
|
+
]);
|
|
203
|
+
if (pipeResult.status === "rejected") {
|
|
204
|
+
deps.logger.warn("render_channel_consume_append_failed", {
|
|
205
|
+
sessionId: sub.sessionId,
|
|
206
|
+
error: pipeResult.reason instanceof Error
|
|
207
|
+
? pipeResult.reason.message
|
|
208
|
+
: String(pipeResult.reason),
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
if (ledgerResult.status === "rejected") {
|
|
212
|
+
const err = ledgerResult.reason;
|
|
213
|
+
deps.logger.error("render_channel_append_failed", {
|
|
214
|
+
sessionId: sub.sessionId,
|
|
215
|
+
error: String(err),
|
|
216
|
+
});
|
|
217
|
+
deps.sendError(ws, "APPEND_FAILED", err instanceof Error ? err.message : String(err), message.requestId);
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
const seq = ledgerResult.value;
|
|
221
|
+
deps.send(ws, {
|
|
222
|
+
type: "ack",
|
|
223
|
+
payload: { sequence: seq, timestamp: Date.now() },
|
|
224
|
+
...(message.requestId ? { requestId: message.requestId } : {}),
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
return { handleInboundAction };
|
|
228
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `channel_subscribe` / source-poll family for the live channel —
|
|
3
|
+
* server-side polling of `streamSpec[ch].source.tool` for the subset
|
|
4
|
+
* of tools the operator listed on `streamWebSocketLocalTools`, fanned
|
|
5
|
+
* to the subscriber as `channel_payload` frames. Includes the
|
|
6
|
+
* symmetric `channel_unsubscribe` handler; WS close tears down any
|
|
7
|
+
* remaining polling loops via the subscriber-lifecycle module.
|
|
8
|
+
*/
|
|
9
|
+
import type { GguiSessionStore } from "@ggui-ai/mcp-server-core";
|
|
10
|
+
import type { WebSocketMessage } from "@ggui-ai/protocol/transport/websocket";
|
|
11
|
+
import type { WebSocket } from "ws";
|
|
12
|
+
import type { Logger } from "../logger.js";
|
|
13
|
+
import type { Subscriber } from "./internal-types.js";
|
|
14
|
+
import type { Outbound } from "./outbound.js";
|
|
15
|
+
/**
|
|
16
|
+
* Opt-in plumbing for the `channel_subscribe` polling loop. When this
|
|
17
|
+
* field is set on `GguiSessionChannelOptions`, channel subscribes
|
|
18
|
+
* whose `source.tool` is in {@link allowlist} are accepted and the
|
|
19
|
+
* server begins polling. When absent, every `channel_subscribe`
|
|
20
|
+
* returns `CHANNEL_NOT_LOCAL` so the iframe falls back to direct
|
|
21
|
+
* polling via the MCP host proxy.
|
|
22
|
+
*/
|
|
23
|
+
export interface GguiSessionChannelLocalToolsOptions {
|
|
24
|
+
/**
|
|
25
|
+
* Whitelist of `source.tool` names this channel can poll. Must mirror
|
|
26
|
+
* the value the host advertises on
|
|
27
|
+
* `handshake.serverCapabilities.streamWebSocketLocalTools` so the
|
|
28
|
+
* iframe + server agree on which channels use the WS fan-out path.
|
|
29
|
+
* Tools NOT in this list are rejected with `CHANNEL_NOT_LOCAL` (the
|
|
30
|
+
* iframe falls back to direct polling).
|
|
31
|
+
*/
|
|
32
|
+
readonly allowlist: readonly string[];
|
|
33
|
+
/**
|
|
34
|
+
* Synchronous resolver invoked at poll time. Returns the tool's
|
|
35
|
+
* structured output (validated against `streamSpec[ch].schema`
|
|
36
|
+
* client-side; server-side schema validation is deferred to the
|
|
37
|
+
* future `validateContract` slice). Implementations typically
|
|
38
|
+
* delegate to the same in-process tool registry that backs `/mcp`.
|
|
39
|
+
*
|
|
40
|
+
* Throwing surfaces `POLL_FAILED` on the subscriber's
|
|
41
|
+
* `channel_error` channel without canceling the poll loop —
|
|
42
|
+
* transient tool failures are recoverable.
|
|
43
|
+
*/
|
|
44
|
+
invoke(name: string, input: unknown): Promise<unknown>;
|
|
45
|
+
/**
|
|
46
|
+
* Optional poll cadence policy. `defaultMs` applies when the client
|
|
47
|
+
* doesn't supply a `pollIntervalMs`; `floorMs`/`ceilingMs` clamp
|
|
48
|
+
* client-supplied values. Defaults:
|
|
49
|
+
* `{floorMs: 1000, ceilingMs: 60000, defaultMs: 10000}`.
|
|
50
|
+
*/
|
|
51
|
+
readonly pollCadence?: {
|
|
52
|
+
readonly floorMs?: number;
|
|
53
|
+
readonly ceilingMs?: number;
|
|
54
|
+
readonly defaultMs?: number;
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
export interface ChannelSubscriptionsDeps {
|
|
58
|
+
readonly logger: Logger;
|
|
59
|
+
readonly renderStore: GguiSessionStore;
|
|
60
|
+
/**
|
|
61
|
+
* Channel-subscribe local-tool poll plumbing, straight from
|
|
62
|
+
* `GguiSessionChannelOptions.streamWebSocketLocalTools`. Absent ⇒
|
|
63
|
+
* all channel subscribes reject with `CHANNEL_NOT_LOCAL`.
|
|
64
|
+
*/
|
|
65
|
+
readonly localTools: GguiSessionChannelLocalToolsOptions | undefined;
|
|
66
|
+
/**
|
|
67
|
+
* ws → subscriber reverse index — the zombie-timer guard reads it at
|
|
68
|
+
* callback fire-time to self-clean intervals that outlived their
|
|
69
|
+
* subscriber.
|
|
70
|
+
*/
|
|
71
|
+
readonly subscribersByWs: WeakMap<WebSocket, Subscriber>;
|
|
72
|
+
readonly send: Outbound["send"];
|
|
73
|
+
readonly sendChannelError: Outbound["sendChannelError"];
|
|
74
|
+
}
|
|
75
|
+
export interface ChannelSubscriptions {
|
|
76
|
+
/**
|
|
77
|
+
* Handle a `channel_subscribe` message. Validates the request,
|
|
78
|
+
* resolves the channel's `source.tool` against the configured
|
|
79
|
+
* allowlist, and schedules a polling loop. Idempotent on
|
|
80
|
+
* `${sessionId}:${channelName}` — a re-subscribe replaces any
|
|
81
|
+
* existing interval rather than running two in parallel.
|
|
82
|
+
*/
|
|
83
|
+
handleChannelSubscribe(ws: WebSocket, sub: Subscriber, message: WebSocketMessage & {
|
|
84
|
+
type: "channel_subscribe";
|
|
85
|
+
}): Promise<void>;
|
|
86
|
+
/**
|
|
87
|
+
* Handle a `channel_unsubscribe` message. Idempotent: a no-op
|
|
88
|
+
* unsubscribe on an unknown channelKey returns silently. WS close
|
|
89
|
+
* implicitly unsubscribes every channel; this message is for
|
|
90
|
+
* mid-session cancellation.
|
|
91
|
+
*/
|
|
92
|
+
handleChannelUnsubscribe(ws: WebSocket, sub: Subscriber, message: WebSocketMessage & {
|
|
93
|
+
type: "channel_unsubscribe";
|
|
94
|
+
}): void;
|
|
95
|
+
}
|
|
96
|
+
export declare function createChannelSubscriptions(deps: ChannelSubscriptionsDeps): ChannelSubscriptions;
|
|
97
|
+
//# sourceMappingURL=channel-subscriptions.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"channel-subscriptions.d.ts","sourceRoot":"","sources":["../../src/ggui-session-channel/channel-subscriptions.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,0BAA0B,CAAC;AAGjE,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,EAA4B,UAAU,EAAE,MAAM,qBAAqB,CAAC;AAChF,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,eAAe,CAAC;AAa9C;;;;;;;GAOG;AACH,MAAM,WAAW,mCAAmC;IAClD;;;;;;;OAOG;IACH,QAAQ,CAAC,SAAS,EAAE,SAAS,MAAM,EAAE,CAAC;IACtC;;;;;;;;;;OAUG;IACH,MAAM,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IACvD;;;;;OAKG;IACH,QAAQ,CAAC,WAAW,CAAC,EAAE;QACrB,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAC;QAC1B,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;QAC5B,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;KAC7B,CAAC;CACH;AAED,MAAM,WAAW,wBAAwB;IACvC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,WAAW,EAAE,gBAAgB,CAAC;IACvC;;;;OAIG;IACH,QAAQ,CAAC,UAAU,EAAE,mCAAmC,GAAG,SAAS,CAAC;IACrE;;;;OAIG;IACH,QAAQ,CAAC,eAAe,EAAE,OAAO,CAAC,SAAS,EAAE,UAAU,CAAC,CAAC;IACzD,QAAQ,CAAC,IAAI,EAAE,QAAQ,CAAC,MAAM,CAAC,CAAC;IAChC,QAAQ,CAAC,gBAAgB,EAAE,QAAQ,CAAC,kBAAkB,CAAC,CAAC;CACzD;AAED,MAAM,WAAW,oBAAoB;IACnC;;;;;;OAMG;IACH,sBAAsB,CACpB,EAAE,EAAE,SAAS,EACb,GAAG,EAAE,UAAU,EACf,OAAO,EAAE,gBAAgB,GAAG;QAAE,IAAI,EAAE,mBAAmB,CAAA;KAAE,GACxD,OAAO,CAAC,IAAI,CAAC,CAAC;IACjB;;;;;OAKG;IACH,wBAAwB,CACtB,EAAE,EAAE,SAAS,EACb,GAAG,EAAE,UAAU,EACf,OAAO,EAAE,gBAAgB,GAAG;QAAE,IAAI,EAAE,qBAAqB,CAAA;KAAE,GAC1D,IAAI,CAAC;CACT;AAED,wBAAgB,0BAA0B,CAAC,IAAI,EAAE,wBAAwB,GAAG,oBAAoB,CAiQ/F"}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `channel_subscribe` / source-poll family for the live channel —
|
|
3
|
+
* server-side polling of `streamSpec[ch].source.tool` for the subset
|
|
4
|
+
* of tools the operator listed on `streamWebSocketLocalTools`, fanned
|
|
5
|
+
* to the subscriber as `channel_payload` frames. Includes the
|
|
6
|
+
* symmetric `channel_unsubscribe` handler; WS close tears down any
|
|
7
|
+
* remaining polling loops via the subscriber-lifecycle module.
|
|
8
|
+
*/
|
|
9
|
+
import { sanitizeCausedBy } from "@ggui-ai/protocol";
|
|
10
|
+
/**
|
|
11
|
+
* Default + boundary cadence for the channel-subscribe polling loop.
|
|
12
|
+
* Server-authoritative: clients propose `pollIntervalMs` on
|
|
13
|
+
* `channel_subscribe`, server clamps to [floorMs, ceilingMs] and
|
|
14
|
+
* defaults to `defaultMs` when absent. Conservative defaults — operators
|
|
15
|
+
* tune via {@link GguiSessionChannelLocalToolsOptions.pollCadence}.
|
|
16
|
+
*/
|
|
17
|
+
const DEFAULT_CHANNEL_POLL_FLOOR_MS = 1_000;
|
|
18
|
+
const DEFAULT_CHANNEL_POLL_CEILING_MS = 60_000;
|
|
19
|
+
const DEFAULT_CHANNEL_POLL_DEFAULT_MS = 10_000;
|
|
20
|
+
export function createChannelSubscriptions(deps) {
|
|
21
|
+
// Poll plumbing resolved once at composition so the
|
|
22
|
+
// `channel_subscribe` handler doesn't pay the option-spread cost per
|
|
23
|
+
// request.
|
|
24
|
+
const localTools = deps.localTools;
|
|
25
|
+
const localToolsAllowlist = localTools
|
|
26
|
+
? new Set(localTools.allowlist)
|
|
27
|
+
: new Set();
|
|
28
|
+
const pollFloorMs = localTools?.pollCadence?.floorMs ?? DEFAULT_CHANNEL_POLL_FLOOR_MS;
|
|
29
|
+
const pollCeilingMs = localTools?.pollCadence?.ceilingMs ?? DEFAULT_CHANNEL_POLL_CEILING_MS;
|
|
30
|
+
const pollDefaultMs = localTools?.pollCadence?.defaultMs ?? DEFAULT_CHANNEL_POLL_DEFAULT_MS;
|
|
31
|
+
/**
|
|
32
|
+
* Clamp a client-supplied `pollIntervalMs` against the configured
|
|
33
|
+
* floor / ceiling. Absent (or non-finite) ⇒ default. Server is
|
|
34
|
+
* authoritative per the `ChannelSubscribePayload.pollIntervalMs`
|
|
35
|
+
* docstring — clients propose, server clamps.
|
|
36
|
+
*/
|
|
37
|
+
function clampPollInterval(supplied) {
|
|
38
|
+
if (typeof supplied !== "number" || !Number.isFinite(supplied)) {
|
|
39
|
+
return pollDefaultMs;
|
|
40
|
+
}
|
|
41
|
+
if (supplied < pollFloorMs)
|
|
42
|
+
return pollFloorMs;
|
|
43
|
+
if (supplied > pollCeilingMs)
|
|
44
|
+
return pollCeilingMs;
|
|
45
|
+
return supplied;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Run one poll of `state.toolName` and fan its result onto the
|
|
49
|
+
* subscriber's WS as a `channel_payload`. Throws never escape — a
|
|
50
|
+
* thrown invocation surfaces as a `channel_error{code:'POLL_FAILED'}`
|
|
51
|
+
* but the polling loop keeps running so transient tool failures are
|
|
52
|
+
* recoverable.
|
|
53
|
+
*
|
|
54
|
+
* Tool registry is guaranteed-present by the caller — channel
|
|
55
|
+
* subscribes whose `source.tool` isn't in `localTools.allowlist`
|
|
56
|
+
* never reach this function.
|
|
57
|
+
*/
|
|
58
|
+
async function pollChannelOnce(sub, state) {
|
|
59
|
+
if (!localTools)
|
|
60
|
+
return;
|
|
61
|
+
if (sub.ws.readyState !== sub.ws.OPEN)
|
|
62
|
+
return;
|
|
63
|
+
try {
|
|
64
|
+
const output = await localTools.invoke(state.toolName, state.args);
|
|
65
|
+
// Skip emission if the socket closed during the poll — closing-
|
|
66
|
+
// raced timers fire at most once, and emitting onto a closing
|
|
67
|
+
// socket is a `send_failed` warning at best.
|
|
68
|
+
if (sub.ws.readyState !== sub.ws.OPEN)
|
|
69
|
+
return;
|
|
70
|
+
state.seq += 1;
|
|
71
|
+
deps.send(sub.ws, {
|
|
72
|
+
type: "channel_payload",
|
|
73
|
+
payload: {
|
|
74
|
+
sessionId: sub.sessionId,
|
|
75
|
+
appId: sub.appId,
|
|
76
|
+
channelName: state.channelName,
|
|
77
|
+
seq: state.seq,
|
|
78
|
+
ts: new Date().toISOString(),
|
|
79
|
+
// Default mode for source-fed channels is `replace` — each
|
|
80
|
+
// poll is a fresh snapshot, not a delta. Channels that need
|
|
81
|
+
// append semantics declare `mode: 'append'` on streamSpec;
|
|
82
|
+
// honoring that is the iframe-runtime's concern at fold
|
|
83
|
+
// time. See `ChannelPayloadFrame.mode`.
|
|
84
|
+
mode: "replace",
|
|
85
|
+
payload: output,
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
catch (err) {
|
|
90
|
+
if (sub.ws.readyState !== sub.ws.OPEN)
|
|
91
|
+
return;
|
|
92
|
+
deps.sendChannelError(sub.ws, sub.sessionId, state.channelName, "POLL_FAILED", err instanceof Error ? err.message : String(err), undefined,
|
|
93
|
+
// causedBy slot — the raw stack is redacted (Bearer tokens,
|
|
94
|
+
// query-param secrets, env-var dumps, 2 KB truncation) before
|
|
95
|
+
// it reaches the wire; raw `err.stack` verbatim is a
|
|
96
|
+
// credential-leak footgun.
|
|
97
|
+
sanitizeCausedBy(err instanceof Error ? err.stack ?? err.message : String(err)));
|
|
98
|
+
deps.logger.warn("render_channel_channel_poll_failed", {
|
|
99
|
+
sessionId: sub.sessionId,
|
|
100
|
+
appId: sub.appId,
|
|
101
|
+
channelName: state.channelName,
|
|
102
|
+
toolName: state.toolName,
|
|
103
|
+
error: String(err),
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
async function handleChannelSubscribe(ws, sub, message) {
|
|
108
|
+
const payload = message.payload;
|
|
109
|
+
// sessionId match — the spoof guard at every wire-input boundary.
|
|
110
|
+
// A subscriber bound to render A can't drive a subscribe for
|
|
111
|
+
// render B even if they crafted the inbound payload.
|
|
112
|
+
if (payload.sessionId !== sub.sessionId) {
|
|
113
|
+
deps.sendChannelError(ws, payload.sessionId, payload.channelName, "SUBSCRIBE_UNAUTHORIZED", `Subscriber is bound to render '${sub.sessionId}' but channel_subscribe targets '${payload.sessionId}'`, message.requestId);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
// Without an `streamWebSocketLocalTools` allowlist, no channel
|
|
117
|
+
// can be subscribed locally. The iframe must fall back to direct
|
|
118
|
+
// polling via the MCP host proxy.
|
|
119
|
+
if (!localTools) {
|
|
120
|
+
deps.sendChannelError(ws, payload.sessionId, payload.channelName, "CHANNEL_NOT_LOCAL", "This server has no streamWebSocketLocalTools allowlist; iframe must poll the source tool directly.", message.requestId);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
// Phase B: a render IS the addressable unit. The prior
|
|
124
|
+
// reverse-index lookup collapses — `renderStore.get` resolves
|
|
125
|
+
// the render directly.
|
|
126
|
+
const stored = await deps.renderStore.get(payload.sessionId);
|
|
127
|
+
if (!stored || stored.id !== sub.sessionId) {
|
|
128
|
+
deps.sendChannelError(ws, payload.sessionId, payload.channelName, "SESSION_NOT_FOUND", `GguiSession '${payload.sessionId}' not found on subscriber '${sub.sessionId}'`, message.requestId);
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
const render = stored.render;
|
|
132
|
+
// Channel entry resolution. mcpApps / system variants have no
|
|
133
|
+
// streamSpec so the field reads back as undefined — same code
|
|
134
|
+
// path as a component variant without the channel declared.
|
|
135
|
+
const streamSpec = render.type === "mcpApps" || render.type === "system" ? undefined : render.streamSpec;
|
|
136
|
+
const channelEntry = streamSpec?.[payload.channelName];
|
|
137
|
+
if (!channelEntry || !channelEntry.source) {
|
|
138
|
+
deps.sendChannelError(ws, payload.sessionId, payload.channelName, "CHANNEL_UNKNOWN", `streamSpec['${payload.channelName}'] not declared OR has no source.tool on render '${payload.sessionId}'`, message.requestId);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
const sourceTool = channelEntry.source.tool;
|
|
142
|
+
if (!localToolsAllowlist.has(sourceTool)) {
|
|
143
|
+
deps.sendChannelError(ws, payload.sessionId, payload.channelName, "CHANNEL_NOT_LOCAL", `source.tool '${sourceTool}' is not in streamWebSocketLocalTools; iframe must poll directly`, message.requestId);
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
// Validation passed — schedule (or re-schedule) the polling loop.
|
|
147
|
+
const channelKey = `${payload.sessionId}:${payload.channelName}`;
|
|
148
|
+
// Idempotent replace: a reconnect that re-subscribes the same
|
|
149
|
+
// (render, channel) pair gets a fresh timer + zeroed seq. The
|
|
150
|
+
// client's gap-detection treats it as a new stream from the
|
|
151
|
+
// server's perspective; client-side reconnect logic owns
|
|
152
|
+
// continuity if the channel was declared `mode: 'append'`.
|
|
153
|
+
const existing = sub.channelSubs.get(channelKey);
|
|
154
|
+
if (existing) {
|
|
155
|
+
clearInterval(existing.timer);
|
|
156
|
+
sub.channelSubs.delete(channelKey);
|
|
157
|
+
}
|
|
158
|
+
const pollIntervalMs = clampPollInterval(payload.pollIntervalMs);
|
|
159
|
+
// Layered args: source.args defines defaults; client args override
|
|
160
|
+
// per ChannelSubscribePayload.args docstring.
|
|
161
|
+
const mergedArgs = {
|
|
162
|
+
...(channelEntry.source.args ?? {}),
|
|
163
|
+
...(payload.args ?? {}),
|
|
164
|
+
};
|
|
165
|
+
// Resolve the channelKey lookup at callback fire-time. The
|
|
166
|
+
// timer callback reads `sub.channelSubs.get(channelKey)` so
|
|
167
|
+
// `state` doesn't have to be referenced through a closure
|
|
168
|
+
// before it's actually inserted into the tracker. Also self-
|
|
169
|
+
// cleans on the zombie-timer path: if the subscription was
|
|
170
|
+
// already torn down (channel_unsubscribe / WS close / replace),
|
|
171
|
+
// the lookup returns undefined and we clearInterval ourselves.
|
|
172
|
+
const timer = setInterval(() => {
|
|
173
|
+
const live = sub.channelSubs.get(channelKey);
|
|
174
|
+
if (!live || !deps.subscribersByWs.has(sub.ws)) {
|
|
175
|
+
clearInterval(timer);
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
void pollChannelOnce(sub, live);
|
|
179
|
+
}, pollIntervalMs);
|
|
180
|
+
const state = {
|
|
181
|
+
pollIntervalMs,
|
|
182
|
+
toolName: sourceTool,
|
|
183
|
+
sessionId: payload.sessionId,
|
|
184
|
+
channelName: payload.channelName,
|
|
185
|
+
args: mergedArgs,
|
|
186
|
+
seq: 0,
|
|
187
|
+
timer,
|
|
188
|
+
};
|
|
189
|
+
sub.channelSubs.set(channelKey, state);
|
|
190
|
+
deps.logger.info("render_channel_channel_subscribe", {
|
|
191
|
+
sessionId: sub.sessionId,
|
|
192
|
+
appId: sub.appId,
|
|
193
|
+
channelName: payload.channelName,
|
|
194
|
+
toolName: sourceTool,
|
|
195
|
+
pollIntervalMs,
|
|
196
|
+
});
|
|
197
|
+
// Eager-poll — fire one invocation immediately so the iframe sees
|
|
198
|
+
// an initial value without waiting `pollIntervalMs`. Matches the
|
|
199
|
+
// user-expected "subscribe then see data" cadence; the interval
|
|
200
|
+
// takes over from there.
|
|
201
|
+
void pollChannelOnce(sub, state);
|
|
202
|
+
}
|
|
203
|
+
function handleChannelUnsubscribe(_ws, sub, message) {
|
|
204
|
+
const payload = message.payload;
|
|
205
|
+
if (payload.sessionId !== sub.sessionId) {
|
|
206
|
+
// No-op silently — the canonical "spoof guard" code path is in
|
|
207
|
+
// channel_subscribe; unsubscribe gets no error frame to avoid
|
|
208
|
+
// leaking cross-render existence.
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
const channelKey = `${payload.sessionId}:${payload.channelName}`;
|
|
212
|
+
const existing = sub.channelSubs.get(channelKey);
|
|
213
|
+
if (!existing)
|
|
214
|
+
return;
|
|
215
|
+
clearInterval(existing.timer);
|
|
216
|
+
sub.channelSubs.delete(channelKey);
|
|
217
|
+
deps.logger.info("render_channel_channel_unsubscribe", {
|
|
218
|
+
sessionId: sub.sessionId,
|
|
219
|
+
appId: sub.appId,
|
|
220
|
+
channelName: payload.channelName,
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
return { handleChannelSubscribe, handleChannelUnsubscribe };
|
|
224
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Internal shared types for the live-channel handler modules. NOT part
|
|
3
|
+
* of the package's public surface — everything publishable is exported
|
|
4
|
+
* (or re-exported) from `../ggui-session-channel.ts`.
|
|
5
|
+
*/
|
|
6
|
+
import type { AuthResult, BufferedStreamEnvelope } from "@ggui-ai/mcp-server-core";
|
|
7
|
+
import type { JsonObject } from "@ggui-ai/protocol";
|
|
8
|
+
import type { WebSocket } from "ws";
|
|
9
|
+
/**
|
|
10
|
+
* A single connected subscriber (one client, one render). Held live in
|
|
11
|
+
* the per-channel subscriber map; torn down on socket close or explicit
|
|
12
|
+
* `close` message.
|
|
13
|
+
*/
|
|
14
|
+
export interface Subscriber {
|
|
15
|
+
readonly ws: WebSocket;
|
|
16
|
+
readonly sessionId: string;
|
|
17
|
+
readonly appId: string;
|
|
18
|
+
readonly identity: AuthResult;
|
|
19
|
+
readonly connectedAt: number;
|
|
20
|
+
/**
|
|
21
|
+
* Largest outbound `seq` the initial replay (or subscribe snapshot)
|
|
22
|
+
* covered for this subscriber. Live fan-out skips envelopes with
|
|
23
|
+
* `seq <= replayCompletedSeq` to prevent double delivery — those
|
|
24
|
+
* were (or will be) delivered via the replay phase.
|
|
25
|
+
*
|
|
26
|
+
* For fresh subscribers (no `fromSeq`), this is the stream cursor
|
|
27
|
+
* at subscribe time; they never see the pre-existing buffer, only
|
|
28
|
+
* new deliveries.
|
|
29
|
+
*/
|
|
30
|
+
readonly replayCompletedSeq: number;
|
|
31
|
+
/**
|
|
32
|
+
* Per-subscriber live-tail iterator from `streamFanout.subscribe`.
|
|
33
|
+
* Owned by the subscriber for its full lifetime; ending it (via
|
|
34
|
+
* `iter.return()`) terminates the pump loop AND unregisters from
|
|
35
|
+
* the StreamFanout. `unregister(ws)` is the single point that
|
|
36
|
+
* does this teardown.
|
|
37
|
+
*/
|
|
38
|
+
readonly iter: AsyncIterator<BufferedStreamEnvelope>;
|
|
39
|
+
/**
|
|
40
|
+
* Active `channel_subscribe` polling loops for this subscriber.
|
|
41
|
+
* Keyed by `${sessionId}:${channelName}` so a reconnect that
|
|
42
|
+
* re-subscribes to the same (render, channel) pair replaces the
|
|
43
|
+
* existing timer rather than minting a duplicate (idempotent
|
|
44
|
+
* semantics on the wire). Torn down en masse by `unregister(ws)` on
|
|
45
|
+
* WS close.
|
|
46
|
+
*
|
|
47
|
+
* Populated by the `channel_subscribe` handler when the composing
|
|
48
|
+
* host wired a `streamWebSocketLocalTools` allowlist; empty
|
|
49
|
+
* otherwise.
|
|
50
|
+
*/
|
|
51
|
+
readonly channelSubs: Map<string, ChannelSubscriptionState>;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Per-(subscriber, sessionId, channelName) polling-loop state.
|
|
55
|
+
* Created on `channel_subscribe` accept, torn down on `channel_unsubscribe`
|
|
56
|
+
* / WS close / re-subscribe-replace.
|
|
57
|
+
*
|
|
58
|
+
* Server-side polling of `streamSpec[ch].source.tool` for the
|
|
59
|
+
* subset of tools the operator listed on `streamWebSocketLocalTools`.
|
|
60
|
+
* Channels whose `source.tool` isn't in the allowlist are rejected
|
|
61
|
+
* with `CHANNEL_NOT_LOCAL` so the iframe falls back to direct polling
|
|
62
|
+
* over the MCP host proxy.
|
|
63
|
+
*/
|
|
64
|
+
export interface ChannelSubscriptionState {
|
|
65
|
+
/** Server-clamped poll cadence in ms (within configured floor/ceiling). */
|
|
66
|
+
readonly pollIntervalMs: number;
|
|
67
|
+
/** Source tool name resolved from `streamSpec[channelName].source.tool`. */
|
|
68
|
+
readonly toolName: string;
|
|
69
|
+
/** GguiSession this subscription is bound to (for fan-out scoping). */
|
|
70
|
+
readonly sessionId: string;
|
|
71
|
+
/** Channel name (key into `streamSpec`). */
|
|
72
|
+
readonly channelName: string;
|
|
73
|
+
/**
|
|
74
|
+
* Merged args used on each poll call. Layered as `{...source.args,
|
|
75
|
+
* ...client.args}` so client wins on key collisions — matches the
|
|
76
|
+
* docstring on `ChannelSubscribePayload.args`.
|
|
77
|
+
*/
|
|
78
|
+
readonly args: JsonObject;
|
|
79
|
+
/**
|
|
80
|
+
* Channel-scoped monotonic counter stamped into every
|
|
81
|
+
* `channel_payload` frame's `seq`. Starts at 1 and advances per
|
|
82
|
+
* successful poll for client-side gap detection.
|
|
83
|
+
*/
|
|
84
|
+
seq: number;
|
|
85
|
+
/** Active `setInterval` handle — cleared on teardown. */
|
|
86
|
+
readonly timer: ReturnType<typeof setInterval>;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Upgrade-time piggyback slots on the Node request object — the
|
|
90
|
+
* standard ws per-request pattern. The upgrade phase resolves identity
|
|
91
|
+
* (and, for console-cookie upgrades, the bound render/app) BEFORE the
|
|
92
|
+
* WebSocket exists, stashes them on the request, and the `connection`
|
|
93
|
+
* handler picks them up to seed the pre-subscribe bindings.
|
|
94
|
+
*/
|
|
95
|
+
export interface UpgradeBindings {
|
|
96
|
+
__gguiIdentity?: AuthResult;
|
|
97
|
+
__gguiCookieBound?: {
|
|
98
|
+
sessionId: string;
|
|
99
|
+
appId: string;
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
//# sourceMappingURL=internal-types.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"internal-types.d.ts","sourceRoot":"","sources":["../../src/ggui-session-channel/internal-types.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,KAAK,EAAE,UAAU,EAAE,sBAAsB,EAAE,MAAM,0BAA0B,CAAC;AACnF,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAC;AACpD,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,IAAI,CAAC;AAEpC;;;;GAIG;AACH,MAAM,WAAW,UAAU;IACzB,QAAQ,CAAC,EAAE,EAAE,SAAS,CAAC;IACvB,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,QAAQ,EAAE,UAAU,CAAC;IAC9B,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B;;;;;;;;;OASG;IACH,QAAQ,CAAC,kBAAkB,EAAE,MAAM,CAAC;IACpC;;;;;;OAMG;IACH,QAAQ,CAAC,IAAI,EAAE,aAAa,CAAC,sBAAsB,CAAC,CAAC;IACrD;;;;;;;;;;;OAWG;IACH,QAAQ,CAAC,WAAW,EAAE,GAAG,CAAC,MAAM,EAAE,wBAAwB,CAAC,CAAC;CAC7D;AAED;;;;;;;;;;GAUG;AACH,MAAM,WAAW,wBAAwB;IACvC,2EAA2E;IAC3E,QAAQ,CAAC,cAAc,EAAE,MAAM,CAAC;IAChC,4EAA4E;IAC5E,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,uEAAuE;IACvE,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,4CAA4C;IAC5C,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B;;;;OAIG;IACH,QAAQ,CAAC,IAAI,EAAE,UAAU,CAAC;IAC1B;;;;OAIG;IACH,GAAG,EAAE,MAAM,CAAC;IACZ,yDAAyD;IACzD,QAAQ,CAAC,KAAK,EAAE,UAAU,CAAC,OAAO,WAAW,CAAC,CAAC;CAChD;AAED;;;;;;GAMG;AACH,MAAM,WAAW,eAAe;IAC9B,cAAc,CAAC,EAAE,UAAU,CAAC;IAC5B,iBAAiB,CAAC,EAAE;QAAE,SAAS,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC;CAC1D"}
|