@ggui-ai/protocol-reference-server 0.1.0-rc.1

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/server.js ADDED
@@ -0,0 +1,224 @@
1
+ /**
2
+ * `ReferenceServer` — the minimal WS live-channel server this package
3
+ * exports. Honest scope: SPEC §12.2 wire, version handshake,
4
+ * wired-action dispatch. That's it.
5
+ *
6
+ * No auth (accepts any bearer). No persistence. No bundle loading.
7
+ * The whole point is to be narrow enough that the vendor-neutral
8
+ * separation claim (Protocol #6) is empirically grounded — if this
9
+ * server passes `@ggui-ai/protocol-conformance`, the protocol has
10
+ * no implicit `@ggui-ai/mcp-server` coupling.
11
+ */
12
+ import { createServer } from 'node:http';
13
+ import { PROTOCOL_SCHEMA_VERSION } from '@ggui-ai/protocol';
14
+ import { WebSocketServer } from 'ws';
15
+ import { dispatchAction, isActionFrame } from './action-router.js';
16
+ import { SessionStore } from './session.js';
17
+ import { ToolRegistry } from './tool-registry.js';
18
+ export class ReferenceServer {
19
+ sessions = new SessionStore();
20
+ tools = new ToolRegistry();
21
+ options;
22
+ http = null;
23
+ wss = null;
24
+ boundPort = null;
25
+ constructor(options) {
26
+ this.options = {
27
+ port: options.port,
28
+ host: options.host ?? '127.0.0.1',
29
+ strictVersionPolicy: options.strictVersionPolicy ?? true,
30
+ versionOverride: options.versionOverride ?? PROTOCOL_SCHEMA_VERSION,
31
+ };
32
+ }
33
+ /**
34
+ * Protocol schema version the server advertises in subscribe ack
35
+ * frames + UPGRADE_REQUIRED error frames. Defaults to
36
+ * `PROTOCOL_SCHEMA_VERSION`; overridable via
37
+ * {@link ReferenceServerOptions.versionOverride} for conformance
38
+ * fault injection.
39
+ */
40
+ get advertisedVersion() {
41
+ return this.options.versionOverride;
42
+ }
43
+ /** Resolved port (valid after `start()` resolves). */
44
+ get port() {
45
+ if (this.boundPort === null) {
46
+ throw new Error('reference-server: port not resolved — did you await start()?');
47
+ }
48
+ return this.boundPort;
49
+ }
50
+ /** Base URL for the kit's `runConformance({serverUrl})`. */
51
+ get baseUrl() {
52
+ return `http://${this.options.host}:${this.port}`;
53
+ }
54
+ async start() {
55
+ const http = createServer();
56
+ const wss = new WebSocketServer({ server: http, path: '/ws' });
57
+ wss.on('connection', (socket) => {
58
+ this.handleConnection(socket);
59
+ });
60
+ await new Promise((done, fail) => {
61
+ const onError = (err) => {
62
+ http.off('listening', onListening);
63
+ fail(err);
64
+ };
65
+ const onListening = () => {
66
+ http.off('error', onError);
67
+ done();
68
+ };
69
+ http.once('error', onError);
70
+ http.once('listening', onListening);
71
+ http.listen(this.options.port, this.options.host);
72
+ });
73
+ const address = http.address();
74
+ if (address === null || typeof address === 'string') {
75
+ throw new Error('reference-server: failed to resolve bound port');
76
+ }
77
+ this.boundPort = address.port;
78
+ this.http = http;
79
+ this.wss = wss;
80
+ }
81
+ async stop() {
82
+ const wss = this.wss;
83
+ const http = this.http;
84
+ if (wss !== null) {
85
+ await new Promise((done) => wss.close(() => done()));
86
+ this.wss = null;
87
+ }
88
+ if (http !== null) {
89
+ await new Promise((done) => http.close(() => done()));
90
+ this.http = null;
91
+ }
92
+ this.boundPort = null;
93
+ }
94
+ // ===========================================================================
95
+ // Connection handling
96
+ // ===========================================================================
97
+ handleConnection(socket) {
98
+ // Subscribe state is per-connection — one WS may subscribe to
99
+ // one session at a time. Re-subscribe overwrites.
100
+ let subscribedSessionId = null;
101
+ const subscriber = {
102
+ send: (frame) => {
103
+ try {
104
+ socket.send(JSON.stringify(frame));
105
+ }
106
+ catch {
107
+ // Socket lifecycle issues are the caller's problem.
108
+ }
109
+ },
110
+ };
111
+ socket.on('message', (raw) => {
112
+ void this.handleMessage(raw.toString('utf8'), {
113
+ socket,
114
+ subscriber,
115
+ onSubscribed: (sessionId) => {
116
+ // If previously subscribed to a different session, unsub
117
+ // from it first.
118
+ if (subscribedSessionId !== null && subscribedSessionId !== sessionId) {
119
+ this.sessions.removeSubscriber(subscribedSessionId, subscriber);
120
+ }
121
+ subscribedSessionId = sessionId;
122
+ },
123
+ });
124
+ });
125
+ socket.on('close', () => {
126
+ if (subscribedSessionId !== null) {
127
+ this.sessions.removeSubscriber(subscribedSessionId, subscriber);
128
+ }
129
+ });
130
+ }
131
+ async handleMessage(text, ctx) {
132
+ let frame;
133
+ try {
134
+ frame = JSON.parse(text);
135
+ }
136
+ catch {
137
+ // Malformed JSON — silently drop per SPEC non-promise "no flow
138
+ // control / no retries". Real servers would log; the reference
139
+ // server keeps it silent so `no-op` fixtures pass.
140
+ return;
141
+ }
142
+ if (frame === null || typeof frame !== 'object')
143
+ return;
144
+ const f = frame;
145
+ if (f['type'] === 'subscribe') {
146
+ this.handleSubscribe(f, {
147
+ subscriber: ctx.subscriber,
148
+ onSubscribed: ctx.onSubscribed,
149
+ socket: ctx.socket,
150
+ });
151
+ return;
152
+ }
153
+ if (f['type'] === 'action' && isActionFrame(frame)) {
154
+ const session = this.sessions.get(frame.sessionId);
155
+ if (session === undefined)
156
+ return; // drop actions for unknown sessions
157
+ await dispatchAction(frame, { session, tools: this.tools });
158
+ return;
159
+ }
160
+ // Unrecognized type — silently drop (extensibly-closed; third
161
+ // parties may send frame types we don't know about).
162
+ }
163
+ handleSubscribe(frame, ctx) {
164
+ const payload = frame['payload'];
165
+ if (payload === null || typeof payload !== 'object')
166
+ return;
167
+ const p = payload;
168
+ const sessionId = p['sessionId'];
169
+ if (typeof sessionId !== 'string')
170
+ return;
171
+ const appId = typeof p['appId'] === 'string' ? p['appId'] : 'conformance';
172
+ const requestId = typeof frame['requestId'] === 'string' ? frame['requestId'] : undefined;
173
+ const supportedVersions = Array.isArray(p['supportedVersions'])
174
+ ? p['supportedVersions'].filter((v) => typeof v === 'string')
175
+ : undefined;
176
+ // Version handshake — if the client declared `supportedVersions`
177
+ // AND our current schema-version is not in the list, emit
178
+ // UPGRADE_REQUIRED per SPEC §12.2.2.
179
+ //
180
+ // - `strictVersionPolicy: true` (default): emit + close the
181
+ // WebSocket so the caller cannot proceed.
182
+ // - `strictVersionPolicy: false` (advisory opt-out): emit +
183
+ // keep the connection open.
184
+ //
185
+ // Per-session override precedence: if the `server-version-override`
186
+ // directive set a `versionOverride` on this session BEFORE the
187
+ // subscribe landed, advertise that value instead of the instance-
188
+ // level default. Lets parallel kit fixtures share one server while
189
+ // mismatching version on exactly one session.
190
+ const existingSession = this.sessions.get(sessionId);
191
+ const advertised = existingSession?.versionOverride ?? this.options.versionOverride;
192
+ if (supportedVersions !== undefined && !supportedVersions.includes(advertised)) {
193
+ ctx.subscriber.send({
194
+ type: 'error',
195
+ payload: {
196
+ code: 'UPGRADE_REQUIRED',
197
+ message: `server advertises '${advertised}'; client supports [${supportedVersions.join(', ')}]`,
198
+ serverVersion: advertised,
199
+ },
200
+ ...(requestId !== undefined ? { requestId } : {}),
201
+ });
202
+ if (this.options.strictVersionPolicy) {
203
+ try {
204
+ ctx.socket.close();
205
+ }
206
+ catch {
207
+ // best-effort — socket may already be closing
208
+ }
209
+ }
210
+ return;
211
+ }
212
+ // Add the subscriber + emit ack.
213
+ this.sessions.addSubscriber(sessionId, ctx.subscriber);
214
+ // Preserve appId on first subscribe — create() is no-op if the
215
+ // session already exists from an earlier directive.
216
+ this.sessions.create(sessionId, appId);
217
+ ctx.onSubscribed(sessionId);
218
+ ctx.subscriber.send({
219
+ type: 'ack',
220
+ payload: { serverVersion: advertised },
221
+ ...(requestId !== undefined ? { requestId } : {}),
222
+ });
223
+ }
224
+ }
@@ -0,0 +1,142 @@
1
+ /**
2
+ * In-memory session store for the reference server.
3
+ *
4
+ * Sessions are ephemeral and process-local — this is the whole
5
+ * point of the reference server. Persistence is explicitly out of
6
+ * scope. Restart drops state; that's documented behavior, not a TODO.
7
+ *
8
+ * Each session carries an actionSpec map that the `register-actionspec`
9
+ * ConformanceHost directive populates. The action router
10
+ * (`./action-router.ts`) consults this map at dispatch time to
11
+ * resolve action-name → tool-name → handler.
12
+ */
13
+ /**
14
+ * Actionspec entry maps an action name (the value wired into DOM
15
+ * `data-ggui-action` attributes on real UIs; here sent verbatim on
16
+ * the fixture's inputEnvelope) to the registered tool name the
17
+ * router should dispatch to.
18
+ */
19
+ export interface ActionSpecEntry {
20
+ readonly name: string;
21
+ readonly tool: string;
22
+ }
23
+ /**
24
+ * Streamspec entry binds a stream channel to a "refresh tool" — the
25
+ * tool the action-router invokes after a successful wired-action
26
+ * dispatch to produce the channel's next snapshot. Mirrors the real
27
+ * `streamSpec[channel].tool` shape declared by ggui blueprints (SPEC
28
+ * §2.3 StreamSpec refresh triggers): a wired action mutates state,
29
+ * the refresh tool reads fresh state, the stream-update wraps the
30
+ * read into an envelope on the named channel.
31
+ *
32
+ * Reference-server scope: refresh-tool invocation is unconditional
33
+ * — every successful wired-action dispatch fans out through every
34
+ * registered streamSpec for the session. Real ggui servers may
35
+ * filter by which actions touch which channels; the reference
36
+ * server's narrower contract is "any successful action triggers all
37
+ * declared refreshes", which is sufficient for the kit's
38
+ * `stream-refresh-success` proof and stays under the package's
39
+ * 20–50 LOC budget for refresh-stream support.
40
+ */
41
+ export interface StreamSpecEntry {
42
+ readonly channel: string;
43
+ readonly tool: string;
44
+ }
45
+ export interface Session {
46
+ readonly sessionId: string;
47
+ readonly appId: string;
48
+ readonly actionSpecs: Map<string, ActionSpecEntry>;
49
+ readonly streamSpecs: Map<string, StreamSpecEntry>;
50
+ readonly subscribers: Set<Subscriber>;
51
+ /**
52
+ * Per-session protocol-version override. When set, the WS subscribe
53
+ * + UPGRADE_REQUIRED paths advertise this value in place of the
54
+ * server-instance-level `versionOverride`.
55
+ *
56
+ * Discipline (mirrors `ReferenceServerOptions.versionOverride`):
57
+ * conformance fault-injection ONLY. Populated exclusively by the
58
+ * `server-version-override` setup directive in the conformance host
59
+ * adapter — production code paths leave this `undefined`.
60
+ *
61
+ * Why per-session, not per-instance: parallel kit fixtures share
62
+ * one `ReferenceServer`. Mutating the instance-level override would
63
+ * leak across sessions; the per-session field scopes the mismatch
64
+ * to the one fixture that asked for it.
65
+ */
66
+ versionOverride?: string;
67
+ }
68
+ /**
69
+ * Minimal subscriber handle — the action router calls `send()` to
70
+ * emit stream frames (including `_ggui:contract-error`) back to the
71
+ * subscribed WebSocket.
72
+ */
73
+ export interface Subscriber {
74
+ send(frame: unknown): void;
75
+ }
76
+ /**
77
+ * In-memory session store. Wraps a `Map<sessionId, Session>` with
78
+ * the operations the ConformanceHost adapter + WS subscribe handler
79
+ * need. No locking — JS single-threaded; all calls originate from
80
+ * the event loop.
81
+ */
82
+ export declare class SessionStore {
83
+ private readonly sessions;
84
+ private lastCreated;
85
+ create(sessionId: string, appId: string): Session;
86
+ /**
87
+ * The sessionId most recently passed to `create()`. Used by the
88
+ * ConformanceHost's `register-actionspec` dispatcher — that
89
+ * directive doesn't carry a sessionId in its JSON shape, so the
90
+ * adapter needs the "most recently created" scope to bind the
91
+ * actionspec to. This matches the fixture-authoring convention that
92
+ * create-session always precedes register-actionspec.
93
+ */
94
+ lastCreatedSessionId(): string | undefined;
95
+ get(sessionId: string): Session | undefined;
96
+ close(sessionId: string): boolean;
97
+ addSubscriber(sessionId: string, subscriber: Subscriber): Session;
98
+ removeSubscriber(sessionId: string, subscriber: Subscriber): void;
99
+ registerActionSpec(sessionId: string, entry: ActionSpecEntry): void;
100
+ /**
101
+ * Register a stream channel ↔ refresh-tool binding on the named
102
+ * session. Same "create-if-missing" semantics as
103
+ * {@link registerActionSpec} so the ConformanceHost adapter can
104
+ * dispatch this directive before subscribe lands. Keyed by
105
+ * `entry.channel` — registering the same channel twice replaces
106
+ * the prior binding, matching the action-spec map's behavior.
107
+ */
108
+ registerStreamSpec(sessionId: string, entry: StreamSpecEntry): void;
109
+ /**
110
+ * Set the per-session protocol-version override. Used by the
111
+ * `server-version-override` ConformanceHost directive — populates
112
+ * {@link Session.versionOverride} so the WS subscribe handler
113
+ * advertises this value (and emits UPGRADE_REQUIRED keyed off it)
114
+ * for THIS session only, leaving parallel sessions on the instance-
115
+ * level default.
116
+ *
117
+ * Same "create-if-missing" semantics as the other register* setters
118
+ * so directive ordering relative to subscribe doesn't matter.
119
+ */
120
+ setVersionOverride(sessionId: string, version: string): void;
121
+ /**
122
+ * Fan out a frame to every subscriber on the named session. Used by
123
+ * the `emit-envelope` ConformanceHost directive — kit fixtures use
124
+ * it to inject WS-observable side-effects (envelopes the server
125
+ * would not normally emit on its own) so the kit can assert
126
+ * downstream consequences (sequencing, fan-out, observability).
127
+ *
128
+ * Returns `true` if the session existed and at least one subscriber
129
+ * received the frame; `false` if the session is unknown OR has no
130
+ * subscribers attached. Caller may use the boolean to log a warning
131
+ * when a fixture's directive-injection lands before any subscribe
132
+ * — the directive then has no observable effect, which is usually
133
+ * a fixture-authoring bug worth surfacing.
134
+ *
135
+ * Subscriber-level send failures (closed socket, etc.) are
136
+ * swallowed per the same convention as the action router's
137
+ * `broadcast()` — one bad subscriber must not block fan-out to the
138
+ * rest.
139
+ */
140
+ injectFrame(sessionId: string, frame: unknown): boolean;
141
+ }
142
+ //# sourceMappingURL=session.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"session.d.ts","sourceRoot":"","sources":["../src/session.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH;;;;;GAKG;AACH,MAAM,WAAW,eAAe;IAC9B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;CACvB;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,MAAM,WAAW,eAAe;IAC9B,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;CACvB;AAED,MAAM,WAAW,OAAO;IACtB,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,WAAW,EAAE,GAAG,CAAC,MAAM,EAAE,eAAe,CAAC,CAAC;IACnD,QAAQ,CAAC,WAAW,EAAE,GAAG,CAAC,MAAM,EAAE,eAAe,CAAC,CAAC;IACnD,QAAQ,CAAC,WAAW,EAAE,GAAG,CAAC,UAAU,CAAC,CAAC;IACtC;;;;;;;;;;;;;;OAcG;IACH,eAAe,CAAC,EAAE,MAAM,CAAC;CAC1B;AAED;;;;GAIG;AACH,MAAM,WAAW,UAAU;IACzB,IAAI,CAAC,KAAK,EAAE,OAAO,GAAG,IAAI,CAAC;CAC5B;AAED;;;;;GAKG;AACH,qBAAa,YAAY;IACvB,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAA8B;IACvD,OAAO,CAAC,WAAW,CAAqB;IAExC,MAAM,CAAC,SAAS,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO;IAkBjD;;;;;;;OAOG;IACH,oBAAoB,IAAI,MAAM,GAAG,SAAS;IAI1C,GAAG,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,GAAG,SAAS;IAI3C,KAAK,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO;IAIjC,aAAa,CAAC,SAAS,EAAE,MAAM,EAAE,UAAU,EAAE,UAAU,GAAG,OAAO;IAMjE,gBAAgB,CAAC,SAAS,EAAE,MAAM,EAAE,UAAU,EAAE,UAAU,GAAG,IAAI;IAMjE,kBAAkB,CAAC,SAAS,EAAE,MAAM,EAAE,KAAK,EAAE,eAAe,GAAG,IAAI;IAKnE;;;;;;;OAOG;IACH,kBAAkB,CAAC,SAAS,EAAE,MAAM,EAAE,KAAK,EAAE,eAAe,GAAG,IAAI;IAKnE;;;;;;;;;;OAUG;IACH,kBAAkB,CAAC,SAAS,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,IAAI;IAK5D;;;;;;;;;;;;;;;;;;OAkBG;IACH,WAAW,CAAC,SAAS,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,GAAG,OAAO;CAcxD"}
@@ -0,0 +1,134 @@
1
+ /**
2
+ * In-memory session store for the reference server.
3
+ *
4
+ * Sessions are ephemeral and process-local — this is the whole
5
+ * point of the reference server. Persistence is explicitly out of
6
+ * scope. Restart drops state; that's documented behavior, not a TODO.
7
+ *
8
+ * Each session carries an actionSpec map that the `register-actionspec`
9
+ * ConformanceHost directive populates. The action router
10
+ * (`./action-router.ts`) consults this map at dispatch time to
11
+ * resolve action-name → tool-name → handler.
12
+ */
13
+ /**
14
+ * In-memory session store. Wraps a `Map<sessionId, Session>` with
15
+ * the operations the ConformanceHost adapter + WS subscribe handler
16
+ * need. No locking — JS single-threaded; all calls originate from
17
+ * the event loop.
18
+ */
19
+ export class SessionStore {
20
+ sessions = new Map();
21
+ lastCreated;
22
+ create(sessionId, appId) {
23
+ const existing = this.sessions.get(sessionId);
24
+ if (existing !== undefined) {
25
+ this.lastCreated = sessionId;
26
+ return existing;
27
+ }
28
+ const session = {
29
+ sessionId,
30
+ appId,
31
+ actionSpecs: new Map(),
32
+ streamSpecs: new Map(),
33
+ subscribers: new Set(),
34
+ };
35
+ this.sessions.set(sessionId, session);
36
+ this.lastCreated = sessionId;
37
+ return session;
38
+ }
39
+ /**
40
+ * The sessionId most recently passed to `create()`. Used by the
41
+ * ConformanceHost's `register-actionspec` dispatcher — that
42
+ * directive doesn't carry a sessionId in its JSON shape, so the
43
+ * adapter needs the "most recently created" scope to bind the
44
+ * actionspec to. This matches the fixture-authoring convention that
45
+ * create-session always precedes register-actionspec.
46
+ */
47
+ lastCreatedSessionId() {
48
+ return this.lastCreated;
49
+ }
50
+ get(sessionId) {
51
+ return this.sessions.get(sessionId);
52
+ }
53
+ close(sessionId) {
54
+ return this.sessions.delete(sessionId);
55
+ }
56
+ addSubscriber(sessionId, subscriber) {
57
+ const session = this.create(sessionId, 'conformance');
58
+ session.subscribers.add(subscriber);
59
+ return session;
60
+ }
61
+ removeSubscriber(sessionId, subscriber) {
62
+ const session = this.sessions.get(sessionId);
63
+ if (session === undefined)
64
+ return;
65
+ session.subscribers.delete(subscriber);
66
+ }
67
+ registerActionSpec(sessionId, entry) {
68
+ const session = this.create(sessionId, 'conformance');
69
+ session.actionSpecs.set(entry.name, entry);
70
+ }
71
+ /**
72
+ * Register a stream channel ↔ refresh-tool binding on the named
73
+ * session. Same "create-if-missing" semantics as
74
+ * {@link registerActionSpec} so the ConformanceHost adapter can
75
+ * dispatch this directive before subscribe lands. Keyed by
76
+ * `entry.channel` — registering the same channel twice replaces
77
+ * the prior binding, matching the action-spec map's behavior.
78
+ */
79
+ registerStreamSpec(sessionId, entry) {
80
+ const session = this.create(sessionId, 'conformance');
81
+ session.streamSpecs.set(entry.channel, entry);
82
+ }
83
+ /**
84
+ * Set the per-session protocol-version override. Used by the
85
+ * `server-version-override` ConformanceHost directive — populates
86
+ * {@link Session.versionOverride} so the WS subscribe handler
87
+ * advertises this value (and emits UPGRADE_REQUIRED keyed off it)
88
+ * for THIS session only, leaving parallel sessions on the instance-
89
+ * level default.
90
+ *
91
+ * Same "create-if-missing" semantics as the other register* setters
92
+ * so directive ordering relative to subscribe doesn't matter.
93
+ */
94
+ setVersionOverride(sessionId, version) {
95
+ const session = this.create(sessionId, 'conformance');
96
+ session.versionOverride = version;
97
+ }
98
+ /**
99
+ * Fan out a frame to every subscriber on the named session. Used by
100
+ * the `emit-envelope` ConformanceHost directive — kit fixtures use
101
+ * it to inject WS-observable side-effects (envelopes the server
102
+ * would not normally emit on its own) so the kit can assert
103
+ * downstream consequences (sequencing, fan-out, observability).
104
+ *
105
+ * Returns `true` if the session existed and at least one subscriber
106
+ * received the frame; `false` if the session is unknown OR has no
107
+ * subscribers attached. Caller may use the boolean to log a warning
108
+ * when a fixture's directive-injection lands before any subscribe
109
+ * — the directive then has no observable effect, which is usually
110
+ * a fixture-authoring bug worth surfacing.
111
+ *
112
+ * Subscriber-level send failures (closed socket, etc.) are
113
+ * swallowed per the same convention as the action router's
114
+ * `broadcast()` — one bad subscriber must not block fan-out to the
115
+ * rest.
116
+ */
117
+ injectFrame(sessionId, frame) {
118
+ const session = this.sessions.get(sessionId);
119
+ if (session === undefined)
120
+ return false;
121
+ if (session.subscribers.size === 0)
122
+ return false;
123
+ for (const subscriber of session.subscribers) {
124
+ try {
125
+ subscriber.send(frame);
126
+ }
127
+ catch {
128
+ // Subscriber lifecycle issues (closed socket, etc.) are the
129
+ // subscriber's problem — the store keeps fanning out.
130
+ }
131
+ }
132
+ return true;
133
+ }
134
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Tool registry — the 4 handler kinds the reference server wires
3
+ * through wired-action dispatch. Names match the `handler` enumeration
4
+ * in `packages/protocol-conformance/src/conformance-host.ts`'s
5
+ * `RegisterToolSetup` so the conformance kit's `register-tool`
6
+ * directive drops through cleanly.
7
+ *
8
+ * The four handler kinds:
9
+ *
10
+ * - `echo` — returns `{received: args}`.
11
+ * - `throw` — rejects with `Error('tool_threw_for_fixture')`.
12
+ * - `timeout` — never resolves; router enforces a 500ms timeout.
13
+ * - `malformed`— returns `{wrong: 'shape'}` to exercise
14
+ * SCHEMA_VIOLATION.
15
+ *
16
+ * `TOOL_NOT_FOUND` is the 5th failure path — exercised by dispatching
17
+ * to an action whose tool is NOT in the registry; no handler needed.
18
+ *
19
+ * Handlers are declarative: they return `{status: 'resolved', value}`
20
+ * or throw. The router consults the return shape against the
21
+ * declared channel schema (minimal — currently only `malformed` is
22
+ * flagged) and maps unsupported cases to `_ggui:contract-error` with
23
+ * the matching ContractErrorCode.
24
+ */
25
+ /**
26
+ * One tool's executable behavior. Async so `timeout` can return a
27
+ * never-resolving promise the router bounds with a timer.
28
+ */
29
+ export type ToolHandler = (args: unknown) => Promise<unknown>;
30
+ export type ToolHandlerKind = 'echo' | 'throw' | 'timeout' | 'malformed' | 'malformed-stream' | 'list-snapshot' | (string & {});
31
+ /**
32
+ * Registered tool — the handler + its declared behavior kind so
33
+ * the router can match against fixture expectations.
34
+ */
35
+ export interface RegisteredTool {
36
+ readonly name: string;
37
+ readonly kind: ToolHandlerKind;
38
+ readonly handler: ToolHandler;
39
+ }
40
+ /**
41
+ * Build one of the four canonical handlers by kind. Unknown kinds
42
+ * throw — the caller (ConformanceHost adapter or setup-step
43
+ * dispatcher) MUST surface this as an honest "handler not implemented"
44
+ * error so the kit records a SKIP with the error message as reason.
45
+ */
46
+ export declare function buildHandler(kind: ToolHandlerKind): ToolHandler;
47
+ /**
48
+ * In-memory tool registry. Scoped to a session via the action router
49
+ * (the plan's register-tool directive wires a handler under the
50
+ * session's tool namespace). No-persistence by design.
51
+ */
52
+ export declare class ToolRegistry {
53
+ private readonly tools;
54
+ register(name: string, kind: ToolHandlerKind): void;
55
+ unregister(name: string): boolean;
56
+ get(name: string): RegisteredTool | undefined;
57
+ has(name: string): boolean;
58
+ /** Name of the first tool registered on this registry, or
59
+ * `undefined` if none. Used by the action-router as a fallback
60
+ * when no explicit action→tool binding exists. */
61
+ firstRegistered(): string | undefined;
62
+ }
63
+ //# sourceMappingURL=tool-registry.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tool-registry.d.ts","sourceRoot":"","sources":["../src/tool-registry.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAEH;;;GAGG;AACH,MAAM,MAAM,WAAW,GAAG,CAAC,IAAI,EAAE,OAAO,KAAK,OAAO,CAAC,OAAO,CAAC,CAAC;AAE9D,MAAM,MAAM,eAAe,GACvB,MAAM,GACN,OAAO,GACP,SAAS,GACT,WAAW,GACX,kBAAkB,GAClB,eAAe,GACf,CAAC,MAAM,GAAG,EAAE,CAAC,CAAC;AAElB;;;GAGG;AACH,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,IAAI,EAAE,eAAe,CAAC;IAC/B,QAAQ,CAAC,OAAO,EAAE,WAAW,CAAC;CAC/B;AAYD;;;;;GAKG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE,eAAe,GAAG,WAAW,CAkC/D;AAED;;;;GAIG;AACH,qBAAa,YAAY;IACvB,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAqC;IAE3D,QAAQ,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,eAAe,GAAG,IAAI;IAInD,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO;IAIjC,GAAG,CAAC,IAAI,EAAE,MAAM,GAAG,cAAc,GAAG,SAAS;IAI7C,GAAG,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO;IAI1B;;uDAEmD;IACnD,eAAe,IAAI,MAAM,GAAG,SAAS;CAItC"}
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Tool registry — the 4 handler kinds the reference server wires
3
+ * through wired-action dispatch. Names match the `handler` enumeration
4
+ * in `packages/protocol-conformance/src/conformance-host.ts`'s
5
+ * `RegisterToolSetup` so the conformance kit's `register-tool`
6
+ * directive drops through cleanly.
7
+ *
8
+ * The four handler kinds:
9
+ *
10
+ * - `echo` — returns `{received: args}`.
11
+ * - `throw` — rejects with `Error('tool_threw_for_fixture')`.
12
+ * - `timeout` — never resolves; router enforces a 500ms timeout.
13
+ * - `malformed`— returns `{wrong: 'shape'}` to exercise
14
+ * SCHEMA_VIOLATION.
15
+ *
16
+ * `TOOL_NOT_FOUND` is the 5th failure path — exercised by dispatching
17
+ * to an action whose tool is NOT in the registry; no handler needed.
18
+ *
19
+ * Handlers are declarative: they return `{status: 'resolved', value}`
20
+ * or throw. The router consults the return shape against the
21
+ * declared channel schema (minimal — currently only `malformed` is
22
+ * flagged) and maps unsupported cases to `_ggui:contract-error` with
23
+ * the matching ContractErrorCode.
24
+ */
25
+ /**
26
+ * Returns a never-resolving promise. The router pairs it with a
27
+ * timer to emit `TOOL_TIMEOUT` contract-error after N ms.
28
+ */
29
+ function neverResolve() {
30
+ return new Promise(() => {
31
+ /* deliberate: never settles */
32
+ });
33
+ }
34
+ /**
35
+ * Build one of the four canonical handlers by kind. Unknown kinds
36
+ * throw — the caller (ConformanceHost adapter or setup-step
37
+ * dispatcher) MUST surface this as an honest "handler not implemented"
38
+ * error so the kit records a SKIP with the error message as reason.
39
+ */
40
+ export function buildHandler(kind) {
41
+ switch (kind) {
42
+ case 'echo':
43
+ return async (args) => ({ received: args });
44
+ case 'throw':
45
+ return async () => {
46
+ throw new Error('tool_threw_for_fixture');
47
+ };
48
+ case 'timeout':
49
+ return () => neverResolve();
50
+ case 'malformed':
51
+ case 'malformed-stream':
52
+ // Both kinds return a shape that does not match the declared
53
+ // channel schema — router maps to SCHEMA_VIOLATION.
54
+ // `malformed-stream` is the fixture-authored alias used by
55
+ // `stream-schema-violation` in the kit.
56
+ return async () => ({ wrong: 'shape' });
57
+ case 'list-snapshot':
58
+ // Refresh-tool kind: returns a deterministic empty list snapshot
59
+ // (`{ items: [] }`). Models the "list-fresh-state" role of a
60
+ // streamSpec refresh tool (real ggui blueprints' `tasks_list` /
61
+ // `notes_list` etc.). The reference server runs handlers
62
+ // statelessly — the snapshot intentionally does NOT reflect
63
+ // prior `tasks_create` calls, since modeling stateful in-memory
64
+ // stores is out of scope for the smallest conformant impl. The
65
+ // kit's `stream-refresh-success` matcher only asserts the
66
+ // channel-update arrived with the declared shape, not that the
67
+ // payload reflects mutation history.
68
+ return async () => ({ items: [] });
69
+ default:
70
+ throw new Error(`reference-server: tool handler kind '${String(kind)}' is not recognized — supported: echo, throw, timeout, malformed, malformed-stream, list-snapshot`);
71
+ }
72
+ }
73
+ /**
74
+ * In-memory tool registry. Scoped to a session via the action router
75
+ * (the plan's register-tool directive wires a handler under the
76
+ * session's tool namespace). No-persistence by design.
77
+ */
78
+ export class ToolRegistry {
79
+ tools = new Map();
80
+ register(name, kind) {
81
+ this.tools.set(name, { name, kind, handler: buildHandler(kind) });
82
+ }
83
+ unregister(name) {
84
+ return this.tools.delete(name);
85
+ }
86
+ get(name) {
87
+ return this.tools.get(name);
88
+ }
89
+ has(name) {
90
+ return this.tools.has(name);
91
+ }
92
+ /** Name of the first tool registered on this registry, or
93
+ * `undefined` if none. Used by the action-router as a fallback
94
+ * when no explicit action→tool binding exists. */
95
+ firstRegistered() {
96
+ const it = this.tools.keys().next();
97
+ return it.done === true ? undefined : it.value;
98
+ }
99
+ }