Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/quiet-frames-chat-react.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@workkit/chat": patch
"@workkit/chat-react": minor
---

Add wire-level debug frame types and a new headless React hook package for client-side chat WebSocket frame inspection.
84 changes: 62 additions & 22 deletions bun.lock

Large diffs are not rendered by default.

7 changes: 7 additions & 0 deletions packages/chat-react/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# @workkit/chat-react

## 0.1.0

### Minor Changes

- Initial release with `useChatDebugFrames`.
16 changes: 16 additions & 0 deletions packages/chat-react/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# @workkit/chat-react

Headless React debugging hooks for `@workkit/chat` WebSocket transports.

```ts
import { useChatDebugFrames } from "@workkit/chat-react";

const { frames, clear, connectionState } = useChatDebugFrames(socket, {
bufferSize: 100,
include: ["message", "error"],
});
```

`useChatDebugFrames` observes incoming `message` events and wraps `socket.send`
while mounted so client-side development panels can inspect the browser-side
frame stream. The hook is headless and does not ship styled UI.
9 changes: 9 additions & 0 deletions packages/chat-react/bunup.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { defineConfig } from "bunup";

export default defineConfig({
entry: ["src/index.ts"],
format: ["esm"],
dts: true,
sourcemap: "linked",
clean: true,
});
61 changes: 61 additions & 0 deletions packages/chat-react/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
{
"name": "@workkit/chat-react",
"version": "0.1.0",
"description": "React debugging hooks for @workkit/chat WebSocket transports",
"license": "MIT",
"author": "Bikash Dash <beeeku>",
"repository": {
"type": "git",
"url": "git+https://github.com/beeeku/workkit.git",
"directory": "packages/chat-react"
},
"type": "module",
"exports": {
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
}
},
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"files": [
"dist"
],
"sideEffects": false,
"publishConfig": {
"access": "public"
},
"scripts": {
"build": "bunup",
"test": "vitest run",
"test:watch": "vitest",
"typecheck": "tsc --noEmit",
"clean": "rm -rf dist"
},
"peerDependencies": {
"@workkit/chat": "workspace:*",
"react": ">=18"
},
"peerDependenciesMeta": {
"react": {
"optional": false
}
},
"devDependencies": {
"@types/react": "^18.3.18",
"@types/react-test-renderer": "^18.3.1",
"@workkit/chat": "workspace:*",
"@workkit/testing": "workspace:*",
"react": "^18.3.1",
"react-test-renderer": "^18.3.1"
},
"keywords": [
"workkit",
"chat",
"react",
"websocket",
"debug"
]
}
199 changes: 199 additions & 0 deletions packages/chat-react/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
import type { ChatMessage, ChatMessageType, DebugFrame } from "@workkit/chat";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";

export type { DebugFrame, InboundFrameEvent, OutboundFrameEvent } from "@workkit/chat";

export type ChatDebugConnectionState = "connecting" | "open" | "closing" | "closed";

export interface ChatDebugSocket {
readonly readyState: number;
send(data: string | ArrayBufferLike | Blob | ArrayBufferView): void;
addEventListener(type: "message", listener: (event: MessageEvent) => void): void;
addEventListener(type: "open" | "close" | "error", listener: (event: Event) => void): void;
removeEventListener(type: "message", listener: (event: MessageEvent) => void): void;
removeEventListener(type: "open" | "close" | "error", listener: (event: Event) => void): void;
}

export interface UseChatDebugFramesOptions {
/** Maximum number of frames kept in memory. Defaults to 100. */
bufferSize?: number;
/** Optional frame-type allowlist. Unknown/unparseable frames are filtered unless included. */
include?: readonly (ChatMessageType | "unknown")[];
}

export interface UseChatDebugFramesResult {
frames: readonly DebugFrame[];
clear: () => void;
connectionState: ChatDebugConnectionState;
}

const DEFAULT_BUFFER_SIZE = 100;
const VALID_TYPES = new Set<ChatMessageType>([
"message",
"typing",
"error",
"tool_call",
"tool_result",
"system",
]);
let nextFrameId = 0;

Comment thread
beeeku marked this conversation as resolved.
Outdated
function connectionStateFromReadyState(readyState: number): ChatDebugConnectionState {
switch (readyState) {
case 0:
return "connecting";
case 1:
return "open";
case 2:
return "closing";
default:
return "closed";
}
}

