v1.13.11-a: WS frame schemas + frontend receive validation
First half of the WebSocket-frame-typing batch (split per recon — total
scope was ~535 LoC, larger than the roadmap's ~300 estimate, so the
server-side publish-site conversion lands separately in v1.13.11-b).
Phase A scope:
(1) apps/server/src/types/ws-frames.ts (NEW) — Zod schemas for all 27
wire-format WS frame types. Discriminated union (WsFrameSchema) plus
KNOWN_FRAME_TYPES const for diagnostic lookup. UUIDs are z.string().
uuid(); model-emitted tool_call_id stays z.string().min(1) since OpenAI-
compatible APIs emit "call_<random>" not UUID. Per-kind payload narrowing
(tool args, message_parts payloads) intentionally stays z.unknown() —
frame-level drift detection is the goal; deep payload validation is
follow-up work.
(2) apps/web/src/api/ws-frames.ts (NEW) — byte-identical mirror of the
authoritative server file. No path alias from web→server in the existing
tsconfig setup; sync-by-hand was chosen over a new packages/shared/ dir.
A ws-frames.test.ts test asserts the two files match.
(3) apps/server/src/services/broker.ts — adds publishFrame() and
publishUserFrame() methods to the Broker interface. Both validate via
WsFrameSchema and fail-closed: log + drop on invalid. createBroker now
accepts an optional FastifyBaseLogger so validation failures land in
the pino stream (with console.error fallback for unit tests). The
existing publish() / publishUser() raw methods stay legal — they get
converted to the typed variants in v1.13.11-b.
(4) apps/web/src/hooks/useSessionStream.ts + useUserEvents.ts — wrap
ws.onmessage with WsFrameSchema.safeParse. Fail-closed: invalid frames
log + return without dispatching. Hand-maintained WsFrame and
SessionEvent types stay in place; one cast bridges Zod-typed → narrowed
shape (Zod uses OpaqueObject for nested Message[] / WorkspacePane[] etc.,
which are dev-time-narrowed via the existing hand-maintained types).
(5) apps/web/package.json — adds zod ^3.23.8 as a direct dep. Was a
transitive dep via ai-sdk / postgres; promotion makes the import legal.
(6) Tests: 15 new in ws-frames.test.ts covering happy-path per major
frame type, drift-catchers (unknown type, invalid enum, non-UUID, negative
tokens), parts-authoritative read variants, the mirror-file diff check,
and four broker fail-closed scenarios. 219/219 server tests pass (was
204; +15 new).
Two recon corrections to the dispatch brief, both flagged before
implementation:
- No 'parts_appended' frame exists. The brief assumed one; the codebase
reads parts via the messages_with_parts view after message_complete
triggers a refetch. MessagePartSchema is therefore unused this batch.
- No 'tool_running' frame exists. The brief listed it as standalone; it
is in fact a 'chat_status' variant ({ status: 'tool_running' }), already
covered by ChatStatusFrame.
Smoke: clean container boot, no validation errors in the server log. Real
production frames pass validation (the schemas were derived from the
existing hand-maintained types in api/types.ts and sessionEvents.ts).
v1.13.11-b will follow immediately: convert all ~85 raw broker.publish /
ctx.publish call sites across 11 server files to publishFrame /
publishUserFrame. Mechanical edit; the wiring done here means the diff
in -b is just the call-site swaps.
~310 LoC across 9 files (4 new + 5 modified).
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import type { Message, WsFrame } from '@/api/types';
|
||||
import { WsFrameSchema } from '@/api/ws-frames';
|
||||
import { api } from '@/api/client';
|
||||
import { sessionEvents } from './sessionEvents';
|
||||
import { recordUsage } from './useChatThroughput';
|
||||
@@ -216,8 +217,28 @@ export function useSessionStream(sessionId: string | undefined) {
|
||||
setState((s) => ({ ...s, connected: true, error: null }));
|
||||
};
|
||||
ws.onmessage = (ev) => {
|
||||
// v1.13.11-a: Zod-validate every inbound frame. Fail-closed — invalid
|
||||
// frames are logged and dropped. WsFrameSchema is the runtime guard;
|
||||
// the hand-maintained WsFrame type stays as the narrowed dev-time
|
||||
// shape (Zod uses OpaqueObject for nested types like Message[]). One
|
||||
// cast bridges the two.
|
||||
let raw: unknown;
|
||||
try {
|
||||
const frame = JSON.parse(typeof ev.data === 'string' ? ev.data : '') as WsFrame;
|
||||
raw = JSON.parse(typeof ev.data === 'string' ? ev.data : '');
|
||||
} catch (err) {
|
||||
console.warn('bad ws frame (parse)', err);
|
||||
return;
|
||||
}
|
||||
const validated = WsFrameSchema.safeParse(raw);
|
||||
if (!validated.success) {
|
||||
console.error('ws-frame-validation-failed (session channel)', {
|
||||
frame_type: (raw as { type?: unknown })?.type,
|
||||
errors: validated.error.flatten(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const frame = validated.data as unknown as WsFrame;
|
||||
// v1.11: on a compaction completion, re-fetch the message list so
|
||||
// the new summary row + the cohort of compacted_at-stamped older
|
||||
// rows render correctly. We dispatch the fresh list as a synthetic
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect } from 'react';
|
||||
import { WsFrameSchema } from '@/api/ws-frames';
|
||||
import { sessionEvents } from './sessionEvents';
|
||||
import { createWsReconnectToast } from './wsReconnectToast';
|
||||
|
||||
@@ -38,14 +39,33 @@ export function useUserEvents(): void {
|
||||
};
|
||||
|
||||
ws.onmessage = (ev) => {
|
||||
// v1.13.11-a: Zod-validate every inbound frame. Fail-closed — invalid
|
||||
// frames are logged and dropped instead of dispatched onto the
|
||||
// sessionEvents bus where a stale or wrong shape would silently
|
||||
// corrupt sidebar / chat state.
|
||||
let raw: unknown;
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(ev.data);
|
||||
if (parsed && typeof (parsed as { type?: unknown }).type === 'string') {
|
||||
sessionEvents.emit(parsed as import('./sessionEvents').SessionEvent);
|
||||
}
|
||||
raw = JSON.parse(ev.data);
|
||||
} catch (err) {
|
||||
console.warn('useUserEvents: failed to parse frame', err);
|
||||
return;
|
||||
}
|
||||
const validated = WsFrameSchema.safeParse(raw);
|
||||
if (!validated.success) {
|
||||
console.error('ws-frame-validation-failed (user channel)', {
|
||||
frame_type: (raw as { type?: unknown })?.type,
|
||||
errors: validated.error.flatten(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Bridge cast: Zod's union is broader than SessionEvent (it includes
|
||||
// per-session-channel frames too, which never arrive on the user
|
||||
// channel). sessionEvents.emit only dispatches frames whose type
|
||||
// appears in SessionEvent; the narrowing happens via the existing
|
||||
// useSidebar.ts applyEvent switch.
|
||||
sessionEvents.emit(
|
||||
validated.data as unknown as import('./sessionEvents').SessionEvent,
|
||||
);
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
|
||||
Reference in New Issue
Block a user