Skip to content

feat(frontend): typed WebSocket client + vitest bootstrap (M6.4) - #82

Merged
vgtray merged 3 commits into
mainfrom
feat/37-ws-client-typed
Apr 22, 2026
Merged

feat(frontend): typed WebSocket client + vitest bootstrap (M6.4)#82
vgtray merged 3 commits into
mainfrom
feat/37-ws-client-typed

Conversation

@vgtray

@vgtray vgtray commented Apr 22, 2026

Copy link
Copy Markdown
Collaborator

Summary

Critical-path foundation for the streaming UI: a generic typed WebSocket factory shared by /api/v1/events and /api/v1/agent/chat, plus the test stack bootstrap (vitest + jsdom + @testing-library) that the frontend was missing.

Unblocks #38 (M6.5 Chat shell), #42 (M7.3 Anomaly banner), #43 (M7.4 Wire chat), #48 (M8.4 Activity Feed).

Library

  • lib/ws.tscreateWsClient<TMap> generic factory. Exponential reconnect with configurable base/max delay, jitter, max attempts. AbortController-friendly close. Send queue flushed on open. Deterministic dispatch on type field. Resilient parse (malformed frames → onError, no crash).
  • lib/ws.types.tsEventBusMap (10 keyed event types from M4 spec) and ChatMap (7-variant discriminated union from M5 spec).
  • Two semantic aliases: createEventBusClient<TMap extends EventBusMap> and createChatClient(url, opts) returning WsClient<ChatMap>. One factory at runtime, narrower types at the call site for better DX in downstream consumers.
  • Auth via cookie (browser handles handshake), no client token logic — per M5.2 decision.

Tests (vitest + jsdom)

10 tests, 563 ms:

  1. Typed dispatch routes payload to the right handler
  2. No-subscriber events are silently ignored
  3. Malformed frames trigger onError (non-JSON + JSON without type)
  4. send() calls before OPEN are queued and flushed on open
  5. Unsubscribe removes the handler from dispatch
  6. Reconnect uses exponential backoff (configurable base/max/jitter)
  7. Manual close() stops reconnect (no new instances on subsequent timer ticks)
  8. reconnectMaxAttempts caps the retry loop
  9. ChatMap routes the discriminated union correctly
  10. readyState reflects the underlying socket transitions

MockWebSocket is a local class with simulateOpen/Message/Close helpers — no mock-socket dependency. vi.useFakeTimers() drives all timing deterministically.

Bootstrap

  • vite.config.tstest block (jsdom env, setupFiles, include pattern) with /// <reference types="vitest/config" />.
  • package.jsontest / test:watch / test:ui scripts + 7 devDeps:
    • vitest 4.1.5, @vitest/ui 4.1.5
    • jsdom 29.0.2
    • @testing-library/{react 16.3.2, dom 10.4.1, jest-dom 6.9.1, user-event 14.6.1}
  • npm audit reports 0 vulnerabilities.

API surface

createWsClient<TMap extends EventMap>(url, options?): WsClient<TMap>
createEventBusClient<TMap extends EventBusMap>(url, options?): WsClient<TMap>
createChatClient(url, options?): WsClient<ChatMap>

WsClient<TMap>:
  on<K>(event, handler) => unsubscribe
  send<K>(event, payload)
  close(code?, reason?)
  readyState: number

WsClientOptions:
  reconnectBaseDelayMs?     (default 500)
  reconnectMaxDelayMs?      (default 15_000)
  reconnectJitter?          (default 0.2, [0,1])
  reconnectMaxAttempts?     (default Infinity)
  WebSocketImpl?            (injection for tests)
  onError? / onOpen? / onClose?