function bytesFor(data: unknown): number {
if (typeof data === "string") {
return new TextEncoder().encode(data).byteLength;
}
Comment thread
beeeku marked this conversation as resolved.
if (data instanceof ArrayBuffer) {
return data.byteLength;
}
if (ArrayBuffer.isView(data)) {
return data.byteLength;
}
if (typeof Blob !== "undefined" && data instanceof Blob) {
return data.size;
}
return 0;
}

function toMessage(data: unknown): ChatMessage | undefined {
if (typeof data !== "string") return undefined;
const parsed = JSON.parse(data) as unknown;
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
throw new Error("Message must be a JSON object");
}
const wire = parsed as Record<string, unknown>;
if (typeof wire.type !== "string" || !VALID_TYPES.has(wire.type as ChatMessageType)) {
throw new Error(`Invalid message type: ${String(wire.type)}`);
}
if (typeof wire.content !== "string") {
throw new Error("Message must have a string 'content' field");
}
return {
id: typeof wire.id === "string" ? wire.id : "",
type: wire.type as ChatMessageType,
role: (wire.role as ChatMessage["role"]) ?? "user",
content: wire.content,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
metadata:
typeof wire.metadata === "object" && wire.metadata !== null && !Array.isArray(wire.metadata)
? (wire.metadata as Record<string, unknown>)
: undefined,
timestamp: Date.now(),
};
Comment thread
beeeku marked this conversation as resolved.
}

function makeFrame(direction: DebugFrame["direction"], data: unknown): DebugFrame {
try {
const message = toMessage(data);
return {
id: `frame-${++nextFrameId}`,
direction,
type: message?.type ?? "unknown",
timestamp: Date.now(),
bytes: bytesFor(data),
data,
message,
};
} catch (err) {
return {
id: `frame-${++nextFrameId}`,
direction,
type: "unknown",
timestamp: Date.now(),
bytes: bytesFor(data),
data,
error: err instanceof Error ? err : new Error(String(err)),
};
}
}

function appendFrame(
frames: readonly DebugFrame[],
frame: DebugFrame,
bufferSize: number,
include: ReadonlySet<ChatMessageType | "unknown"> | undefined,
): readonly DebugFrame[] {
if (include && !include.has(frame.type)) return frames;
const next = [...frames, frame];
return next.length > bufferSize ? next.slice(next.length - bufferSize) : next;
}

export function useChatDebugFrames(
socket: ChatDebugSocket | null | undefined,
options: UseChatDebugFramesOptions = {},
): UseChatDebugFramesResult {
const bufferSize = Math.max(1, Math.floor(options.bufferSize ?? DEFAULT_BUFFER_SIZE));
Comment thread
beeeku marked this conversation as resolved.
Outdated
const include = useMemo(
() => (options.include ? new Set(options.include) : undefined),
[options.include],
);
const [frames, setFrames] = useState<readonly DebugFrame[]>([]);
const [connectionState, setConnectionState] = useState<ChatDebugConnectionState>(() =>
socket ? connectionStateFromReadyState(socket.readyState) : "closed",
);
const originalSendRef = useRef<ChatDebugSocket["send"] | undefined>(undefined);

const recordFrame = useCallback(
(direction: DebugFrame["direction"], data: unknown) => {
const frame = makeFrame(direction, data);
setFrames((current) => appendFrame(current, frame, bufferSize, include));
},
[bufferSize, include],
);

const clear = useCallback(() => {
setFrames([]);
}, []);

useEffect(() => {
if (!socket) {
setConnectionState("closed");
return;
}

setConnectionState(connectionStateFromReadyState(socket.readyState));

const syncConnectionState = () => {
setConnectionState(connectionStateFromReadyState(socket.readyState));
};
const onMessage = (event: MessageEvent) => {
recordFrame("in", event.data);
};

socket.addEventListener("message", onMessage);
socket.addEventListener("open", syncConnectionState);
socket.addEventListener("close", syncConnectionState);
socket.addEventListener("error", syncConnectionState);

const originalSend = socket.send.bind(socket);
originalSendRef.current = originalSend;
socket.send = ((data: Parameters<ChatDebugSocket["send"]>[0]) => {
recordFrame("out", data);
return originalSend(data);
}) as ChatDebugSocket["send"];

return () => {
socket.removeEventListener("message", onMessage);
socket.removeEventListener("open", syncConnectionState);
socket.removeEventListener("close", syncConnectionState);
socket.removeEventListener("error", syncConnectionState);
if (originalSendRef.current) {
socket.send = originalSendRef.current;
}
originalSendRef.current = undefined;
};
}, [recordFrame, socket]);

return { frames, clear, connectionState };
}
Loading