From 73cf44458708bd840bfe91ef60ce18efc37e8f01 Mon Sep 17 00:00:00 2001 From: LuminaFlow Date: Sat, 23 May 2026 05:47:42 +0200 Subject: [PATCH 1/6] chat: add react debug frame hook Why: - implement help-wanted chat-react debug frames package for beeeku/workkit#84 - share DebugFrame types from @workkit/chat and provide focused hook tests Verified: - bun run --filter @workkit/chat-react test - bun run --filter @workkit/chat-react typecheck - bun run --filter @workkit/chat-react build - bun run --filter @workkit/chat test - bun run --filter @workkit/chat typecheck - bun x biome check packages/chat/src/index.ts packages/chat/src/types.ts packages/chat-react .changeset/quiet-frames-chat-react.md --- .changeset/quiet-frames-chat-react.md | 6 + bun.lock | 84 ++++++-- packages/chat-react/CHANGELOG.md | 7 + packages/chat-react/README.md | 16 ++ packages/chat-react/bunup.config.ts | 9 + packages/chat-react/package.json | 61 ++++++ packages/chat-react/src/index.ts | 199 ++++++++++++++++++ .../tests/use-chat-debug-frames.test.ts | 131 ++++++++++++ packages/chat-react/tsconfig.json | 10 + packages/chat-react/vitest.config.ts | 5 + packages/chat/src/index.ts | 2 + packages/chat/src/types.ts | 23 ++ 12 files changed, 531 insertions(+), 22 deletions(-) create mode 100644 .changeset/quiet-frames-chat-react.md create mode 100644 packages/chat-react/CHANGELOG.md create mode 100644 packages/chat-react/README.md create mode 100644 packages/chat-react/bunup.config.ts create mode 100644 packages/chat-react/package.json create mode 100644 packages/chat-react/src/index.ts create mode 100644 packages/chat-react/tests/use-chat-debug-frames.test.ts create mode 100644 packages/chat-react/tsconfig.json create mode 100644 packages/chat-react/vitest.config.ts diff --git a/.changeset/quiet-frames-chat-react.md b/.changeset/quiet-frames-chat-react.md new file mode 100644 index 0000000..657244a --- /dev/null +++ b/.changeset/quiet-frames-chat-react.md @@ -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. diff --git a/bun.lock b/bun.lock index fd44091..d87fdef 100644 --- a/bun.lock +++ b/bun.lock @@ -99,7 +99,7 @@ }, "packages/agent": { "name": "@workkit/agent", - "version": "0.3.0", + "version": "0.4.0", "dependencies": { "@standard-schema/spec": "^1.1.0", "@workkit/ai-gateway": "workspace:*", @@ -112,7 +112,7 @@ }, "packages/ai": { "name": "@workkit/ai", - "version": "0.2.1", + "version": "0.2.2", "dependencies": { "@workkit/errors": "^1.0.1", "@workkit/types": "^1.0.1", @@ -120,7 +120,7 @@ }, "packages/ai-gateway": { "name": "@workkit/ai-gateway", - "version": "0.5.0", + "version": "0.6.0", "dependencies": { "@workkit/errors": "^1.0.1", "@workkit/types": "^1.0.1", @@ -139,7 +139,7 @@ }, "packages/approval": { "name": "@workkit/approval", - "version": "0.1.2", + "version": "0.1.3", "dependencies": { "@workkit/crypto": "workspace:*", "@workkit/errors": "workspace:*", @@ -165,7 +165,7 @@ }, "packages/browser": { "name": "@workkit/browser", - "version": "0.1.0", + "version": "0.1.1", "dependencies": { "@workkit/errors": "^1.0.2", }, @@ -191,6 +191,22 @@ "@workkit/types": "^1.0.1", }, }, + "packages/chat-react": { + "name": "@workkit/chat-react", + "version": "0.1.0", + "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", + }, + "peerDependencies": { + "@workkit/chat": "workspace:*", + "react": ">=18", + }, + }, "packages/cli": { "name": "workkit", "version": "0.2.1", @@ -254,7 +270,7 @@ }, "packages/errors": { "name": "@workkit/errors", - "version": "1.0.3", + "version": "1.0.4", "devDependencies": { "@workkit/types": "workspace:*", }, @@ -330,9 +346,9 @@ }, "packages/mail": { "name": "@workkit/mail", - "version": "0.1.1", + "version": "0.2.0", "dependencies": { - "@workkit/errors": "^1.0.1", + "@workkit/errors": "^1.0.4", "@workkit/types": "^1.0.1", "mimetext": "^3.0.24", "postal-mime": "^2.4.1", @@ -343,7 +359,7 @@ }, "packages/mcp": { "name": "@workkit/mcp", - "version": "0.2.1", + "version": "0.2.2", "dependencies": { "@workkit/errors": "workspace:*", "@workkit/types": "^1.0.1", @@ -356,7 +372,7 @@ }, "packages/memory": { "name": "@workkit/memory", - "version": "0.2.0", + "version": "0.2.1", "dependencies": { "@workkit/errors": "workspace:*", }, @@ -368,10 +384,10 @@ }, "packages/notify": { "name": "@workkit/notify", - "version": "0.2.0", + "version": "1.0.0", "dependencies": { "@standard-schema/spec": "^1.1.0", - "@workkit/errors": "^1.0.3", + "@workkit/errors": "^1.0.4", }, "devDependencies": { "@types/better-sqlite3": "^7.6.13", @@ -391,7 +407,7 @@ }, "packages/pdf": { "name": "@workkit/pdf", - "version": "0.1.0", + "version": "0.1.1", "dependencies": { "@workkit/browser": "workspace:*", "@workkit/errors": "^1.0.2", @@ -424,7 +440,7 @@ }, "packages/ratelimit": { "name": "@workkit/ratelimit", - "version": "0.2.1", + "version": "0.2.2", "dependencies": { "@workkit/errors": "^1.0.1", "@workkit/types": "^1.0.1", @@ -432,7 +448,7 @@ }, "packages/realtime": { "name": "@workkit/realtime", - "version": "0.1.0", + "version": "0.2.0", "dependencies": { "@workkit/do": "workspace:*", "@workkit/types": "^1.0.1", @@ -481,7 +497,7 @@ }, "packages/workflow": { "name": "@workkit/workflow", - "version": "0.1.1", + "version": "0.1.2", "dependencies": { "@workkit/errors": "workspace:*", }, @@ -1113,10 +1129,14 @@ "@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="], - "@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="], + "@types/prop-types": ["@types/prop-types@15.7.15", "", {}, "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw=="], + + "@types/react": ["@types/react@18.3.29", "", { "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" } }, "sha512-ch0qJdr2JY0r04NXSprbK6TXOgnaJ1Tz23fm5W+z0/CBah6BSBc3n96h7K9GOtwh0HrilNWHIBzE1Ko4Dcw/Wg=="], "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], + "@types/react-test-renderer": ["@types/react-test-renderer@18.3.1", "", { "dependencies": { "@types/react": "^18" } }, "sha512-vAhnk0tG2eGa37lkU9+s5SoroCsRI08xnsWFiAXOuPH2jqzMbcXvKExXViPi1P5fIklDeCvXqyrdmipFaSkZrA=="], + "@types/sax": ["@types/sax@1.2.7", "", { "dependencies": { "@types/node": "*" } }, "sha512-rO73L89PJxeYM3s3pPPjiPgVVcymqU490g0YO5n5By0k2Erzj6tay/4lr1CHAAU4JyOWd1rpQ8bCf6cZfHU96A=="], "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], @@ -1177,6 +1197,8 @@ "@workkit/chat": ["@workkit/chat@workspace:packages/chat"], + "@workkit/chat-react": ["@workkit/chat-react@workspace:packages/chat-react"], + "@workkit/cron": ["@workkit/cron@workspace:packages/cron"], "@workkit/crypto": ["@workkit/crypto@workspace:packages/crypto"], @@ -1707,7 +1729,7 @@ "js-base64": ["js-base64@3.7.8", "", {}, "sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow=="], - "js-tokens": ["js-tokens@9.0.1", "", {}, "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ=="], + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], @@ -1759,6 +1781,8 @@ "longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="], + "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], + "loupe": ["loupe@3.2.1", "", {}, "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ=="], "lru-cache": ["lru-cache@11.2.7", "", {}, "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA=="], @@ -2057,12 +2081,18 @@ "rc": ["rc@1.2.8", "", { "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", "minimist": "^1.2.0", "strip-json-comments": "~2.0.1" }, "bin": { "rc": "./cli.js" } }, "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw=="], - "react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="], + "react": ["react@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="], "react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="], + "react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], + "react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="], + "react-shallow-renderer": ["react-shallow-renderer@16.15.0", "", { "dependencies": { "object-assign": "^4.1.1", "react-is": "^16.12.0 || ^17.0.0 || ^18.0.0" }, "peerDependencies": { "react": "^16.0.0 || ^17.0.0 || ^18.0.0" } }, "sha512-oScf2FqQ9LFVQgA73vr86xl2NaOIX73rh+YFqcOp68CWj56tSfgtGKrEbyhCj0rSijyG9M1CYprTh39fBi5hzA=="], + + "react-test-renderer": ["react-test-renderer@18.3.1", "", { "dependencies": { "react-is": "^18.3.1", "react-shallow-renderer": "^16.15.0", "scheduler": "^0.23.2" }, "peerDependencies": { "react": "^18.3.1" } }, "sha512-KkAgygexHUkQqtvvx/otwxtuFu5cVjfzTCtjXLH9boS19/Nbtg84zS7wIQn39G8IlrhThBpQsMKkq5ZHZIYFXA=="], + "read-cache": ["read-cache@1.0.0", "", { "dependencies": { "pify": "^2.3.0" } }, "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA=="], "read-yaml-file": ["read-yaml-file@1.1.0", "", { "dependencies": { "graceful-fs": "^4.1.5", "js-yaml": "^3.6.1", "pify": "^4.0.1", "strip-bom": "^3.0.0" } }, "sha512-VIMnQi/Z4HT2Fxuwg5KrY174U1VdUIASQVWXXyqtNRtxSr9IYkn1rsI6Tb6HsrHCmB7gVpNwX6JxPTHcH6IoTA=="], @@ -2143,7 +2173,7 @@ "sax": ["sax@1.6.0", "", {}, "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA=="], - "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], + "scheduler": ["scheduler@0.23.2", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ=="], "semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], @@ -2457,8 +2487,6 @@ "@astrojs/tailwind/astro": ["astro@5.18.1", "", { "dependencies": { "@astrojs/compiler": "^2.13.0", "@astrojs/internal-helpers": "0.7.6", "@astrojs/markdown-remark": "6.3.11", "@astrojs/telemetry": "3.3.0", "@capsizecss/unpack": "^4.0.0", "@oslojs/encoding": "^1.1.0", "@rollup/pluginutils": "^5.3.0", "acorn": "^8.15.0", "aria-query": "^5.3.2", "axobject-query": "^4.1.0", "boxen": "8.0.1", "ci-info": "^4.3.1", "clsx": "^2.1.1", "common-ancestor-path": "^1.0.1", "cookie": "^1.1.1", "cssesc": "^3.0.0", "debug": "^4.4.3", "deterministic-object-hash": "^2.0.2", "devalue": "^5.6.2", "diff": "^8.0.3", "dlv": "^1.1.3", "dset": "^3.1.4", "es-module-lexer": "^1.7.0", "esbuild": "^0.27.3", "estree-walker": "^3.0.3", "flattie": "^1.1.1", "fontace": "~0.4.0", "github-slugger": "^2.0.0", "html-escaper": "3.0.3", "http-cache-semantics": "^4.2.0", "import-meta-resolve": "^4.2.0", "js-yaml": "^4.1.1", "magic-string": "^0.30.21", "magicast": "^0.5.1", "mrmime": "^2.0.1", "neotraverse": "^0.6.18", "p-limit": "^6.2.0", "p-queue": "^8.1.1", "package-manager-detector": "^1.6.0", "piccolore": "^0.1.3", "picomatch": "^4.0.3", "prompts": "^2.4.2", "rehype": "^13.0.2", "semver": "^7.7.3", "shiki": "^3.21.0", "smol-toml": "^1.6.0", "svgo": "^4.0.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tsconfck": "^3.1.6", "ultrahtml": "^1.6.0", "unifont": "~0.7.3", "unist-util-visit": "^5.0.0", "unstorage": "^1.17.4", "vfile": "^6.0.3", "vite": "^6.4.1", "vitefu": "^1.1.1", "xxhash-wasm": "^1.1.0", "yargs-parser": "^21.1.1", "yocto-spinner": "^0.2.3", "zod": "^3.25.76", "zod-to-json-schema": "^3.25.1", "zod-to-ts": "^1.2.0" }, "optionalDependencies": { "sharp": "^0.34.0" }, "bin": { "astro": "astro.js" } }, "sha512-m4VWilWZ+Xt6NPoYzC4CgGZim/zQUO7WFL0RHCH0AiEavF1153iC3+me2atDvXpf/yX4PyGUeD8wZLq1cirT3g=="], - "@babel/code-frame/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], - "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "@babel/generator/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], @@ -2497,10 +2525,16 @@ "@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], + "@types/react-dom/@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="], + "@workkit/api/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], + "@workkit/docs/@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="], + "@workkit/docs/astro": ["astro@5.18.1", "", { "dependencies": { "@astrojs/compiler": "^2.13.0", "@astrojs/internal-helpers": "0.7.6", "@astrojs/markdown-remark": "6.3.11", "@astrojs/telemetry": "3.3.0", "@capsizecss/unpack": "^4.0.0", "@oslojs/encoding": "^1.1.0", "@rollup/pluginutils": "^5.3.0", "acorn": "^8.15.0", "aria-query": "^5.3.2", "axobject-query": "^4.1.0", "boxen": "8.0.1", "ci-info": "^4.3.1", "clsx": "^2.1.1", "common-ancestor-path": "^1.0.1", "cookie": "^1.1.1", "cssesc": "^3.0.0", "debug": "^4.4.3", "deterministic-object-hash": "^2.0.2", "devalue": "^5.6.2", "diff": "^8.0.3", "dlv": "^1.1.3", "dset": "^3.1.4", "es-module-lexer": "^1.7.0", "esbuild": "^0.27.3", "estree-walker": "^3.0.3", "flattie": "^1.1.1", "fontace": "~0.4.0", "github-slugger": "^2.0.0", "html-escaper": "3.0.3", "http-cache-semantics": "^4.2.0", "import-meta-resolve": "^4.2.0", "js-yaml": "^4.1.1", "magic-string": "^0.30.21", "magicast": "^0.5.1", "mrmime": "^2.0.1", "neotraverse": "^0.6.18", "p-limit": "^6.2.0", "p-queue": "^8.1.1", "package-manager-detector": "^1.6.0", "piccolore": "^0.1.3", "picomatch": "^4.0.3", "prompts": "^2.4.2", "rehype": "^13.0.2", "semver": "^7.7.3", "shiki": "^3.21.0", "smol-toml": "^1.6.0", "svgo": "^4.0.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tsconfck": "^3.1.6", "ultrahtml": "^1.6.0", "unifont": "~0.7.3", "unist-util-visit": "^5.0.0", "unstorage": "^1.17.4", "vfile": "^6.0.3", "vite": "^6.4.1", "vitefu": "^1.1.1", "xxhash-wasm": "^1.1.0", "yargs-parser": "^21.1.1", "yocto-spinner": "^0.2.3", "zod": "^3.25.76", "zod-to-json-schema": "^3.25.1", "zod-to-ts": "^1.2.0" }, "optionalDependencies": { "sharp": "^0.34.0" }, "bin": { "astro": "astro.js" } }, "sha512-m4VWilWZ+Xt6NPoYzC4CgGZim/zQUO7WFL0RHCH0AiEavF1153iC3+me2atDvXpf/yX4PyGUeD8wZLq1cirT3g=="], + "@workkit/docs/react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="], + "@workkit/env/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], "@workkit/hono/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], @@ -2539,6 +2573,10 @@ "rc/strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="], + "react-dom/react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="], + + "react-dom/scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], + "read-cache/pify": ["pify@2.3.0", "", {}, "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog=="], "read-yaml-file/js-yaml": ["js-yaml@3.14.2", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg=="], @@ -2547,6 +2585,8 @@ "sitemap/@types/node": ["@types/node@24.12.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ=="], + "strip-literal/js-tokens": ["js-tokens@9.0.1", "", {}, "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ=="], + "sucrase/commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="], "tailwindcss/chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="], diff --git a/packages/chat-react/CHANGELOG.md b/packages/chat-react/CHANGELOG.md new file mode 100644 index 0000000..3b65333 --- /dev/null +++ b/packages/chat-react/CHANGELOG.md @@ -0,0 +1,7 @@ +# @workkit/chat-react + +## 0.1.0 + +### Minor Changes + +- Initial release with `useChatDebugFrames`. diff --git a/packages/chat-react/README.md b/packages/chat-react/README.md new file mode 100644 index 0000000..db3d8cd --- /dev/null +++ b/packages/chat-react/README.md @@ -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. diff --git a/packages/chat-react/bunup.config.ts b/packages/chat-react/bunup.config.ts new file mode 100644 index 0000000..5a7ef82 --- /dev/null +++ b/packages/chat-react/bunup.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "bunup"; + +export default defineConfig({ + entry: ["src/index.ts"], + format: ["esm"], + dts: true, + sourcemap: "linked", + clean: true, +}); diff --git a/packages/chat-react/package.json b/packages/chat-react/package.json new file mode 100644 index 0000000..492a33e --- /dev/null +++ b/packages/chat-react/package.json @@ -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 ", + "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" + ] +} diff --git a/packages/chat-react/src/index.ts b/packages/chat-react/src/index.ts new file mode 100644 index 0000000..e415f69 --- /dev/null +++ b/packages/chat-react/src/index.ts @@ -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([ + "message", + "typing", + "error", + "tool_call", + "tool_result", + "system", +]); +let nextFrameId = 0; + +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; + } + 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; + 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, + metadata: + typeof wire.metadata === "object" && wire.metadata !== null && !Array.isArray(wire.metadata) + ? (wire.metadata as Record) + : undefined, + timestamp: Date.now(), + }; +} + +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 | 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)); + const include = useMemo( + () => (options.include ? new Set(options.include) : undefined), + [options.include], + ); + const [frames, setFrames] = useState([]); + const [connectionState, setConnectionState] = useState(() => + socket ? connectionStateFromReadyState(socket.readyState) : "closed", + ); + const originalSendRef = useRef(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[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 }; +} diff --git a/packages/chat-react/tests/use-chat-debug-frames.test.ts b/packages/chat-react/tests/use-chat-debug-frames.test.ts new file mode 100644 index 0000000..b8d9ecc --- /dev/null +++ b/packages/chat-react/tests/use-chat-debug-frames.test.ts @@ -0,0 +1,131 @@ +import React from "react"; +import { type ReactTestRenderer, act, create } from "react-test-renderer"; +import { describe, expect, it } from "vitest"; +import { type ChatDebugSocket, type UseChatDebugFramesResult, useChatDebugFrames } from "../src"; + +class MockSocket implements ChatDebugSocket { + readyState = 0; + sent: unknown[] = []; + private listeners = new Map void>>(); + + send(data: Parameters[0]): void { + this.sent.push(data); + } + + addEventListener(type: string, listener: (event: any) => void): void { + const listeners = this.listeners.get(type) ?? new Set(); + listeners.add(listener); + this.listeners.set(type, listeners); + } + + removeEventListener(type: string, listener: (event: any) => void): void { + this.listeners.get(type)?.delete(listener); + } + + emit(type: string, event: any = {}): void { + for (const listener of this.listeners.get(type) ?? []) { + listener(event); + } + } +} + +function mountHook(socket: MockSocket, options?: Parameters[1]) { + let latest: UseChatDebugFramesResult | undefined; + let renderer: ReactTestRenderer; + + function TestComponent() { + latest = useChatDebugFrames(socket, options); + return null; + } + + act(() => { + renderer = create(React.createElement(TestComponent)); + }); + + return { + get result() { + if (!latest) throw new Error("hook did not render"); + return latest; + }, + unmount: () => { + act(() => { + renderer.unmount(); + }); + }, + }; +} + +describe("useChatDebugFrames", () => { + it("captures inbound and outbound chat frames newest-last", () => { + const socket = new MockSocket(); + socket.readyState = 1; + const hook = mountHook(socket); + + act(() => { + socket.emit("message", { + data: JSON.stringify({ id: "in-1", type: "message", role: "assistant", content: "hi" }), + }); + socket.send(JSON.stringify({ id: "out-1", type: "typing", role: "user", content: "..." })); + }); + + expect(hook.result.connectionState).toBe("open"); + expect(hook.result.frames).toHaveLength(2); + expect(hook.result.frames[0]?.direction).toBe("in"); + expect(hook.result.frames[0]?.message?.id).toBe("in-1"); + expect(hook.result.frames[1]?.direction).toBe("out"); + expect(hook.result.frames[1]?.type).toBe("typing"); + expect(socket.sent).toHaveLength(1); + + hook.unmount(); + }); + + it("applies include filters and buffer caps", () => { + const socket = new MockSocket(); + const hook = mountHook(socket, { bufferSize: 2, include: ["message"] }); + + act(() => { + socket.emit("message", { + data: JSON.stringify({ id: "m1", type: "message", role: "assistant", content: "one" }), + }); + socket.emit("message", { + data: JSON.stringify({ id: "t1", type: "typing", role: "assistant", content: "typing" }), + }); + socket.emit("message", { + data: JSON.stringify({ id: "m2", type: "message", role: "assistant", content: "two" }), + }); + socket.emit("message", { + data: JSON.stringify({ id: "m3", type: "message", role: "assistant", content: "three" }), + }); + }); + + expect(hook.result.frames.map((frame) => frame.message?.id)).toEqual(["m2", "m3"]); + + hook.unmount(); + }); + + it("tracks connection state and clears the buffer", () => { + const socket = new MockSocket(); + const hook = mountHook(socket); + + act(() => { + socket.readyState = 1; + socket.emit("open"); + socket.emit("message", { + data: JSON.stringify({ id: "m1", type: "message", role: "assistant", content: "one" }), + }); + }); + expect(hook.result.connectionState).toBe("open"); + expect(hook.result.frames).toHaveLength(1); + + act(() => { + hook.result.clear(); + socket.readyState = 3; + socket.emit("close"); + }); + + expect(hook.result.frames).toHaveLength(0); + expect(hook.result.connectionState).toBe("closed"); + + hook.unmount(); + }); +}); diff --git a/packages/chat-react/tsconfig.json b/packages/chat-react/tsconfig.json new file mode 100644 index 0000000..0d4f143 --- /dev/null +++ b/packages/chat-react/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tooling/tsconfig/library.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "lib": ["ES2022", "DOM"] + }, + "include": ["src/**/*.ts"], + "exclude": ["dist", "node_modules", "tests"] +} diff --git a/packages/chat-react/vitest.config.ts b/packages/chat-react/vitest.config.ts new file mode 100644 index 0000000..7c3d531 --- /dev/null +++ b/packages/chat-react/vitest.config.ts @@ -0,0 +1,5 @@ +import { defineWorkkitVitest } from "@workkit/vitest-config"; + +export default defineWorkkitVitest({ + include: ["tests/**/*.test.ts"], +}); diff --git a/packages/chat/src/index.ts b/packages/chat/src/index.ts index 93579de..e40f2cc 100644 --- a/packages/chat/src/index.ts +++ b/packages/chat/src/index.ts @@ -18,6 +18,8 @@ export type { ChatErrorCode } from "./errors"; export type { ChatMessage, ChatMessageType, + DebugFrame, + DebugFrameDirection, ChatTransportOptions, InboundFrameEvent, OutboundFrameEvent, diff --git a/packages/chat/src/types.ts b/packages/chat/src/types.ts index 16d6b35..bd0df3e 100644 --- a/packages/chat/src/types.ts +++ b/packages/chat/src/types.ts @@ -77,6 +77,29 @@ export type OutboundFrameEvent = | (OutboundFrameEventBase & { phase: "sent" }) | (OutboundFrameEventBase & { phase: "send-failed"; error: Error }); +/** Direction for a client-side debug frame captured around a WebSocket. */ +export type DebugFrameDirection = "in" | "out"; + +/** Client-side frame captured for development/debug inspection. */ +export interface DebugFrame { + /** Stable hook-local id for rendering lists. */ + id: string; + /** Whether the browser received (`in`) or sent (`out`) the frame. */ + direction: DebugFrameDirection; + /** Best-effort parsed chat message type; `unknown` when the payload is not a chat message. */ + type: ChatMessageType | "unknown"; + /** Frame capture time in epoch milliseconds. */ + timestamp: number; + /** UTF-8 byte length for strings, raw byte length for binary frames when available. */ + bytes: number; + /** Raw payload as it appeared at the browser boundary. */ + data: unknown; + /** Decoded chat message payload when parsing succeeds. */ + message?: ChatMessage; + /** Parse or socket error captured while observing the frame. */ + error?: Error; +} + /** Options for creating a chat transport */ export interface ChatTransportOptions { /** Called when a message arrives on the WebSocket. Return message(s) to send back. */ From dabc374141d46f99ff72d657c6ede35d591ecc7e Mon Sep 17 00:00:00 2001 From: LuminaFlow Date: Sat, 23 May 2026 05:58:01 +0200 Subject: [PATCH 2/6] chat-react: validate debug frame roles Why: - Address CodeRabbit feedback on PR #118 by preventing invalid wire roles from entering parsed debug messages. Verified: - bun run --filter @workkit/chat-react test - bun run --filter @workkit/chat-react typecheck - bun run --filter @workkit/chat-react build - bun run --filter @workkit/chat test - bun run --filter @workkit/chat typecheck - bun x biome check packages/chat-react/src/index.ts packages/chat-react/tests/use-chat-debug-frames.test.ts packages/chat/src/index.ts packages/chat/src/types.ts - bun run constitution:check --diff-only --base=origin/master - git diff --check --- packages/chat-react/src/index.ts | 8 +++++++- .../tests/use-chat-debug-frames.test.ts | 16 ++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/packages/chat-react/src/index.ts b/packages/chat-react/src/index.ts index e415f69..ac70ed4 100644 --- a/packages/chat-react/src/index.ts +++ b/packages/chat-react/src/index.ts @@ -36,6 +36,7 @@ const VALID_TYPES = new Set([ "tool_result", "system", ]); +const VALID_ROLES = new Set(["user", "assistant", "system"]); let nextFrameId = 0; function connectionStateFromReadyState(readyState: number): ChatDebugConnectionState { @@ -67,6 +68,10 @@ function bytesFor(data: unknown): number { return 0; } +function isChatMessageRole(value: unknown): value is ChatMessage["role"] { + return typeof value === "string" && VALID_ROLES.has(value as ChatMessage["role"]); +} + function toMessage(data: unknown): ChatMessage | undefined { if (typeof data !== "string") return undefined; const parsed = JSON.parse(data) as unknown; @@ -80,10 +85,11 @@ function toMessage(data: unknown): ChatMessage | undefined { if (typeof wire.content !== "string") { throw new Error("Message must have a string 'content' field"); } + const role = isChatMessageRole(wire.role) ? wire.role : "user"; return { id: typeof wire.id === "string" ? wire.id : "", type: wire.type as ChatMessageType, - role: (wire.role as ChatMessage["role"]) ?? "user", + role, content: wire.content, metadata: typeof wire.metadata === "object" && wire.metadata !== null && !Array.isArray(wire.metadata) diff --git a/packages/chat-react/tests/use-chat-debug-frames.test.ts b/packages/chat-react/tests/use-chat-debug-frames.test.ts index b8d9ecc..b77a6e0 100644 --- a/packages/chat-react/tests/use-chat-debug-frames.test.ts +++ b/packages/chat-react/tests/use-chat-debug-frames.test.ts @@ -103,6 +103,22 @@ describe("useChatDebugFrames", () => { hook.unmount(); }); + it("falls back to user when a wire message has an invalid role", () => { + const socket = new MockSocket(); + const hook = mountHook(socket); + + act(() => { + socket.emit("message", { + data: JSON.stringify({ id: "bad-role", type: "message", role: "admin", content: "one" }), + }); + }); + + expect(hook.result.frames[0]?.message?.role).toBe("user"); + expect(hook.result.frames[0]?.error).toBeUndefined(); + + hook.unmount(); + }); + it("tracks connection state and clears the buffer", () => { const socket = new MockSocket(); const hook = mountHook(socket); From f42c5a2650a7960936f39269e057a189d1fc4972 Mon Sep 17 00:00:00 2001 From: LuminaFlow Date: Sat, 23 May 2026 07:13:04 +0200 Subject: [PATCH 3/6] chat-react: address debug frame review --- packages/chat-react/src/index.ts | 26 +++++--- .../tests/use-chat-debug-frames.test.ts | 59 ++++++++++++++++++- 2 files changed, 76 insertions(+), 9 deletions(-) diff --git a/packages/chat-react/src/index.ts b/packages/chat-react/src/index.ts index ac70ed4..8485345 100644 --- a/packages/chat-react/src/index.ts +++ b/packages/chat-react/src/index.ts @@ -37,7 +37,7 @@ const VALID_TYPES = new Set([ "system", ]); const VALID_ROLES = new Set(["user", "assistant", "system"]); -let nextFrameId = 0; +const textEncoder = new TextEncoder(); function connectionStateFromReadyState(readyState: number): ChatDebugConnectionState { switch (readyState) { @@ -54,7 +54,7 @@ function connectionStateFromReadyState(readyState: number): ChatDebugConnectionS function bytesFor(data: unknown): number { if (typeof data === "string") { - return new TextEncoder().encode(data).byteLength; + return textEncoder.encode(data).byteLength; } if (data instanceof ArrayBuffer) { return data.byteLength; @@ -72,6 +72,10 @@ function isChatMessageRole(value: unknown): value is ChatMessage["role"] { return typeof value === "string" && VALID_ROLES.has(value as ChatMessage["role"]); } +function timestampFromWire(value: unknown): number { + return typeof value === "number" && Number.isFinite(value) ? value : Date.now(); +} + function toMessage(data: unknown): ChatMessage | undefined { if (typeof data !== "string") return undefined; const parsed = JSON.parse(data) as unknown; @@ -95,15 +99,15 @@ function toMessage(data: unknown): ChatMessage | undefined { typeof wire.metadata === "object" && wire.metadata !== null && !Array.isArray(wire.metadata) ? (wire.metadata as Record) : undefined, - timestamp: Date.now(), + timestamp: timestampFromWire(wire.timestamp), }; } -function makeFrame(direction: DebugFrame["direction"], data: unknown): DebugFrame { +function makeFrame(direction: DebugFrame["direction"], data: unknown, id: string): DebugFrame { try { const message = toMessage(data); return { - id: `frame-${++nextFrameId}`, + id, direction, type: message?.type ?? "unknown", timestamp: Date.now(), @@ -113,7 +117,7 @@ function makeFrame(direction: DebugFrame["direction"], data: unknown): DebugFram }; } catch (err) { return { - id: `frame-${++nextFrameId}`, + id, direction, type: "unknown", timestamp: Date.now(), @@ -124,6 +128,11 @@ function makeFrame(direction: DebugFrame["direction"], data: unknown): DebugFram } } +function sanitizeBufferSize(value: number | undefined): number { + const candidate = value ?? DEFAULT_BUFFER_SIZE; + return Number.isFinite(candidate) ? Math.max(1, Math.floor(candidate)) : DEFAULT_BUFFER_SIZE; +} + function appendFrame( frames: readonly DebugFrame[], frame: DebugFrame, @@ -139,7 +148,7 @@ export function useChatDebugFrames( socket: ChatDebugSocket | null | undefined, options: UseChatDebugFramesOptions = {}, ): UseChatDebugFramesResult { - const bufferSize = Math.max(1, Math.floor(options.bufferSize ?? DEFAULT_BUFFER_SIZE)); + const bufferSize = sanitizeBufferSize(options.bufferSize); const include = useMemo( () => (options.include ? new Set(options.include) : undefined), [options.include], @@ -149,10 +158,11 @@ export function useChatDebugFrames( socket ? connectionStateFromReadyState(socket.readyState) : "closed", ); const originalSendRef = useRef(undefined); + const nextFrameIdRef = useRef(0); const recordFrame = useCallback( (direction: DebugFrame["direction"], data: unknown) => { - const frame = makeFrame(direction, data); + const frame = makeFrame(direction, data, `frame-${++nextFrameIdRef.current}`); setFrames((current) => appendFrame(current, frame, bufferSize, include)); }, [bufferSize, include], diff --git a/packages/chat-react/tests/use-chat-debug-frames.test.ts b/packages/chat-react/tests/use-chat-debug-frames.test.ts index b77a6e0..036daec 100644 --- a/packages/chat-react/tests/use-chat-debug-frames.test.ts +++ b/packages/chat-react/tests/use-chat-debug-frames.test.ts @@ -60,10 +60,17 @@ describe("useChatDebugFrames", () => { const socket = new MockSocket(); socket.readyState = 1; const hook = mountHook(socket); + const messageTimestamp = 1_774_111_200_000; act(() => { socket.emit("message", { - data: JSON.stringify({ id: "in-1", type: "message", role: "assistant", content: "hi" }), + data: JSON.stringify({ + id: "in-1", + type: "message", + role: "assistant", + content: "hi", + timestamp: messageTimestamp, + }), }); socket.send(JSON.stringify({ id: "out-1", type: "typing", role: "user", content: "..." })); }); @@ -72,6 +79,7 @@ describe("useChatDebugFrames", () => { expect(hook.result.frames).toHaveLength(2); expect(hook.result.frames[0]?.direction).toBe("in"); expect(hook.result.frames[0]?.message?.id).toBe("in-1"); + expect(hook.result.frames[0]?.message?.timestamp).toBe(messageTimestamp); expect(hook.result.frames[1]?.direction).toBe("out"); expect(hook.result.frames[1]?.type).toBe("typing"); expect(socket.sent).toHaveLength(1); @@ -103,6 +111,55 @@ describe("useChatDebugFrames", () => { hook.unmount(); }); + it("falls back to the default buffer size when bufferSize is NaN", () => { + const socket = new MockSocket(); + const hook = mountHook(socket, { bufferSize: Number.NaN }); + + act(() => { + for (let index = 0; index < 101; index += 1) { + socket.emit("message", { + data: JSON.stringify({ + id: `m${index}`, + type: "message", + role: "assistant", + content: String(index), + }), + }); + } + }); + + expect(hook.result.frames).toHaveLength(100); + expect(hook.result.frames[0]?.message?.id).toBe("m1"); + + hook.unmount(); + }); + + it("uses hook-local frame ids", () => { + const firstSocket = new MockSocket(); + const firstHook = mountHook(firstSocket); + + act(() => { + firstSocket.emit("message", { + data: JSON.stringify({ id: "first", type: "message", role: "assistant", content: "one" }), + }); + }); + + expect(firstHook.result.frames[0]?.id).toBe("frame-1"); + firstHook.unmount(); + + const secondSocket = new MockSocket(); + const secondHook = mountHook(secondSocket); + + act(() => { + secondSocket.emit("message", { + data: JSON.stringify({ id: "second", type: "message", role: "assistant", content: "two" }), + }); + }); + + expect(secondHook.result.frames[0]?.id).toBe("frame-1"); + secondHook.unmount(); + }); + it("falls back to user when a wire message has an invalid role", () => { const socket = new MockSocket(); const hook = mountHook(socket); From 22ac9c248a50d64ce01b45eea1d3fa4dcfba5375 Mon Sep 17 00:00:00 2001 From: Christian Scherkl Date: Sat, 23 May 2026 07:31:31 +0200 Subject: [PATCH 4/6] chat-react: harden shared debug send capture --- README.md | 2 + docs/README.md | 1 + docs/api-reference.md | 11 +++ docs/getting-started.md | 3 + packages/chat-react/src/index.ts | 60 ++++++++++--- .../tests/use-chat-debug-frames.test.ts | 86 +++++++++++++++++++ 6 files changed, 151 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 295956f..f65d867 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,8 @@ Every package wraps a Cloudflare binding or API with type safety, better DX, and | [`@workkit/ai-gateway`](packages/ai-gateway) | Multi-provider AI gateway — Workers AI, OpenAI, Anthropic, custom. Routing, streaming, fallback, retry, prompt caching, Cloudflare AI Gateway routing, cost tracking | | [`@workkit/api`](packages/api) | Type-safe API definitions with Standard Schema and OpenAPI generation | | [`@workkit/realtime`](packages/realtime) | SSE-over-Durable-Objects broadcast primitive — per-channel pub/sub with Last-Event-ID replay and a fetch-based client wrapper | +| [`@workkit/chat`](packages/chat) | WebSocket chat transport primitives and shared debug-frame types | +| [`@workkit/chat-react`](packages/chat-react) | Headless React hook for inspecting inbound/outbound chat WebSocket frames | | [`@workkit/logger`](packages/logger) | Structured logging with request context and Hono middleware | | [`@workkit/auth`](packages/auth) | JWT, session management, and auth middleware | | [`@workkit/testing`](packages/testing) | In-memory mocks with operation tracking, seed builders, error injection, and snapshots | diff --git a/docs/README.md b/docs/README.md index cdb9350..e39f178 100644 --- a/docs/README.md +++ b/docs/README.md @@ -18,6 +18,7 @@ A composable, tree-shakeable toolkit for building type-safe Cloudflare Workers. - [Authentication](./guides/authentication.md) -- JWT, sessions, password hashing, middleware - [Rate Limiting](./guides/rate-limiting.md) -- Fixed window, sliding window, token bucket, composite - [AI Integration](./guides/ai-integration.md) -- Workers AI, AI Gateway, streaming, fallbacks, cost tracking +- [Chat React Debugging](../packages/chat-react/README.md) -- Inspect browser-side chat WebSocket frames with a headless hook - [Testing](./guides/testing.md) -- Mock bindings, integration patterns, vitest setup - [Error Handling](./guides/error-handling.md) -- Structured errors, retry logic, HTTP mapping - [Queues and Crons](./guides/queues-and-crons.md) -- Queue processing, cron scheduling, dead letter queues diff --git a/docs/api-reference.md b/docs/api-reference.md index 9ee4005..d150cdc 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -93,6 +93,17 @@ Quick reference of all exported functions, types, and classes per package. | `durableObject()` | function | DurableObjectNamespace binding validator | | `service()` | function | Service binding validator | +## `@workkit/chat-react` + +| Export | Kind | Description | +|--------|------|-------------| +| `useChatDebugFrames(socket, options?)` | hook | Capture inbound `message` events and outbound `send()` calls from a chat WebSocket-compatible transport | +| `ChatDebugSocket` | type | Minimal socket contract consumed by the debug hook | +| `UseChatDebugFramesOptions` | type | Configure `bufferSize` and optional message-type filtering | +| `UseChatDebugFramesResult` | type | Read captured frames, clear the buffer, and inspect connection state | +| `ChatDebugConnectionState` | type | Normalized socket state: `connecting`, `open`, `closing`, or `closed` | +| `DebugFrame` | type | Re-exported frame payload from `@workkit/chat` | + ## `@workkit/d1` | Export | Kind | Description | diff --git a/docs/getting-started.md b/docs/getting-started.md index b3a2f96..d6856ad 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -15,6 +15,9 @@ bun add @workkit/ai @workkit/ai-gateway bun add @workkit/do @workkit/r2 @workkit/crypto bun add @workkit/logger +# Chat debugging hooks +bun add @workkit/chat @workkit/chat-react + # Testing utilities bun add -d @workkit/testing ``` diff --git a/packages/chat-react/src/index.ts b/packages/chat-react/src/index.ts index 8485345..6a23f8b 100644 --- a/packages/chat-react/src/index.ts +++ b/packages/chat-react/src/index.ts @@ -38,6 +38,15 @@ const VALID_TYPES = new Set([ ]); const VALID_ROLES = new Set(["user", "assistant", "system"]); const textEncoder = new TextEncoder(); +type SendData = Parameters[0]; +type SendRecorder = (data: SendData) => void; + +interface SendPatch { + originalSend: ChatDebugSocket["send"]; + recorders: Set; +} + +const sendPatches = new WeakMap(); function connectionStateFromReadyState(readyState: number): ChatDebugConnectionState { switch (readyState) { @@ -144,6 +153,43 @@ function appendFrame( return next.length > bufferSize ? next.slice(next.length - bufferSize) : next; } +function installSendRecorder(socket: ChatDebugSocket, recorder: SendRecorder): () => void { + let patch = sendPatches.get(socket); + + if (!patch) { + const originalSend = socket.send; + patch = { + originalSend, + recorders: new Set(), + }; + sendPatches.set(socket, patch); + + socket.send = ((data: SendData) => { + const activePatch = sendPatches.get(socket); + if (!activePatch) { + return originalSend.call(socket, data); + } + for (const activeRecorder of [...activePatch.recorders]) { + activeRecorder(data); + } + return activePatch.originalSend.call(socket, data); + }) as ChatDebugSocket["send"]; + } + + patch.recorders.add(recorder); + + return () => { + const activePatch = sendPatches.get(socket); + if (!activePatch) return; + + activePatch.recorders.delete(recorder); + if (activePatch.recorders.size === 0) { + socket.send = activePatch.originalSend; + sendPatches.delete(socket); + } + }; +} + export function useChatDebugFrames( socket: ChatDebugSocket | null | undefined, options: UseChatDebugFramesOptions = {}, @@ -157,7 +203,6 @@ export function useChatDebugFrames( const [connectionState, setConnectionState] = useState(() => socket ? connectionStateFromReadyState(socket.readyState) : "closed", ); - const originalSendRef = useRef(undefined); const nextFrameIdRef = useRef(0); const recordFrame = useCallback( @@ -191,23 +236,14 @@ export function useChatDebugFrames( 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[0]) => { - recordFrame("out", data); - return originalSend(data); - }) as ChatDebugSocket["send"]; + const uninstallSendRecorder = installSendRecorder(socket, (data) => recordFrame("out", data)); 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; + uninstallSendRecorder(); }; }, [recordFrame, socket]); diff --git a/packages/chat-react/tests/use-chat-debug-frames.test.ts b/packages/chat-react/tests/use-chat-debug-frames.test.ts index 036daec..0c6e7a9 100644 --- a/packages/chat-react/tests/use-chat-debug-frames.test.ts +++ b/packages/chat-react/tests/use-chat-debug-frames.test.ts @@ -55,6 +55,65 @@ function mountHook(socket: MockSocket, options?: Parameters { + renderer = create(React.createElement(TestComponent)); + }); + + return { + get first() { + if (!first) throw new Error("first hook did not render"); + return first; + }, + get second() { + if (!second) throw new Error("second hook did not render"); + return second; + }, + unmountFirst: () => { + act(() => { + showFirst = false; + renderer.update(React.createElement(TestComponent)); + }); + }, + unmountSecond: () => { + act(() => { + showSecond = false; + renderer.update(React.createElement(TestComponent)); + }); + }, + unmount: () => { + act(() => { + renderer.unmount(); + }); + }, + }; +} + describe("useChatDebugFrames", () => { it("captures inbound and outbound chat frames newest-last", () => { const socket = new MockSocket(); @@ -160,6 +219,33 @@ describe("useChatDebugFrames", () => { secondHook.unmount(); }); + it("keeps same-socket send capture isolated across multiple hook instances", () => { + const socket = new MockSocket(); + const originalSend = socket.send; + const hooks = mountTwoHooks(socket); + + act(() => { + socket.send(JSON.stringify({ id: "out-1", type: "message", role: "user", content: "one" })); + }); + + expect(hooks.first.frames.map((frame) => frame.message?.id)).toEqual(["out-1"]); + expect(hooks.second.frames.map((frame) => frame.message?.id)).toEqual(["out-1"]); + + hooks.unmountFirst(); + + act(() => { + socket.send(JSON.stringify({ id: "out-2", type: "message", role: "user", content: "two" })); + }); + + expect(hooks.second.frames.map((frame) => frame.message?.id)).toEqual(["out-1", "out-2"]); + expect(socket.send).not.toBe(originalSend); + + hooks.unmountSecond(); + + expect(socket.send).toBe(originalSend); + hooks.unmount(); + }); + it("falls back to user when a wire message has an invalid role", () => { const socket = new MockSocket(); const hook = mountHook(socket); From 20e3b9cd9dfac3c83505370a9291f5b11beb7a36 Mon Sep 17 00:00:00 2001 From: LuminaFlow Date: Sun, 24 May 2026 06:06:21 +0200 Subject: [PATCH 5/6] docs: add chat-react guide --- .../src/content/docs/guides/chat-react.md | 113 ++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 apps/docs/src/content/docs/guides/chat-react.md diff --git a/apps/docs/src/content/docs/guides/chat-react.md b/apps/docs/src/content/docs/guides/chat-react.md new file mode 100644 index 0000000..5f941e0 --- /dev/null +++ b/apps/docs/src/content/docs/guides/chat-react.md @@ -0,0 +1,113 @@ +--- +title: "Chat React Debugging" +--- + +# Chat React Debugging + +`@workkit/chat-react` provides headless React hooks for inspecting browser-side `@workkit/chat` WebSocket traffic. Use it to build local debug panels, QA overlays, or support-only diagnostics without coupling your app to a styled component. + +The first hook, `useChatDebugFrames`, captures inbound `message` events and outbound `socket.send()` calls while the hook is mounted. It keeps a bounded in-memory frame buffer, parses valid `ChatMessage` payloads, records malformed payloads as `unknown`, and exposes the current socket connection state. + +## Install + +```bash +bun add @workkit/chat @workkit/chat-react react +``` + +`@workkit/chat` and `react` are peer dependencies. Keep them installed in the app that renders the hook. + +## Basic usage + +```tsx +import { useChatDebugFrames } from "@workkit/chat-react"; + +export function ChatDebugPanel({ socket }: { socket: WebSocket | null }) { + const { frames, clear, connectionState } = useChatDebugFrames(socket, { + bufferSize: 100, + include: ["message", "error", "unknown"], + }); + + return ( + + ); +} +``` + +The hook is UI-agnostic: it returns data only. Render the output into your own development panel, drawer, command palette, or test harness. + +## Frame shape + +The hook re-exports `DebugFrame` from `@workkit/chat`: + +```ts +type DebugFrame = { + id: string; + direction: "in" | "out"; + type: ChatMessageType | "unknown"; + timestamp: number; + bytes: number; + data: unknown; + message?: ChatMessage; + error?: Error; +}; +``` + +Valid `@workkit/chat` envelopes populate `message`. Malformed JSON, invalid message types, and non-string payloads are retained as `unknown` frames so diagnostics can show what the browser actually sent or received. + +## Options + +```ts +type UseChatDebugFramesOptions = { + bufferSize?: number; + include?: readonly (ChatMessageType | "unknown")[]; +}; +``` + +| Option | Default | Behavior | +|---|---:|---| +| `bufferSize` | `100` | Maximum frames retained in memory. Invalid values fall back to the default. | +| `include` | all frame types | Optional allowlist for `message`, `typing`, `error`, `tool_call`, `tool_result`, `system`, or `unknown`. | + +`frames` are stored oldest-to-newest. When the buffer exceeds `bufferSize`, the oldest frames are dropped. + +## Multiple panels on one socket + +Multiple `useChatDebugFrames` instances can observe the same socket. The hook installs one shared `send()` wrapper per socket and keeps each hook's recorder isolated, so unmounting one panel does not break another panel or leave `send()` patched after the last panel unmounts. + +## Connection state + +`connectionState` maps the socket `readyState` into a stable union: + +| WebSocket state | Hook value | +|---:|---| +| `0` | `connecting` | +| `1` | `open` | +| `2` | `closing` | +| any other value | `closed` | + +The value updates on `open`, `close`, and `error` events. If `socket` is `null` or `undefined`, the hook reports `closed`. + +## Production use + +The hook does not send diagnostics anywhere by itself. If you expose debug frames in production, gate the rendered panel behind your own authorization checks and avoid showing raw payloads to users who should not see conversation data. + +## See also + +- [Real-time Chat](/workkit/guides/chat/) - server transport, message envelope, and Durable Object sessions. +- [Realtime](/workkit/guides/realtime/) - SSE broadcast channels for live dashboards and run timelines. +- [Testing](/workkit/guides/testing/) - test utilities and validation patterns for Workkit packages. From c185255e54e4b3776d3cfb8531306811e8b0d647 Mon Sep 17 00:00:00 2001 From: Christian Scherkl Date: Sun, 24 May 2026 07:05:19 +0200 Subject: [PATCH 6/6] fix chat debug send failure recording --- packages/chat-react/src/index.ts | 3 ++- .../tests/use-chat-debug-frames.test.ts | 19 +++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/packages/chat-react/src/index.ts b/packages/chat-react/src/index.ts index 6a23f8b..291b20b 100644 --- a/packages/chat-react/src/index.ts +++ b/packages/chat-react/src/index.ts @@ -169,10 +169,11 @@ function installSendRecorder(socket: ChatDebugSocket, recorder: SendRecorder): ( if (!activePatch) { return originalSend.call(socket, data); } + const result = activePatch.originalSend.call(socket, data); for (const activeRecorder of [...activePatch.recorders]) { activeRecorder(data); } - return activePatch.originalSend.call(socket, data); + return result; }) as ChatDebugSocket["send"]; } diff --git a/packages/chat-react/tests/use-chat-debug-frames.test.ts b/packages/chat-react/tests/use-chat-debug-frames.test.ts index 0c6e7a9..d89b462 100644 --- a/packages/chat-react/tests/use-chat-debug-frames.test.ts +++ b/packages/chat-react/tests/use-chat-debug-frames.test.ts @@ -6,9 +6,11 @@ import { type ChatDebugSocket, type UseChatDebugFramesResult, useChatDebugFrames class MockSocket implements ChatDebugSocket { readyState = 0; sent: unknown[] = []; + sendError: Error | undefined; private listeners = new Map void>>(); send(data: Parameters[0]): void { + if (this.sendError) throw this.sendError; this.sent.push(data); } @@ -246,6 +248,23 @@ describe("useChatDebugFrames", () => { hooks.unmount(); }); + it("does not record outbound frames when socket.send throws", () => { + const socket = new MockSocket(); + const hook = mountHook(socket); + socket.sendError = new Error("send failed"); + + act(() => { + expect(() => + socket.send(JSON.stringify({ id: "out-1", type: "message", role: "user", content: "one" })), + ).toThrow("send failed"); + }); + + expect(hook.result.frames).toHaveLength(0); + expect(socket.sent).toHaveLength(0); + + hook.unmount(); + }); + it("falls back to user when a wire message has an invalid role", () => { const socket = new MockSocket(); const hook = mountHook(socket);