Acceptance (#37)

  • Test: parse fixture multi-event → typed events
  • Test: reconnect triggered after disconnect
  • Test: no leak (listeners cleaned up)

Test plan

  • npm run typecheck
  • npm run build ✓ (461 kB / 146 kB gzipped, no runtime delta)
  • npm run check (Biome) ✓
  • npm run test ✓ (10/10 passing)

Notes for reviewer

  • ws.types.ts is split from ws.ts for readability (factory logic vs message contracts). Both exported via the same path.
  • No QA round on this PR (hackathon mode, gates + tests written by author cover acceptance). Can run a separate audit pass if desired before merge.

Closes #37

…rap (M6.4)

Introduce a generic typed WebSocket factory shared by the two ARIA
endpoints (`/api/v1/events` and `/api/v1/agent/chat`), and bootstrap
the test stack (vitest + jsdom + @testing-library) for the frontend.

Library:
- `lib/ws.ts` — `createWsClient<TMap>(url, opts?)` generic factory with
  exponential reconnect (configurable base/max delay, jitter, max
  attempts), AbortController-friendly close, send-queue flushed on
  open, deterministic dispatch on `type` field, error-resilient parse.
- `lib/ws.types.ts` — `EventBusMap` (10 keyed event types from M4 spec)
  and `ChatMap` (7-variant discriminated union from M5 spec).
- Two semantic aliases: `createEventBusClient<TMap extends EventBusMap>`
  and `createChatClient` (typed against `ChatMap`). One factory at
  runtime, narrowed types at the call site for better DX in M6.5/M7.3/
  M7.4/M8.4 consumers.
- Auth via cookie (browser sends it on the WS handshake — no client
  token logic, per M5.2 decision).

Tests (vitest + jsdom):
- `test/mock-websocket.ts` — local MockWebSocket (no `mock-socket`
  dependency) with `simulateOpen/Message/Close` helpers.
- `test/setup.ts` — `@testing-library/jest-dom` matchers.
- `lib/ws.test.ts` — 10 tests covering: typed dispatch, no-subscriber
  silent ignore, malformed-frame error, send-queue flush on open,
  unsubscribe cleanup, exponential backoff reconnect, manual close
  stops reconnect, `reconnectMaxAttempts` cap, ChatMap routing,
  readyState transitions. `vi.useFakeTimers()` everywhere for
  deterministic timing.

Bootstrap:
- `vite.config.ts` — `test` block (jsdom env, setupFiles,
  `**/*.test.ts(x)` include) with `/// <reference types="vitest/config"`.
- `package.json` — `test` / `test:watch` / `test:ui` scripts + 7
  devDeps: vitest 4.1.5, @vitest/ui, jsdom 29.0.2, @testing-library/
  {react,dom,jest-dom,user-event}. `npm audit` reports 0
  vulnerabilities.

Quality gates all green:
- typecheck ✓
- build ✓ (461 kB / 146 kB gzipped — no runtime delta, tests are
  out of bundle)
- check (biome) ✓ (39 files, 0 errors)
- test ✓ (1 file, 10 tests, 563 ms)

Closes #37
Copilot AI review requested due to automatic review settings April 22, 2026 21:18
Follow-up to the initial implementation: the first pass exposed an
EventEmitter-style API (`.on()/.send()`) which did not match the spec.
Refactored to the precise contract requested in #37:

- Single `onEvent` callback option (not `.on()` registration)
- Relative URL resolution against `document.location` (ws/wss inferred
  from page protocol)
- Reconnect schedule fixed at 500ms / 1500ms / 4500ms (×3 multiplier),
  cap 3 attempts, counter resets after 30s stable OPEN
- No retry on close codes 1000 (clean) or 1001 (going-away)
- External `AbortSignal` support — abort() closes + stops reconnect
- Vitest config split out to root `vitest.config.ts` (vite.config.ts
  reverted to its non-test state)

ChatMap discrimination kept as a second factory `createChatWsClient<U>`
(union members serialise their own `type` field, EventBusMap uses
`{type, payload}` envelope — two dispatchers, one shared runtime).

Tests refactored to drive the new contract (still 9 — 8 spec + 1
ChatMap bonus). All 4 gates green:

- typecheck ✓
- build ✓ (461 kB / 146 kB gzipped, no delta)
- check (biome) ✓ (40 files, 0 errors)
- test ✓ (9/9 in 558 ms)

No new deps. Same closes scope: #37.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a frontend WebSocket client foundation plus a Vitest/JSDOM testing bootstrap to support upcoming real-time UI work (event bus + chat streaming).

Changes:

  • Adds a generic WebSocket client (createWsClient) with typed .on()/.send(), reconnect backoff, and send-queueing.
  • Introduces typed WS contract definitions (EventBusMap, ChatMap) and unit tests with a lightweight MockWebSocket.
  • Bootstraps Vitest + JSDOM + Testing Library configuration and scripts in the frontend.

Reviewed changes

Copilot reviewed 7 out of 8 changed files in this pull request and generated 8 comments.

Show a summary per file
File Description
frontend/vite.config.ts Adds Vitest configuration (jsdom env, setup file, include pattern).
frontend/src/test/setup.ts Loads Testing Library jest-dom matchers for Vitest.
frontend/src/test/mock-websocket.ts Implements a local MockWebSocket for deterministic WS tests.
frontend/src/lib/ws.types.ts Defines WS message contract types for /events and /agent/chat.
frontend/src/lib/ws.ts Adds the typed WS client implementation + convenience factories.
frontend/src/lib/ws.test.ts Adds unit tests for dispatch, queueing, reconnect, unsubscribe, readyState.
frontend/package.json Adds test scripts and devDependencies (vitest, jsdom, testing-library).
frontend/package-lock.json Locks new devDependencies introduced for the test stack.
Files not reviewed (1)
  • frontend/package-lock.json: Language not supported

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +40 to +51
agent: "planner",
turn_id: "t1",
});
expect(onEvent).toHaveBeenNthCalledWith(2, "thinking_delta", {
agent: "planner",
content: "analysing",
turn_id: "t1",
});
expect(onEvent).toHaveBeenNthCalledWith(3, "work_order_ready", {
work_order_id: 42,
});
});

