Skip to content

Commit 6cbd387

Browse files
committed
feat(kkrpc): add WebSocketLike interface for adapter compatibility
Add WebSocketLike interface to support multiple WebSocket implementations including DOM WebSocket, ws library, and Bun native WebSocket. This allows WebSocketServerIO to work with different WebSocket backends without type casting or adapter code. - Add WebSocketLike interface in websocket adapter - Add Bun native WebSocket server example - Configure strict TypeScript mode for streaming demo - Update documentation with Bun server option
1 parent d1403a8 commit 6cbd387

File tree

11 files changed

+301
-90
lines changed

11 files changed

+301
-90
lines changed

.journal/2026-02-07.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,3 +128,36 @@ The initial response carries `{ __stream: true }` to signal the consumer to crea
128128
- **Bidirectional streaming** — current design is server→client only. Client→server streaming would need a `sendStream()` API on the proxy
129129
- **Stream timeout** — currently streams can run indefinitely. A per-stream idle timeout could auto-cancel stalled streams
130130
- **PLANNING.md removed** — all 4 major roadmap items (middleware, streaming, timeout, type safety) are complete
131+
132+
## 01:47 — WebSocketLike interface + Bun native WebSocket support
133+
134+
### Core Decision/Topic
135+
136+
Fixed a type issue where `WebSocketServerIO` required a DOM `WebSocket` but the `ws` library's `WebSocket` type didn't fully match (missing `dispatchEvent`, `URL`). Added a structural `WebSocketLike` interface and created a Bun-native WebSocket server example.
137+
138+
### Options Considered
139+
140+
- **Cast in user code** (`ws as unknown as WebSocket`) — works but ugly, leaks implementation detail to users
141+
- **Structural interface** (`WebSocketLike`) — defines only the members actually used (`onmessage`, `onerror`, `send`, `close`). Works with DOM WebSocket, `ws` library, and Bun's `ServerWebSocket` via a thin wrapper. Chosen approach.
142+
143+
### Final Decision & Rationale
144+
145+
**Structural typing over nominal:** The adapter only needs 4 operations. By defining `WebSocketLike` with those exact members, we accept anything that quacks like a WebSocket — no casts needed.
146+
147+
**Bun's callback-based API:** Bun uses `websocket: { message(ws, msg) }` callbacks rather than event setters. We bridge this with a wrapper stored in a `Map<ServerWebSocket, WebSocketLike>` — Bun's callbacks route to our wrapper's `onmessage` handler.
148+
149+
### Key Changes Made
150+
151+
| File | Change |
152+
|---|---|
153+
| `packages/kkrpc/src/adapters/websocket.ts` | Added exported `WebSocketLike` interface; changed `WebSocketServerIO` constructor to accept `WebSocketLike` instead of DOM `WebSocket` |
154+
| `examples/streaming-middleware-demo/server.ts` | Removed cast; now passes `ws` directly |
155+
| `examples/streaming-middleware-demo/server-bun.ts` | New file — Bun native WebSocket server using `Bun.serve()` with WebSocket upgrade |
156+
| `examples/streaming-middleware-demo/package.json` | Added `server:bun` script; added `typescript` devDependency |
157+
| `examples/streaming-middleware-demo/tsconfig.json` | New file — strict TypeScript config with `noUncheckedIndexedAccess` |
158+
159+
### Future Considerations
160+
161+
- **First-party Bun adapter** — could add `BunWebSocketIO` to kkrpc core that wraps Bun's native API directly, avoiding the user-space wrapper
162+
- **Deno WebSocket** — similar pattern could work for Deno's native WebSocket server
163+
- **WebSocketLike in client** — currently only server uses this; client still creates `new WebSocket()`. Could extend pattern if needed for non-DOM environments

bun.lock

Lines changed: 46 additions & 75 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

deno.lock

Lines changed: 9 additions & 8 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

examples/streaming-middleware-demo/README.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ Demonstrates kkrpc's AsyncIterable streaming and interceptor middleware over Web
2121

2222
## Run
2323

24+
### Option 1: `ws` library (works with Node.js, Bun, Deno)
25+
2426
```bash
2527
# Terminal 1 — start the server
2628
bun run server.ts
@@ -29,6 +31,18 @@ bun run server.ts
2931
bun run client.ts
3032
```
3133

34+
### Option 2: Bun native WebSocket (Bun only)
35+
36+
```bash
37+
# Terminal 1 — start the Bun native server
38+
bun run server-bun.ts
39+
40+
# Terminal 2 — run the client (same client works with both servers)
41+
bun run client.ts
42+
```
43+
44+
Both servers are interchangeable — the client connects to `ws://localhost:3100` and works the same way.
45+
3246
## How middleware works in kkrpc
3347

