@ggui-ai/mcp-server 0.7.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api-renders-routes.d.ts.map +1 -1
- package/dist/api-renders-routes.js +32 -23
- package/dist/api-renders-stream-route.d.ts +80 -0
- package/dist/api-renders-stream-route.d.ts.map +1 -0
- package/dist/api-renders-stream-route.js +311 -0
- package/dist/build-mcp.d.ts +10 -0
- package/dist/build-mcp.d.ts.map +1 -1
- package/dist/build-mcp.js +64 -4
- package/dist/console-session-routes.d.ts +10 -5
- package/dist/console-session-routes.d.ts.map +1 -1
- package/dist/console-session-routes.js +21 -5
- package/dist/ggui-session-channel/action-ingress.d.ts +2 -2
- package/dist/ggui-session-channel/action-ingress.d.ts.map +1 -1
- package/dist/ggui-session-channel/channel-subscriptions.d.ts +4 -4
- package/dist/ggui-session-channel/channel-subscriptions.d.ts.map +1 -1
- package/dist/ggui-session-channel/internal-types.d.ts +63 -9
- package/dist/ggui-session-channel/internal-types.d.ts.map +1 -1
- package/dist/ggui-session-channel/outbound.d.ts +15 -5
- package/dist/ggui-session-channel/outbound.d.ts.map +1 -1
- package/dist/ggui-session-channel/outbound.js +64 -24
- package/dist/ggui-session-channel/socket-router.d.ts +8 -3
- package/dist/ggui-session-channel/socket-router.d.ts.map +1 -1
- package/dist/ggui-session-channel/socket-router.js +6 -1
- package/dist/ggui-session-channel/subscribe.d.ts +48 -3
- package/dist/ggui-session-channel/subscribe.d.ts.map +1 -1
- package/dist/ggui-session-channel/subscribe.js +97 -36
- package/dist/ggui-session-channel/subscriber-lifecycle.d.ts +23 -13
- package/dist/ggui-session-channel/subscriber-lifecycle.d.ts.map +1 -1
- package/dist/ggui-session-channel/subscriber-lifecycle.js +24 -11
- package/dist/ggui-session-channel.d.ts +58 -11
- package/dist/ggui-session-channel.d.ts.map +1 -1
- package/dist/ggui-session-channel.js +55 -19
- package/dist/index.d.ts +4 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -1
- package/dist/instructions-presets.js +10 -10
- package/dist/mcp-apps-outbound.d.ts +93 -4
- package/dist/mcp-apps-outbound.d.ts.map +1 -1
- package/dist/mcp-apps-outbound.js +460 -105
- package/dist/mcp-endpoint-routes.d.ts +17 -5
- package/dist/mcp-endpoint-routes.d.ts.map +1 -1
- package/dist/mcp-endpoint-routes.js +2 -0
- package/dist/oauth-as-routes.d.ts.map +1 -1
- package/dist/oauth-as-routes.js +36 -0
- package/dist/oauth.d.ts.map +1 -1
- package/dist/oauth.js +8 -1
- package/dist/runtime-bundle-hash.d.ts +43 -0
- package/dist/runtime-bundle-hash.d.ts.map +1 -0
- package/dist/runtime-bundle-hash.js +66 -0
- package/dist/runtime-bundle-route.d.ts +15 -0
- package/dist/runtime-bundle-route.d.ts.map +1 -1
- package/dist/runtime-bundle-route.js +21 -5
- package/dist/server.d.ts +83 -14
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +167 -15
- package/package.json +12 -12
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import { PROTOCOL_SCHEMA_VERSION, UPGRADE_REQUIRED } from "@ggui-ai/protocol";
|
|
10
10
|
import { defaultAppIdFromIdentity, resolveIdentityFromHeaders, UnauthenticatedError, } from "../auth.js";
|
|
11
|
+
import { createWsSink } from "./outbound.js";
|
|
11
12
|
/**
|
|
12
13
|
* Marker `userId` (and `workspaceId`) `resolveIdentityFromUpgrade`
|
|
13
14
|
* returns for a bootstrap-gated upgrade — see the "WS-token gate"
|
|
@@ -277,6 +278,24 @@ export function createSubscribeHandlers(deps) {
|
|
|
277
278
|
return;
|
|
278
279
|
}
|
|
279
280
|
}
|
|
281
|
+
// Transport-neutral tail — shared verbatim with the SSE attach
|
|
282
|
+
// path (`attachExternalSubscriber`). The WS head above resolved
|
|
283
|
+
// auth + render; the tail owns snapshot → replay → register →
|
|
284
|
+
// ack → ledger replay → stream replay.
|
|
285
|
+
await completeSubscribe({
|
|
286
|
+
stored,
|
|
287
|
+
identity: effectiveIdentity,
|
|
288
|
+
sink: createWsSink(ws, deps.logger),
|
|
289
|
+
transport: "ws",
|
|
290
|
+
ws,
|
|
291
|
+
...(payload.sinceSequence !== undefined ? { sinceSequence: payload.sinceSequence } : {}),
|
|
292
|
+
...(payload.fromSeq !== undefined ? { fromSeq: payload.fromSeq } : {}),
|
|
293
|
+
...(message.requestId ? { requestId: message.requestId } : {}),
|
|
294
|
+
...(mintedSessionToken !== undefined ? { sessionToken: mintedSessionToken } : {}),
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
async function completeSubscribe(args) {
|
|
298
|
+
const { stored, sink } = args;
|
|
280
299
|
// Snapshot the outbound-stream cursor BEFORE registering the
|
|
281
300
|
// subscriber. Any concurrent producer that calls sendToGguiSession
|
|
282
301
|
// between here and registration gets seq > snapshotSeq, so the
|
|
@@ -300,8 +319,8 @@ export function createSubscribeHandlers(deps) {
|
|
|
300
319
|
const activeStreamSpec = activeItem.type !== "mcpApps" && activeItem.type !== "system"
|
|
301
320
|
? activeItem.streamSpec
|
|
302
321
|
: undefined;
|
|
303
|
-
const replay =
|
|
304
|
-
? await deps.streamBuffer.replay(stored.id,
|
|
322
|
+
const replay = args.fromSeq !== undefined
|
|
323
|
+
? await deps.streamBuffer.replay(stored.id, args.fromSeq, activeStreamSpec)
|
|
305
324
|
: await deps.streamBuffer.replay(stored.id, 0, undefined);
|
|
306
325
|
// Subscribe to the StreamFanout BEFORE constructing the Subscriber:
|
|
307
326
|
// the seam returns an AsyncIterable whose iterator we hand off; the
|
|
@@ -310,29 +329,34 @@ export function createSubscribeHandlers(deps) {
|
|
|
310
329
|
// point onward queues into our iterator — paired with the
|
|
311
330
|
// replayCompletedSeq cursor below, that's race-free.
|
|
312
331
|
const fanoutIter = deps.streamFanout.subscribe(stored.id)[Symbol.asyncIterator]();
|
|
313
|
-
const
|
|
314
|
-
ws,
|
|
332
|
+
const base = {
|
|
315
333
|
sessionId: stored.id,
|
|
316
334
|
appId: stored.appId,
|
|
317
|
-
identity:
|
|
335
|
+
identity: args.identity,
|
|
318
336
|
connectedAt: Date.now(),
|
|
319
337
|
replayCompletedSeq: snapshotSeq,
|
|
320
338
|
iter: fanoutIter,
|
|
321
339
|
// Per-subscriber channel-subscribe tracker. Populated
|
|
322
340
|
// lazily by the `channel_subscribe` handler when the operator
|
|
323
|
-
// wired `streamWebSocketLocalTools`; stays empty otherwise
|
|
341
|
+
// wired `streamWebSocketLocalTools`; stays empty otherwise
|
|
342
|
+
// (structurally so for SSE — no inbound channel).
|
|
324
343
|
channelSubs: new Map(),
|
|
344
|
+
sink,
|
|
325
345
|
};
|
|
346
|
+
const sub = args.transport === "ws"
|
|
347
|
+
? { ...base, transport: "ws", ws: args.ws }
|
|
348
|
+
: { ...base, transport: "sse" };
|
|
326
349
|
deps.register(sub);
|
|
327
350
|
deps.logger.info("render_channel_subscribed", {
|
|
328
351
|
sessionId: stored.id,
|
|
329
352
|
appId: stored.appId,
|
|
330
|
-
identityKind:
|
|
331
|
-
|
|
353
|
+
identityKind: args.identity.identity.kind,
|
|
354
|
+
transport: args.transport,
|
|
355
|
+
fromSeq: args.fromSeq,
|
|
332
356
|
snapshotSeq,
|
|
333
357
|
replayCount: replay?.envelopes.length ?? 0,
|
|
334
358
|
replayTruncated: replay?.truncated ?? false,
|
|
335
|
-
bootstrap:
|
|
359
|
+
bootstrap: args.sessionToken !== undefined,
|
|
336
360
|
});
|
|
337
361
|
const ackPayload = {
|
|
338
362
|
sequence: stored.eventSequence,
|
|
@@ -346,14 +370,14 @@ export function createSubscribeHandlers(deps) {
|
|
|
346
370
|
// the handshake ignore the field (legacy-pass-through).
|
|
347
371
|
serverVersion: PROTOCOL_SCHEMA_VERSION,
|
|
348
372
|
...(replay?.truncated ? { replayTruncated: true } : {}),
|
|
349
|
-
...(
|
|
373
|
+
...(args.sessionToken !== undefined ? { sessionToken: args.sessionToken } : {}),
|
|
350
374
|
};
|
|
351
|
-
|
|
375
|
+
sink.write({
|
|
352
376
|
type: "ack",
|
|
353
377
|
payload: ackPayload,
|
|
354
|
-
...(
|
|
378
|
+
...(args.requestId ? { requestId: args.requestId } : {}),
|
|
355
379
|
});
|
|
356
|
-
// R7 — GguiSessionEvent ledger replay. When `
|
|
380
|
+
// R7 — GguiSessionEvent ledger replay. When `args.sinceSequence` is
|
|
357
381
|
// present, fetch events with `seq > sinceSequence` from the per-
|
|
358
382
|
// render ledger and emit each as a `render_event` wire frame
|
|
359
383
|
// BEFORE the per-channel stream-buffer replay. Consumers dispatch
|
|
@@ -361,38 +385,70 @@ export function createSubscribeHandlers(deps) {
|
|
|
361
385
|
// (render/props_update/etc.) — same cursor model as the HTTP
|
|
362
386
|
// `/api/sessions/:id/events?sinceSequence=N` endpoint.
|
|
363
387
|
//
|
|
388
|
+
// Each replay frame carries `resumeId` = the ledger seq — the SSE
|
|
389
|
+
// sink stamps it as the `id:` field so the browser's Last-Event-ID
|
|
390
|
+
// lands on the exact same cursor space; the WS sink ignores it.
|
|
391
|
+
//
|
|
392
|
+
// Pagination: a hasMore-loop over 100-event pages (the HTTP
|
|
393
|
+
// route's default page size). Strict superset of the previous
|
|
394
|
+
// single-page-100 WS behavior.
|
|
395
|
+
//
|
|
364
396
|
// Horizon gate: a cursor below the server's replay horizon OR
|
|
365
397
|
// above `lastSequence` (stale from a different deployment) emits
|
|
366
398
|
// an error frame with `code: 'REPLAY_HORIZON_PASSED'` and skips
|
|
367
|
-
// the replay.
|
|
368
|
-
|
|
369
|
-
|
|
399
|
+
// the replay. The frame carries `resumeId` = lastSequence — a
|
|
400
|
+
// dispatched id-bearing frame advances the browser's Last-Event-ID
|
|
401
|
+
// to the fresh high-water mark, and the ack snapshot already
|
|
402
|
+
// re-mounted state. Client recovery: re-mount from a fresh /state
|
|
403
|
+
// read (WS) / the already-delivered ack (SSE).
|
|
404
|
+
if (args.sinceSequence !== undefined) {
|
|
405
|
+
const sinceSeq = args.sinceSequence;
|
|
370
406
|
if (sinceSeq < 0 || !Number.isInteger(sinceSeq)) {
|
|
371
|
-
|
|
407
|
+
sink.write({
|
|
408
|
+
type: "error",
|
|
409
|
+
payload: {
|
|
410
|
+
code: "INVALID_SINCE_SEQUENCE",
|
|
411
|
+
message: "sinceSequence must be a non-negative integer",
|
|
412
|
+
},
|
|
413
|
+
...(args.requestId ? { requestId: args.requestId } : {}),
|
|
414
|
+
});
|
|
372
415
|
}
|
|
373
416
|
else {
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
417
|
+
let cursor = sinceSeq;
|
|
418
|
+
let firstPage = true;
|
|
419
|
+
for (;;) {
|
|
420
|
+
const ledger = await deps.renderStore.listEventsSince(stored.id, cursor, 100);
|
|
421
|
+
if (ledger === null) {
|
|
422
|
+
// GguiSession disappeared between resolve and ledger read —
|
|
423
|
+
// already handled by the broader error envelope path;
|
|
424
|
+
// nothing to do here.
|
|
425
|
+
break;
|
|
426
|
+
}
|
|
427
|
+
if (firstPage && (sinceSeq > ledger.lastSequence || sinceSeq < ledger.horizonSeq)) {
|
|
428
|
+
sink.write({
|
|
429
|
+
type: "error",
|
|
430
|
+
payload: {
|
|
431
|
+
code: "REPLAY_HORIZON_PASSED",
|
|
432
|
+
message: `cursor ${sinceSeq} is outside replayable range [${ledger.horizonSeq}, ${ledger.lastSequence}]`,
|
|
433
|
+
details: { currentSequence: ledger.lastSequence },
|
|
434
|
+
},
|
|
435
|
+
...(args.requestId ? { requestId: args.requestId } : {}),
|
|
436
|
+
}, { resumeId: String(ledger.lastSequence) });
|
|
437
|
+
break;
|
|
438
|
+
}
|
|
439
|
+
firstPage = false;
|
|
387
440
|
for (const event of ledger.events) {
|
|
388
441
|
// GguiSessionEvent is now the wire-shape ledger primitive
|
|
389
442
|
// (Wave 7 of flatten-render-identity, 2026-05-28); no
|
|
390
443
|
// projection — emit the store's row directly.
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
payload: event,
|
|
394
|
-
});
|
|
444
|
+
sink.write({ type: "render_event", payload: event }, { resumeId: String(event.seq) });
|
|
445
|
+
cursor = event.seq;
|
|
395
446
|
}
|
|
447
|
+
// Defensive: an empty page with hasMore would loop forever;
|
|
448
|
+
// the store contract never produces it, but a broken adapter
|
|
449
|
+
// must not spin the server.
|
|
450
|
+
if (!ledger.hasMore || ledger.events.length === 0)
|
|
451
|
+
break;
|
|
396
452
|
}
|
|
397
453
|
}
|
|
398
454
|
}
|
|
@@ -403,9 +459,14 @@ export function createSubscribeHandlers(deps) {
|
|
|
403
459
|
// the single source of truth for ordering.
|
|
404
460
|
if (replay) {
|
|
405
461
|
for (const env of replay.envelopes) {
|
|
406
|
-
|
|
462
|
+
sink.write({ type: "data", payload: env });
|
|
407
463
|
}
|
|
408
464
|
}
|
|
465
|
+
return {
|
|
466
|
+
detach: () => {
|
|
467
|
+
deps.unregister(sub);
|
|
468
|
+
},
|
|
469
|
+
};
|
|
409
470
|
}
|
|
410
|
-
return { resolveIdentityFromUpgrade, handleSubscribe };
|
|
471
|
+
return { resolveIdentityFromUpgrade, handleSubscribe, completeSubscribe };
|
|
411
472
|
}
|
|
@@ -1,26 +1,35 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Subscriber lifecycle for the live channel — registration into the
|
|
3
3
|
* shared subscriber set, the per-subscriber live-tail pump loop, and
|
|
4
4
|
* the symmetric teardown path (`unregister`) that ends the pump,
|
|
5
5
|
* unhooks the StreamFanout subscription, and clears every
|
|
6
6
|
* `channel_subscribe` polling loop the subscriber owned.
|
|
7
7
|
*
|
|
8
|
+
* Transport-neutral: subscribers write through their `SubscriberSink`,
|
|
9
|
+
* so WS-attached and SSE-attached subscribers share this module
|
|
10
|
+
* unchanged. Only the ws→subscriber reverse index is WS-specific —
|
|
11
|
+
* it is populated for `transport: 'ws'` rows only, because it exists
|
|
12
|
+
* for the inbound socket-router dispatch SSE does not have.
|
|
13
|
+
*
|
|
8
14
|
* Owns the per-render subscriber counter that drives the
|
|
9
15
|
* `onFirstSubscriber` / `onLastSubscriberGone` 0↔1 transition hooks —
|
|
10
|
-
* no other module reads it.
|
|
16
|
+
* no other module reads it. The hooks fire for SSE attaches too:
|
|
17
|
+
* hosted cross-pod pubsub scoping keys on "does this pod hold any
|
|
18
|
+
* subscriber for this render", regardless of transport.
|
|
11
19
|
*/
|
|
12
|
-
import type { WebSocketMessage } from "@ggui-ai/protocol/transport/websocket";
|
|
13
20
|
import type { WebSocket } from "ws";
|
|
14
21
|
import type { Logger } from "../logger.js";
|
|
15
|
-
import type { Subscriber } from "./internal-types.js";
|
|
22
|
+
import type { Subscriber, WsSubscriber } from "./internal-types.js";
|
|
16
23
|
export interface SubscriberLifecycleDeps {
|
|
17
24
|
readonly logger: Logger;
|
|
18
|
-
/** Flat set of all live WS
|
|
25
|
+
/** Flat set of all live subscribers (WS + SSE) — membership owned HERE. */
|
|
19
26
|
readonly wsSubscribers: Set<Subscriber>;
|
|
20
|
-
/**
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
27
|
+
/**
|
|
28
|
+
* ws → subscriber reverse index so socket-close / inbound dispatch
|
|
29
|
+
* can look up cheaply. WS-only by construction: populated for
|
|
30
|
+
* `transport: 'ws'` subscribers only.
|
|
31
|
+
*/
|
|
32
|
+
readonly subscribersByWs: WeakMap<WebSocket, WsSubscriber>;
|
|
24
33
|
/** 0→1 transition hook — see `GguiSessionChannelOptions.onFirstSubscriber`. */
|
|
25
34
|
readonly onFirstSubscriber?: (sessionId: string) => void;
|
|
26
35
|
/** 1→0 transition hook — see `GguiSessionChannelOptions.onLastSubscriberGone`. */
|
|
@@ -30,11 +39,12 @@ export interface SubscriberLifecycle {
|
|
|
30
39
|
/** Add a subscriber to the live set and start its pump loop. */
|
|
31
40
|
register(sub: Subscriber): void;
|
|
32
41
|
/**
|
|
33
|
-
* Tear down
|
|
34
|
-
*
|
|
35
|
-
*
|
|
42
|
+
* Tear down `sub` (if still registered): remove from the live set,
|
|
43
|
+
* end the fanout iterator (terminates the pump), clear every
|
|
44
|
+
* channel-subscribe polling timer. Idempotent — a second call for
|
|
45
|
+
* the same subscriber is a no-op.
|
|
36
46
|
*/
|
|
37
|
-
unregister(
|
|
47
|
+
unregister(sub: Subscriber): void;
|
|
38
48
|
}
|
|
39
49
|
export declare function createSubscriberLifecycle(deps: SubscriberLifecycleDeps): SubscriberLifecycle;
|
|
40
50
|
//# sourceMappingURL=subscriber-lifecycle.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"subscriber-lifecycle.d.ts","sourceRoot":"","sources":["../../src/ggui-session-channel/subscriber-lifecycle.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"subscriber-lifecycle.d.ts","sourceRoot":"","sources":["../../src/ggui-session-channel/subscriber-lifecycle.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAEH,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,IAAI,CAAC;AACpC,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,cAAc,CAAC;AAC3C,OAAO,KAAK,EAAE,UAAU,EAAE,YAAY,EAAE,MAAM,qBAAqB,CAAC;AAEpE,MAAM,WAAW,uBAAuB;IACtC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,2EAA2E;IAC3E,QAAQ,CAAC,aAAa,EAAE,GAAG,CAAC,UAAU,CAAC,CAAC;IACxC;;;;OAIG;IACH,QAAQ,CAAC,eAAe,EAAE,OAAO,CAAC,SAAS,EAAE,YAAY,CAAC,CAAC;IAC3D,+EAA+E;IAC/E,QAAQ,CAAC,iBAAiB,CAAC,EAAE,CAAC,SAAS,EAAE,MAAM,KAAK,IAAI,CAAC;IACzD,kFAAkF;IAClF,QAAQ,CAAC,oBAAoB,CAAC,EAAE,CAAC,SAAS,EAAE,MAAM,KAAK,IAAI,CAAC;CAC7D;AAED,MAAM,WAAW,mBAAmB;IAClC,gEAAgE;IAChE,QAAQ,CAAC,GAAG,EAAE,UAAU,GAAG,IAAI,CAAC;IAChC;;;;;OAKG;IACH,UAAU,CAAC,GAAG,EAAE,UAAU,GAAG,IAAI,CAAC;CACnC;AAED,wBAAgB,yBAAyB,CAAC,IAAI,EAAE,uBAAuB,GAAG,mBAAmB,CAiH5F"}
|
|
@@ -1,13 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Subscriber lifecycle for the live channel — registration into the
|
|
3
3
|
* shared subscriber set, the per-subscriber live-tail pump loop, and
|
|
4
4
|
* the symmetric teardown path (`unregister`) that ends the pump,
|
|
5
5
|
* unhooks the StreamFanout subscription, and clears every
|
|
6
6
|
* `channel_subscribe` polling loop the subscriber owned.
|
|
7
7
|
*
|
|
8
|
+
* Transport-neutral: subscribers write through their `SubscriberSink`,
|
|
9
|
+
* so WS-attached and SSE-attached subscribers share this module
|
|
10
|
+
* unchanged. Only the ws→subscriber reverse index is WS-specific —
|
|
11
|
+
* it is populated for `transport: 'ws'` rows only, because it exists
|
|
12
|
+
* for the inbound socket-router dispatch SSE does not have.
|
|
13
|
+
*
|
|
8
14
|
* Owns the per-render subscriber counter that drives the
|
|
9
15
|
* `onFirstSubscriber` / `onLastSubscriberGone` 0↔1 transition hooks —
|
|
10
|
-
* no other module reads it.
|
|
16
|
+
* no other module reads it. The hooks fire for SSE attaches too:
|
|
17
|
+
* hosted cross-pod pubsub scoping keys on "does this pod hold any
|
|
18
|
+
* subscriber for this render", regardless of transport.
|
|
11
19
|
*/
|
|
12
20
|
export function createSubscriberLifecycle(deps) {
|
|
13
21
|
/**
|
|
@@ -21,7 +29,7 @@ export function createSubscriberLifecycle(deps) {
|
|
|
21
29
|
const renderCountById = new Map();
|
|
22
30
|
/**
|
|
23
31
|
* Pump live frames from the StreamFanout iterator out to this
|
|
24
|
-
* subscriber's
|
|
32
|
+
* subscriber's sink. Started fire-and-forget by `register`; ends when
|
|
25
33
|
* the iterator yields done (close() on the seam) OR `unregister`
|
|
26
34
|
* calls `iter.return()`. Per-subscriber seq filter applied here:
|
|
27
35
|
* frames with `seq <= replayCompletedSeq` were (or will be)
|
|
@@ -42,11 +50,11 @@ export function createSubscriberLifecycle(deps) {
|
|
|
42
50
|
return;
|
|
43
51
|
if (value.seq <= sub.replayCompletedSeq)
|
|
44
52
|
continue;
|
|
45
|
-
if (sub.
|
|
53
|
+
if (!sub.sink.isOpen()) {
|
|
46
54
|
await sub.iter.return?.();
|
|
47
55
|
return;
|
|
48
56
|
}
|
|
49
|
-
|
|
57
|
+
sub.sink.write({ type: "data", payload: value });
|
|
50
58
|
}
|
|
51
59
|
}
|
|
52
60
|
catch (err) {
|
|
@@ -58,7 +66,9 @@ export function createSubscriberLifecycle(deps) {
|
|
|
58
66
|
}
|
|
59
67
|
function register(sub) {
|
|
60
68
|
deps.wsSubscribers.add(sub);
|
|
61
|
-
|
|
69
|
+
if (sub.transport === "ws") {
|
|
70
|
+
deps.subscribersByWs.set(sub.ws, sub);
|
|
71
|
+
}
|
|
62
72
|
// Per-render count bookkeeping + 0→1 hook for cloud pubsub
|
|
63
73
|
// adapter scoping. Increment FIRST so the hook sees the up-to-date
|
|
64
74
|
// state; hook fires only on the transition (prevCount === 0).
|
|
@@ -70,7 +80,7 @@ export function createSubscriberLifecycle(deps) {
|
|
|
70
80
|
}
|
|
71
81
|
catch (err) {
|
|
72
82
|
// Best-effort: a thrown hook MUST NOT corrupt the
|
|
73
|
-
// wsSubscribers set vs the real
|
|
83
|
+
// wsSubscribers set vs the real transport lifecycle.
|
|
74
84
|
deps.logger.warn("render_channel_on_first_subscriber_threw", {
|
|
75
85
|
sessionId: sub.sessionId,
|
|
76
86
|
error: String(err),
|
|
@@ -81,12 +91,15 @@ export function createSubscriberLifecycle(deps) {
|
|
|
81
91
|
// inside pumpSubscriber, never propagated.
|
|
82
92
|
void pumpSubscriber(sub);
|
|
83
93
|
}
|
|
84
|
-
function unregister(
|
|
85
|
-
|
|
86
|
-
|
|
94
|
+
function unregister(sub) {
|
|
95
|
+
// Idempotency gate: membership in the live set is the single
|
|
96
|
+
// "still registered" signal, shared across transports.
|
|
97
|
+
if (!deps.wsSubscribers.has(sub))
|
|
87
98
|
return;
|
|
88
|
-
deps.subscribersByWs.delete(ws);
|
|
89
99
|
deps.wsSubscribers.delete(sub);
|
|
100
|
+
if (sub.transport === "ws") {
|
|
101
|
+
deps.subscribersByWs.delete(sub.ws);
|
|
102
|
+
}
|
|
90
103
|
// Per-render count bookkeeping + 1→0 hook (symmetric with register).
|
|
91
104
|
const prevCount = renderCountById.get(sub.sessionId) ?? 0;
|
|
92
105
|
if (prevCount <= 1) {
|
|
@@ -20,11 +20,13 @@
|
|
|
20
20
|
* mutation handlers (ggui_emit / connector `ctx.send`). Validated
|
|
21
21
|
* through `assertStreamContract` before delivery.
|
|
22
22
|
*
|
|
23
|
-
* `props_update`: the agent-driven `ggui_update`
|
|
24
|
-
* `channel.sendPropsUpdate(sessionId, props)`
|
|
25
|
-
* `propsUpdateNotifier`) to fan a
|
|
26
|
-
* live subscribers. Reaches the
|
|
27
|
-
* branch in `iframe-runtime
|
|
23
|
+
* `props_update`: the agent-driven `ggui_update` / `ggui_amend`
|
|
24
|
+
* handlers call `channel.sendPropsUpdate(sessionId, props, epoch)`
|
|
25
|
+
* (wired as their `propsUpdateNotifier`) to fan a
|
|
26
|
+
* `{type:'props_update'}` frame to live subscribers. Reaches the
|
|
27
|
+
* renderer's `props_update` branch in `iframe-runtime`, which applies
|
|
28
|
+
* new props in-place — or freezes the mount when the frame's `epoch`
|
|
29
|
+
* exceeds its own (#483 freeze latch).
|
|
28
30
|
*
|
|
29
31
|
* Not handled here:
|
|
30
32
|
*
|
|
@@ -56,6 +58,7 @@ import type { WebSocketMessage } from "@ggui-ai/protocol/transport/websocket";
|
|
|
56
58
|
import type { IncomingMessage } from "node:http";
|
|
57
59
|
import type { Duplex } from "node:stream";
|
|
58
60
|
import { type GguiSessionChannelLocalToolsOptions } from "./ggui-session-channel/channel-subscriptions.js";
|
|
61
|
+
import type { SubscriberSink } from "./ggui-session-channel/internal-types.js";
|
|
59
62
|
import { type GguiSessionChannelBootstrap, type GguiSessionChannelCookieAuth } from "./ggui-session-channel/subscribe.js";
|
|
60
63
|
import type { Logger } from "./logger.js";
|
|
61
64
|
/** Default URL path for the channel endpoint. Operators can override. */
|
|
@@ -110,6 +113,17 @@ export declare const DEFAULT_PRE_SUBSCRIBE_IDLE_MS = 30000;
|
|
|
110
113
|
export declare const DEFAULT_MAX_PRE_SUBSCRIBE_CONNECTIONS = 1024;
|
|
111
114
|
export type { GguiSessionChannelLocalToolsOptions } from "./ggui-session-channel/channel-subscriptions.js";
|
|
112
115
|
export type { GguiSessionChannelBootstrap, GguiSessionChannelBootstrapRefreshResult, GguiSessionChannelBootstrapVerifyResult, GguiSessionChannelCookieAuth, } from "./ggui-session-channel/subscribe.js";
|
|
116
|
+
export type { SubscriberSink } from "./ggui-session-channel/internal-types.js";
|
|
117
|
+
/**
|
|
118
|
+
* Thrown by {@link GguiSessionChannelServer.attachExternalSubscriber}
|
|
119
|
+
* when the target render does not exist OR is bound to a different
|
|
120
|
+
* app than the caller claims. Callers that pre-gated (the SSE route
|
|
121
|
+
* does) treat this as a lost race with eviction; callers that did not
|
|
122
|
+
* MUST map it to their 404 surface.
|
|
123
|
+
*/
|
|
124
|
+
export declare class ChannelSessionNotFoundError extends Error {
|
|
125
|
+
constructor(sessionId: string);
|
|
126
|
+
}
|
|
113
127
|
export interface GguiSessionChannelOptions {
|
|
114
128
|
/** Required — the render backing store (typically `InMemoryGguiSessionStore`). */
|
|
115
129
|
readonly renderStore: GguiSessionStore;
|
|
@@ -432,11 +446,13 @@ export interface GguiSessionChannelServer {
|
|
|
432
446
|
*/
|
|
433
447
|
notifyGguiSessionCommit(sessionId: string, render: GguiSession, matchType?: string): void;
|
|
434
448
|
/**
|
|
435
|
-
* Fan a `{type:'props_update', payload:{sessionId, props}}`
|
|
436
|
-
* to every subscriber currently bound to `sessionId`.
|
|
437
|
-
*
|
|
438
|
-
*
|
|
439
|
-
*
|
|
449
|
+
* Fan a `{type:'props_update', payload:{sessionId, props, epoch}}`
|
|
450
|
+
* wire frame to every subscriber currently bound to `sessionId`.
|
|
451
|
+
* Both mutation handlers call this (wired as their
|
|
452
|
+
* `propsUpdateNotifier`): `ggui_amend` fans the unchanged head
|
|
453
|
+
* epoch so the live mount repaints in place, `ggui_update` fans its
|
|
454
|
+
* freshly-advanced epoch so superseded mounts freeze (#483 latch)
|
|
455
|
+
* — no waiting for a resubscribe either way.
|
|
440
456
|
*
|
|
441
457
|
* Validation posture (mirrors `notifyGguiSessionCommit`'s "best-effort orphan
|
|
442
458
|
* no-op"):
|
|
@@ -464,7 +480,7 @@ export interface GguiSessionChannelServer {
|
|
|
464
480
|
* `render.propsSpec` before applying — defense-in-depth at the
|
|
465
481
|
* receiving boundary.
|
|
466
482
|
*/
|
|
467
|
-
sendPropsUpdate(sessionId: string, props: JsonObject): Promise<void>;
|
|
483
|
+
sendPropsUpdate(sessionId: string, props: JsonObject, epoch: number): Promise<void>;
|
|
468
484
|
/**
|
|
469
485
|
* Fan a `{type:'drain_ack', payload:{sessionId, appId,
|
|
470
486
|
* eventId, drainedAt}}` wire frame to every subscriber currently
|
|
@@ -508,6 +524,37 @@ export interface GguiSessionChannelServer {
|
|
|
508
524
|
* subscriber send failures are logged but never propagated.
|
|
509
525
|
*/
|
|
510
526
|
externalBroadcast(sessionId: string, frame: WebSocketMessage): void;
|
|
527
|
+
/**
|
|
528
|
+
* Register an externally-transported (non-WS) subscriber into the
|
|
529
|
+
* SAME fan-out planes WS subscribers ride: the StreamFanout live
|
|
530
|
+
* tail, the direct walks (`props_update` / `render` / `drain_ack` /
|
|
531
|
+
* external broadcasts), and the subscribe tail's ack → ledger-replay
|
|
532
|
+
* → stream-replay ordering. The single consumer today is the SSE
|
|
533
|
+
* route (`GET /api/sessions/:sessionId/stream`).
|
|
534
|
+
*
|
|
535
|
+
* The caller owns transport-level auth (the SSE route clones the
|
|
536
|
+
* /state wsToken gate) — this method only re-checks render existence
|
|
537
|
+
* + appId binding as defense-in-depth, throwing
|
|
538
|
+
* {@link ChannelSessionNotFoundError} on mismatch.
|
|
539
|
+
*
|
|
540
|
+
* `sinceSequence` replays the GguiSessionEvent ledger as
|
|
541
|
+
* `render_event` frames (each written with `resumeId` = ledger seq —
|
|
542
|
+
* the SSE sink stamps it as the `id:` field); `fromSeq` replays the
|
|
543
|
+
* stream buffer as `data` frames. Returns `detach` — idempotent
|
|
544
|
+
* teardown that unregisters the subscriber, ends its pump, and fires
|
|
545
|
+
* the 1→0 subscriber hook when applicable. The caller MUST invoke it
|
|
546
|
+
* on transport close.
|
|
547
|
+
*/
|
|
548
|
+
attachExternalSubscriber(args: {
|
|
549
|
+
readonly sessionId: string;
|
|
550
|
+
readonly appId: string;
|
|
551
|
+
readonly identity: AuthResult;
|
|
552
|
+
readonly sink: SubscriberSink;
|
|
553
|
+
readonly sinceSequence?: number;
|
|
554
|
+
readonly fromSeq?: number;
|
|
555
|
+
}): Promise<{
|
|
556
|
+
readonly detach: () => void;
|
|
557
|
+
}>;
|
|
511
558
|
/** Number of live subscribers. Useful for health / debug introspection. */
|
|
512
559
|
readonly subscriberCount: number;
|
|
513
560
|
/** Number of distinct renders with at least one subscriber. */
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ggui-session-channel.d.ts","sourceRoot":"","sources":["../src/ggui-session-channel.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"ggui-session-channel.d.ts","sourceRoot":"","sources":["../src/ggui-session-channel.ts"],"names":[],"mappings":"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"}
|
|
@@ -20,11 +20,13 @@
|
|
|
20
20
|
* mutation handlers (ggui_emit / connector `ctx.send`). Validated
|
|
21
21
|
* through `assertStreamContract` before delivery.
|
|
22
22
|
*
|
|
23
|
-
* `props_update`: the agent-driven `ggui_update`
|
|
24
|
-
* `channel.sendPropsUpdate(sessionId, props)`
|
|
25
|
-
* `propsUpdateNotifier`) to fan a
|
|
26
|
-
* live subscribers. Reaches the
|
|
27
|
-
* branch in `iframe-runtime
|
|
23
|
+
* `props_update`: the agent-driven `ggui_update` / `ggui_amend`
|
|
24
|
+
* handlers call `channel.sendPropsUpdate(sessionId, props, epoch)`
|
|
25
|
+
* (wired as their `propsUpdateNotifier`) to fan a
|
|
26
|
+
* `{type:'props_update'}` frame to live subscribers. Reaches the
|
|
27
|
+
* renderer's `props_update` branch in `iframe-runtime`, which applies
|
|
28
|
+
* new props in-place — or freezes the mount when the frame's `epoch`
|
|
29
|
+
* exceeds its own (#483 freeze latch).
|
|
28
30
|
*
|
|
29
31
|
* Not handled here:
|
|
30
32
|
*
|
|
@@ -109,6 +111,19 @@ export const DEFAULT_PRE_SUBSCRIBE_IDLE_MS = 30_000;
|
|
|
109
111
|
* Disable with `0`.
|
|
110
112
|
*/
|
|
111
113
|
export const DEFAULT_MAX_PRE_SUBSCRIBE_CONNECTIONS = 1_024;
|
|
114
|
+
/**
|
|
115
|
+
* Thrown by {@link GguiSessionChannelServer.attachExternalSubscriber}
|
|
116
|
+
* when the target render does not exist OR is bound to a different
|
|
117
|
+
* app than the caller claims. Callers that pre-gated (the SSE route
|
|
118
|
+
* does) treat this as a lost race with eviction; callers that did not
|
|
119
|
+
* MUST map it to their 404 surface.
|
|
120
|
+
*/
|
|
121
|
+
export class ChannelSessionNotFoundError extends Error {
|
|
122
|
+
constructor(sessionId) {
|
|
123
|
+
super(`GguiSession '${sessionId}' not found (or bound to a different app)`);
|
|
124
|
+
this.name = "ChannelSessionNotFoundError";
|
|
125
|
+
}
|
|
126
|
+
}
|
|
112
127
|
/**
|
|
113
128
|
* Build an OSS live-channel server. The returned object is designed to be
|
|
114
129
|
* composed into `createGguiServer` — see `server.ts` for the wire-up.
|
|
@@ -141,13 +156,18 @@ export function createGguiSessionChannelServer(opts) {
|
|
|
141
156
|
? new WebSocketServer({ noServer: true, maxPayload: wsMaxPayloadBytes })
|
|
142
157
|
: new WebSocketServer({ noServer: true });
|
|
143
158
|
/**
|
|
144
|
-
* Flat set of all live
|
|
145
|
-
* `subscribersByRender` Map —
|
|
146
|
-
* this set tracks
|
|
147
|
-
* that the seam can't see
|
|
159
|
+
* Flat set of all live subscribers — WS-attached AND externally-
|
|
160
|
+
* attached (SSE). Replaces the per-render `subscribersByRender` Map —
|
|
161
|
+
* routing is now StreamFanout's job; this set tracks channel-level
|
|
162
|
+
* bookkeeping (stats, shutdown-broadcast) that the seam can't see
|
|
163
|
+
* (and shouldn't).
|
|
148
164
|
*/
|
|
149
165
|
const wsSubscribers = new Set();
|
|
150
|
-
/**
|
|
166
|
+
/**
|
|
167
|
+
* ws → subscriber reverse index so socket-close / inbound dispatch
|
|
168
|
+
* can look up cheaply. WS-only: the lifecycle module populates it
|
|
169
|
+
* for `transport: 'ws'` subscribers only.
|
|
170
|
+
*/
|
|
151
171
|
const subscribersByWs = new WeakMap();
|
|
152
172
|
// Send / fan-out family — wire-write primitives shared by every
|
|
153
173
|
// handler module plus the public fan-out surfaces the returned
|
|
@@ -169,7 +189,6 @@ export function createGguiSessionChannelServer(opts) {
|
|
|
169
189
|
logger: opts.logger,
|
|
170
190
|
wsSubscribers,
|
|
171
191
|
subscribersByWs,
|
|
172
|
-
send,
|
|
173
192
|
onFirstSubscriber: opts.onFirstSubscriber,
|
|
174
193
|
onLastSubscriberGone: opts.onLastSubscriberGone,
|
|
175
194
|
});
|
|
@@ -197,7 +216,7 @@ export function createGguiSessionChannelServer(opts) {
|
|
|
197
216
|
// Subscribe / credential-path family — upgrade-time identity
|
|
198
217
|
// resolution (bearer / bootstrap / console cookie) + the
|
|
199
218
|
// `subscribe` handler. See `ggui-session-channel/subscribe.ts`.
|
|
200
|
-
const { resolveIdentityFromUpgrade, handleSubscribe } = createSubscribeHandlers({
|
|
219
|
+
const { resolveIdentityFromUpgrade, handleSubscribe, completeSubscribe } = createSubscribeHandlers({
|
|
201
220
|
logger: opts.logger,
|
|
202
221
|
auth: opts.auth,
|
|
203
222
|
renderStore: opts.renderStore,
|
|
@@ -207,9 +226,9 @@ export function createGguiSessionChannelServer(opts) {
|
|
|
207
226
|
cookieAuth: opts.cookieAuth,
|
|
208
227
|
appIdFromIdentity: opts.appIdFromIdentity,
|
|
209
228
|
versionPolicy: opts.versionPolicy,
|
|
210
|
-
send,
|
|
211
229
|
sendError,
|
|
212
230
|
register,
|
|
231
|
+
unregister,
|
|
213
232
|
});
|
|
214
233
|
// WS lifecycle / message routing — the `connection` handler with
|
|
215
234
|
// the per-socket inbound ordering chain, pre-subscribe identity +
|
|
@@ -272,6 +291,24 @@ export function createGguiSessionChannelServer(opts) {
|
|
|
272
291
|
sendPropsUpdate: outbound.sendPropsUpdate,
|
|
273
292
|
sendDrainAck: outbound.sendDrainAck,
|
|
274
293
|
externalBroadcast: outbound.externalBroadcast,
|
|
294
|
+
async attachExternalSubscriber(args) {
|
|
295
|
+
// Defense-in-depth re-check — the transport route already gated
|
|
296
|
+
// on the wsToken's (sessionId, appId) claims, but this method is
|
|
297
|
+
// a public surface: never register a subscriber onto a render
|
|
298
|
+
// that no longer exists or belongs to a different app.
|
|
299
|
+
const stored = await opts.renderStore.get(args.sessionId);
|
|
300
|
+
if (!stored || stored.appId !== args.appId) {
|
|
301
|
+
throw new ChannelSessionNotFoundError(args.sessionId);
|
|
302
|
+
}
|
|
303
|
+
return completeSubscribe({
|
|
304
|
+
stored,
|
|
305
|
+
identity: args.identity,
|
|
306
|
+
sink: args.sink,
|
|
307
|
+
transport: "sse",
|
|
308
|
+
...(args.sinceSequence !== undefined ? { sinceSequence: args.sinceSequence } : {}),
|
|
309
|
+
...(args.fromSeq !== undefined ? { fromSeq: args.fromSeq } : {}),
|
|
310
|
+
});
|
|
311
|
+
},
|
|
275
312
|
get subscriberCount() {
|
|
276
313
|
return wsSubscribers.size;
|
|
277
314
|
},
|
|
@@ -312,12 +349,11 @@ export function createGguiSessionChannelServer(opts) {
|
|
|
312
349
|
const sessionIds = new Set();
|
|
313
350
|
for (const sub of wsSubscribers) {
|
|
314
351
|
sessionIds.add(sub.sessionId);
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
}
|
|
352
|
+
// Transport-neutral shutdown: WS maps to close(1012), SSE ends
|
|
353
|
+
// the response so EventSource auto-reconnects to the new pod —
|
|
354
|
+
// exactly the 1012 semantics. `sink.end` is best-effort by
|
|
355
|
+
// contract (never throws on an already-closing transport).
|
|
356
|
+
sub.sink.end("service_restart");
|
|
321
357
|
void sub.iter.return?.();
|
|
322
358
|
}
|
|
323
359
|
wsSubscribers.clear();
|