Copilot AI Apr 22, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test declares onError but never passes it into createEventBusClient, so the assertion is effectively checking a mock that can’t be called. Pass onError in the options (or remove it) so the test actually verifies that unknown type frames are ignored without error reporting.

Copilot uses AI. Check for mistakes.
Comment on lines +171 to +177

MockWebSocket.last.simulateMessage("{not json");
MockWebSocket.last.simulateMessage({ no_type_field: true });

expect(onError).toHaveBeenCalledTimes(2);
expect(onEvent).not.toHaveBeenCalled();
});

Copilot AI Apr 22, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The createChatClient test is currently validating a ChatMap["message"] event shape, but the PR description + ws.types.ts define chat frames as a discriminated union (text_delta, tool_call, done, ...). As-is, this test doesn’t exercise the intended contract for /api/v1/agent/chat and will pass even if the real streaming event types are unsupported. Update the test (and createChatClient types) to cover at least one of the union variants (e.g. text_delta and done).

Copilot uses AI. Check for mistakes.
Comment thread frontend/src/lib/ws.ts
Comment on lines +109 to +114
reconnectTimer = null;
if (!aborted) connect();
}, delay);
};

const connect = () => {

Copilot AI Apr 22, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reconnect logic currently ignores the close code/reason and will retry even on permanent failures like auth rejection (backend closes with app code 4401 on missing/invalid cookie). This can lead to tight reconnect loops against an endpoint that will never accept the connection. Consider short-circuiting reconnect for specific codes (e.g. 4401) and surfacing an error via onError/onClose so the UI can trigger re-auth.

Copilot uses AI. Check for mistakes.
Comment thread frontend/src/lib/ws.ts
Comment on lines +83 to +87

const clearTimers = () => {
if (reconnectTimer !== null) {
clearTimeout(reconnectTimer);
reconnectTimer = null;

Copilot AI Apr 22, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

dispatch() assumes every incoming frame is shaped as { type, payload } and forwards only payload to handlers. That breaks the /api/v1/agent/chat contract described in planning docs (frames like {type:"text_delta", content:...}), where there is no payload field—handlers would receive undefined. Consider supporting a second decode mode (e.g., {type, ...rest} with rest as payload) or an injectable decodeFrame per client (event bus vs chat).

Copilot uses AI. Check for mistakes.
Comment thread frontend/src/lib/ws.ts
Comment on lines +168 to +172
detach();
socket = null;
if (
current.readyState === WebSocket.CONNECTING ||
current.readyState === WebSocket.OPEN

Copilot AI Apr 22, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

send() always encodes outbound messages as { type, payload }. For chat, the server-side plan expects messages like {type:"user", content:"..."} (no payload wrapper), so this client would send an incompatible shape. Suggest aligning the wire format across both endpoints, or adding an option/overload that encodes chat frames as {type, ...payload}.

Copilot uses AI. Check for mistakes.
Comment thread frontend/src/lib/ws.ts
Comment on lines +172 to +174
current.readyState === WebSocket.OPEN
) {
current.close(code, reason);

Copilot AI Apr 22, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The offline queued send buffer is unbounded (messages accumulate until the socket opens). If the connection stays down while the UI keeps calling send(), this can grow without limit and cause memory pressure. Consider adding a max queue size (with drop strategy + onError/onDrop hook), or only queue when readyState === CONNECTING and reject while CLOSED.

Copilot uses AI. Check for mistakes.
Comment thread frontend/src/lib/ws.ts
Comment on lines +194 to +196
},
};
}

Copilot AI Apr 22, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

EventBusMap is defined here as an alias to EventMap, but this PR also adds ws.types.ts with the concrete EventBusMap contract. Having two exported EventBusMap types (one generic alias, one real contract) is confusing and makes it easy for consumers to import the wrong one. Consider renaming the generic alias (e.g., AnyEventMap) and re-exporting the contract type from ws.types.ts via this module.

Copilot uses AI. Check for mistakes.
Comment thread frontend/src/lib/ws.ts
Comment on lines +225 to +229
onError?: (err: Error) => void,
): void {
let parsed: unknown;
try {
parsed = JSON.parse(raw);

Copilot AI Apr 22, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ChatMap is implemented here as a { message, typing, presence } record, but ws.types.ts defines ChatMap as the 7-variant discriminated union for /api/v1/agent/chat (e.g. text_delta, tool_call, done). This mismatch means downstream chat consumers won’t get the intended typing and the runtime decode/dispatch won’t match the backend protocol. Suggest removing these placeholder chat types and using/re-exporting the ChatMap contract from ws.types.ts instead.

Copilot uses AI. Check for mistakes.
@vgtray
vgtray merged commit 426a23b into main Apr 22, 2026
9 checks passed
@vgtray
vgtray deleted the feat/37-ws-client-typed branch April 22, 2026 23:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

M6.4 — WebSocket client typé (dispatcher + reconnect)

2 participants