3448
Interceptors follow the **onion model** (like Koa, tRPC). Each interceptor wraps the next, and the innermost layer is the actual handler.

examples/streaming-middleware-demo/api.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,8 +61,8 @@ export function createApi(session: { authenticated: boolean; username: string })
6161
let seq = 0
6262
while (true) {
6363
await sleep(300 + Math.random() * 700)
64-
const level = levels[Math.floor(Math.random() * levels.length)]
65-
const message = messages[Math.floor(Math.random() * messages.length)]
64+
const level = levels[Math.floor(Math.random() * levels.length)]!
65+
const message = messages[Math.floor(Math.random() * messages.length)]!
6666
yield {
6767
timestamp: new Date().toISOString(),
6868
level,

examples/streaming-middleware-demo/package.json

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,16 @@
33
"private": true,
44
"scripts": {
55
"server": "bun run server.ts",
6-
"client": "bun run client.ts"
6+
"server:bun": "bun run server-bun.ts",
7+
"client": "bun run client.ts",
8+
"check-types": "tsc --noEmit"
79
},
810
"dependencies": {
911
"kkrpc": "workspace:*",
1012
"ws": "^8.18.0"
1113
},
1214
"devDependencies": {
13-
"@types/bun": "latest"
15+
"@types/bun": "latest",
16+
"typescript": "^5.0.0"
1417
}
1518
}
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
/**
2+
* Streaming + Middleware demo — Bun native WebSocket server.
3+
*
4+
* Demonstrates four interceptor patterns:
5+
* 1. Logging — logs every RPC call with method name and args
6+
* 2. Timing — measures and logs execution time per call
7+
* 3. Auth — blocks protected methods unless the session is authenticated
8+
* 4. Rate limit — limits calls per second, rejects excess with an error
9+
*
10+
* Run with: bun run server-bun.ts
11+
* Then in another terminal: bun run client.ts
12+
*/
13+
import { RPCChannel, WebSocketServerIO, type RPCInterceptor, type WebSocketLike } from "kkrpc"
14+
import { createApi, type StreamingMiddlewareAPI } from "./api.ts"
15+
16+
const PORT = 3100
17+
18+
// Map to track Bun ServerWebSocket -> our wrapper
19+
const connections = new Map<any, WebSocketLike>()
20+
21+
// ─── Interceptor factories ───────────────────────────────────────────────────
22+
23+
const logger: RPCInterceptor = async (ctx, next) => {
24+
const argsStr = ctx.args.map((a) => (typeof a === "object" ? JSON.stringify(a) : String(a))).join(", ")
25+
console.log(` [log] ${ctx.method}(${argsStr})`)
26+
return next()
27+
}
28+
29+
const timing: RPCInterceptor = async (ctx, next) => {
30+
const start = performance.now()
31+
const result = await next()
32+
const elapsed = (performance.now() - start).toFixed(1)
33+
console.log(` [time] ${ctx.method}${elapsed}ms`)
34+
return result
35+
}
36+
37+
function createAuthInterceptor(session: { authenticated: boolean; username: string }): RPCInterceptor {
38+
const protectedMethods = new Set(["getSecretData"])
39+
return async (ctx, next) => {
40+
if (protectedMethods.has(ctx.method) && !session.authenticated) {
41+
throw new Error(`Unauthorized: '${ctx.method}' requires authentication. Call login() first.`)
42+
}
43+
return next()
44+
}
45+
}
46+
47+
function createRateLimiter(max: number, windowMs: number = 1000): RPCInterceptor {
48+
const calls: number[] = []
49+
return async (ctx, next) => {
50+
const now = Date.now()
51+
while (calls.length > 0 && calls[0]! <= now - windowMs) {
52+
calls.shift()
53+
}
54+
if (calls.length >= max) {
55+
throw new Error(`Rate limit exceeded: max ${max} calls per ${windowMs}ms. Try again shortly.`)
56+
}
57+
calls.push(now)
58+
return next()
59+
}
60+
}
61+
62+
// ─── Bun WebSocket wrapper ───────────────────────────────────────────────────
63+
64+
/**
65+
* Creates a WebSocketLike wrapper around Bun's ServerWebSocket.
66+
* Bun uses a different pattern (callback-based) vs DOM WebSocket (event setters).
67+
* This wrapper bridges the two patterns.
68+
*/
69+
function createBunWebSocketLike(bunWs: any): WebSocketLike {
70+
return {
71+
onmessage: null,
72+
onerror: null,
73+
send(data: string) {
74+
bunWs.send(data)
75+
},
76+
close() {
77+
bunWs.close()
78+
}
79+
}
80+
}
81+
82+
// ─── Bun server setup ────────────────────────────────────────────────────────
83+
84+
console.log(`[server] Streaming + Middleware demo (Bun native) listening on ws://localhost:${PORT}`)
85+
console.log(`[server] Interceptors: logger → timing → auth → rateLimiter`)
86+
87+
Bun.serve({
88+
port: PORT,
89+
fetch(req, server) {
90+
// Upgrade HTTP request to WebSocket
91+
if (server.upgrade(req)) {
92+
return // Upgraded successfully
93+
}
94+
return new Response("WebSocket upgrade failed", { status: 400 })
95+
},
96+
websocket: {
97+
open(bunWs) {
98+
console.log("[server] Client connected")
99+
100+
// Create wrapper that implements WebSocketLike
101+
const wrapper = createBunWebSocketLike(bunWs)
102+
connections.set(bunWs, wrapper)
103+
104+
// Each connection gets its own session and interceptors
105+
const session = { authenticated: false, username: "" }
106+
const api = createApi(session)
107+
const auth = createAuthInterceptor(session)
108+
const rateLimiter = createRateLimiter(5)
109+
110+
new RPCChannel<StreamingMiddlewareAPI, {}>(new WebSocketServerIO(wrapper), {
111+
expose: api,
112+
interceptors: [logger, timing, auth, rateLimiter]
113+
})
114+
},
115+
message(bunWs, message) {
116+
// Route Bun message to our wrapper's onmessage handler
117+
const wrapper = connections.get(bunWs)
118+
if (wrapper?.onmessage) {
119+
wrapper.onmessage({ data: message })
120+
}
121+
},
122+
close(bunWs, code, reason) {
123+
const wrapper = connections.get(bunWs)
124+
if (wrapper) {
125+
// Trigger any cleanup
126+
wrapper.onmessage = null
127+
wrapper.onerror = null
128+
connections.delete(bunWs)
129+
}
130+
console.log(`[server] Client disconnected`)
131+
}
132+
}
133+
})

examples/streaming-middleware-demo/server.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ function createRateLimiter(max: number, windowMs: number = 1000): RPCInterceptor
6969
return async (ctx, next) => {
7070
const now = Date.now()
7171
// Evict timestamps outside the window
72-
while (calls.length > 0 && calls[0] <= now - windowMs) {
72+
while (calls.length > 0 && calls[0]! <= now - windowMs) {
7373
calls.shift()
7474
}
7575
if (calls.length >= max) {
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
{
2+
"compilerOptions": {
3+
// Environment setup & latest features
4+
"lib": ["ESNext"],
5+
"target": "ESNext",
6+
"module": "Preserve",
7+
"moduleDetection": "force",
8+
"jsx": "react-jsx",
9+
"allowJs": true,
10+
11+
// Bundler mode
12+
"moduleResolution": "bundler",
13+
"allowImportingTsExtensions": true,
14+
"verbatimModuleSyntax": true,
15+
"noEmit": true,
16+
17+
// Best practices
18+
"strict": true,
19+
"skipLibCheck": true,
20+
"noFallthroughCasesInSwitch": true,
21+
"noUncheckedIndexedAccess": true,
22+
"noImplicitOverride": true,
23+
24+
// Some stricter flags (disabled by default)
25+
"noUnusedLocals": false,
26+
"noUnusedParameters": false,
27+
"noPropertyAccessFromIndexSignature": false
28+
}
29+
}

packages/kkrpc/src/adapters/websocket.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,30 @@ export class WebSocketClientIO implements IoInterface {
113113
}
114114
}
115115

116+
/**
117+
* Minimal WebSocket interface accepted by WebSocketServerIO.
118+
*
119+
* This is a structural type covering only the members actually used,
120+
* so it works with both the DOM WebSocket and the `ws` library's WebSocket
121+
* without requiring a cast.
122+
*/
123+
/**
124+
* Minimal WebSocket interface accepted by WebSocketServerIO.
125+
*
126+
* This is a structural type covering only the members actually used,
127+
* so it works with both the DOM WebSocket and the `ws` library's WebSocket
128+
* without requiring a cast.
129+
*
130+
* NOTE: The handler types use `any` for event parameters to allow assignment
131+
* from both DOM WebSocket (MessageEvent) and ws library (any) types.
132+
*/
133+
export interface WebSocketLike {
134+
onmessage: ((event: any) => void) | null
135+
onerror: ((event: any) => void) | null
136+
send(data: string): void
137+
close(): void
138+
}
139+
116140
/**
117141
* WebSocket Server implementation of IoInterface
118142
*/
@@ -127,7 +151,7 @@ export class WebSocketServerIO implements IoInterface {
127151
transfer: false
128152
}
129153

130-
constructor(private ws: WebSocket) {
154+
constructor(private ws: WebSocketLike) {
131155
this.ws.onmessage = (event) => {
132156
let message = event.data
133157
if (typeof message === "object" && message !== null && "toString" in message) {

0 commit comments

Comments
 (0)