Skip to content

Commit 7b0602e

Browse files
jmorrellclaude
andauthored
Add newWorkersWebSocketRpcSession for bidirectional server-side WebSockets + typescript fixes (#8)
- Add newWorkersWebSocketRpcSession — new export for bidirectional WebSocket RPC in Workers. Returns { response, remote } so server-side code can call back to the client with a typed proxy. newWorkersWebSocketRpcResponse is now a thin wrapper that discards the remote. - Unify tsconfig — single tsconfig.json covers both library and test files so the editor/language server picks up Cloudflare Workers types. Add npm run typecheck script. - Eliminate as any from production code — replace with Record<string, unknown> casts in type guards. Reduce test as any from 20 to 5 justified instances. Before/after: bidirectional worker fixture ```ts // Before (20 lines, manual plumbing, untyped) if (request.headers.get("Upgrade") !== "websocket") { return new Response("Expected WebSocket upgrade", { status: 400 }); } const pair = new WebSocketPair(); const [client, server] = Object.values(pair); server.accept(); const transport = createWebSocketTransport(server); const biService = { async addWithClientMultiplier(a: number, b: number): Promise<number> { const multiplier: number = await (session.remote as any).getMultiplier(); return (a + b) * multiplier; }, }; const session = new RpcSession(transport, biService, { role: "acceptor" }); return new Response(null, { status: 101, webSocket: client }); // After (8 lines, fully typed) const biService = { async addWithClientMultiplier(a: number, b: number): Promise<number> { const multiplier = await remote.getMultiplier(); return (a + b) * multiplier; }, }; const { response, remote } = newWorkersWebSocketRpcSession<ClientService>(request, biService); return response; ``` --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 490689d commit 7b0602e

File tree

13 files changed

+154
-102
lines changed

13 files changed

+154
-102
lines changed

AGENTS.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ Last verified: 2026-02-15
2929
- `src/core.ts` - Shared types, error classes, and transport-agnostic JSON-RPC 2.0 engine (wire format types, type guards, request/response builders, RpcProtocolError, RPC processor)
3030
- `src/http-batch.ts` - HTTP batch transport: newHttpBatchRpcResponse (server) + newHttpBatchRpcSession (client with auto-batching)
3131
- `src/session.ts` - Bidirectional RPC over message transports (defines session-specific types: RpcMessageTransport, RpcSessionOptions, RpcSession)
32-
- `src/websocket.ts` - WebSocket transport: newWorkersWebSocketRpcResponse (server) + newWebSocketRpcSession (client with Disposable proxy)
32+
- `src/websocket.ts` - WebSocket transport: newWorkersWebSocketRpcResponse (server, fire-and-forget) + newWorkersWebSocketRpcSession (server, bidirectional with typed remote proxy) + newWebSocketRpcSession (client with Disposable proxy)
3333
- `src/__tests__/` - Test files
3434
- `src/__tests__/fixtures/worker.ts` - Test worker entry point for Workers runtime tests
3535
- `docs/` - Design documents and implementation plans
@@ -39,7 +39,7 @@ Last verified: 2026-02-15
3939

4040
Single public entry point via index.ts:
4141

42-
- `@jmorrell/jsonrpc` - newHttpBatchRpcResponse, newHttpBatchRpcSession, newWorkersWebSocketRpcResponse, newWebSocketRpcSession, newWorkersRpcResponse, RpcError, RpcProtocolError
42+
- `@jmorrell/jsonrpc` - newHttpBatchRpcResponse, newHttpBatchRpcSession, newWorkersWebSocketRpcResponse, newWorkersWebSocketRpcSession, newWebSocketRpcSession, newWorkersRpcResponse, RpcError, RpcProtocolError
4343

4444
## Conventions
4545

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
},
1414
"scripts": {
1515
"build": "tsc",
16+
"typecheck": "tsc --noEmit",
1617
"fmt": "oxfmt",
1718
"fmt:check": "oxfmt --check",
1819
"lint": "oxlint",

src/__tests__/core.test.ts

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
processRpc,
88
RpcProtocolError,
99
} from "../core.js";
10+
import type { JsonRpcResponse } from "../core.js";
1011

1112
// --- isJsonRpcResponse ---
1213

@@ -202,18 +203,15 @@ const service = {
202203
throw new Error("Something went wrong");
203204
},
204205
throwsWithCode() {
205-
const err = new Error("Custom error");
206-
(err as any).code = 42;
207-
(err as any).data = { detail: "extra info" };
208-
throw err;
206+
throw Object.assign(new Error("Custom error"), { code: 42, data: { detail: "extra info" } });
209207
},
210208
returnsUndefined() {
211209
return undefined;
212210
},
213211
async asyncAdd(a: number, b: number) {
214212
return a + b;
215213
},
216-
notAFunction: 42 as any,
214+
notAFunction: 42 as any, // deliberate type violation to test non-function method error handling
217215
};
218216

219217
describe("processRpc single requests", () => {
@@ -508,13 +506,14 @@ describe("processRpc batch", () => {
508506
service,
509507
);
510508
expect(result).toHaveLength(3);
511-
expect((result as any)[0]).toEqual({ jsonrpc: "2.0", id: 1, result: 3 });
512-
expect((result as any)[1]).toMatchObject({
509+
const arr = result as JsonRpcResponse[];
510+
expect(arr[0]).toEqual({ jsonrpc: "2.0", id: 1, result: 3 });
511+
expect(arr[1]).toMatchObject({
513512
jsonrpc: "2.0",
514513
id: 2,
515514
error: { code: -32000, message: "Something went wrong" },
516515
});
517-
expect((result as any)[2]).toEqual({ jsonrpc: "2.0", id: 3, result: 2 });
516+
expect(arr[2]).toEqual({ jsonrpc: "2.0", id: 3, result: 2 });
518517
});
519518

520519
it("executes batch items concurrently", async () => {

src/__tests__/fixtures/worker.ts

Lines changed: 9 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
// Test worker for Workers runtime tests
2-
import { newWorkersRpcResponse } from "../../index.js";
3-
import { RpcSession } from "../../session.js";
4-
import { createWebSocketTransport } from "../../websocket.js";
2+
import { newWorkersRpcResponse, newWorkersWebSocketRpcSession } from "../../index.js";
53

64
// Test service shared between HTTP and WebSocket transports
75
const service = {
@@ -16,32 +14,26 @@ const service = {
1614
},
1715
};
1816

17+
type ClientService = { getMultiplier(): number };
18+
1919
export default {
2020
async fetch(request: Request): Promise<Response> {
2121
const url = new URL(request.url);
2222

2323
// Bidirectional test endpoint: server method calls back to client
2424
if (url.pathname === "/bidirectional") {
25-
if (request.headers.get("Upgrade") !== "websocket") {
26-
return new Response("Expected WebSocket upgrade", { status: 400 });
27-
}
28-
29-
const pair = new WebSocketPair();
30-
const [client, server] = Object.values(pair);
31-
server.accept();
32-
33-
const transport = createWebSocketTransport(server);
34-
3525
const biService = {
3626
async addWithClientMultiplier(a: number, b: number): Promise<number> {
37-
const multiplier: number = await (session.remote as any).getMultiplier();
27+
const multiplier = await remote.getMultiplier();
3828
return (a + b) * multiplier;
3929
},
4030
};
4131

42-
const session = new RpcSession(transport, biService, { role: "acceptor" });
43-
44-
return new Response(null, { status: 101, webSocket: client });
32+
const { response, remote } = newWorkersWebSocketRpcSession<ClientService, typeof biService>(
33+
request,
34+
biService,
35+
);
36+
return response;
4537
}
4638

4739
return newWorkersRpcResponse(request, service);

src/__tests__/property.test.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ const service = {
1616

1717
function isValidResponse(res: unknown): res is JsonRpcResponse {
1818
if (typeof res !== "object" || res === null) return false;
19-
const r = res as any;
19+
const r = res as Record<string, unknown>;
2020
if (r.jsonrpc !== "2.0") return false;
2121
if (!("id" in r)) return false;
2222
if ("result" in r && !("error" in r)) return true;
@@ -25,8 +25,8 @@ function isValidResponse(res: unknown): res is JsonRpcResponse {
2525
return (
2626
typeof e === "object" &&
2727
e !== null &&
28-
typeof e.code === "number" &&
29-
typeof e.message === "string"
28+
typeof (e as Record<string, unknown>).code === "number" &&
29+
typeof (e as Record<string, unknown>).message === "string"
3030
);
3131
}
3232
return false;
@@ -67,7 +67,7 @@ describe("property-based tests", () => {
6767
async (batch) => {
6868
const result = await processRpc(batch, service);
6969
expect(Array.isArray(result)).toBe(true);
70-
expect((result as any[]).length).toBe(batch.length);
70+
expect((result as JsonRpcResponse[]).length).toBe(batch.length);
7171
},
7272
),
7373
{ numRuns: 200 },
@@ -109,7 +109,7 @@ describe("property-based tests", () => {
109109
}),
110110
async (request) => {
111111
const result = await processRpc(request, service);
112-
if (result && "error" in (result as any)) {
112+
if (result && !Array.isArray(result) && "error" in result) {
113113
const err = (result as JsonRpcErrorResponse).error;
114114
expect(typeof err.code).toBe("number");
115115
expect(typeof err.message).toBe("string");

src/__tests__/session.test.ts

Lines changed: 8 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -425,10 +425,10 @@ describe("Remote method that throws", () => {
425425

426426
const acceptorService: ThrowService = {
427427
throwWithData(): Promise<never> {
428-
const err = new Error("Something went wrong");
429-
(err as any).code = -32000;
430-
(err as any).data = { details: "extra info" };
431-
throw err;
428+
throw Object.assign(new Error("Something went wrong"), {
429+
code: -32000,
430+
data: { details: "extra info" },
431+
});
432432
},
433433
};
434434

@@ -907,8 +907,8 @@ describe("session lifecycle", () => {
907907
// Verify error was logged via onError (send failed because transport is closed)
908908
expect(onErrorCalled).toBe(true);
909909
expect(errorLogged).toBeInstanceOf(RpcProtocolError);
910-
expect((errorLogged as RpcProtocolError).code).toBe("SEND_FAILED");
911-
expect((errorLogged as RpcProtocolError).cause).toBeInstanceOf(Error);
910+
expect((errorLogged as unknown as RpcProtocolError).code).toBe("SEND_FAILED");
911+
expect((errorLogged as unknown as RpcProtocolError).cause).toBeInstanceOf(Error);
912912

913913
sessionA.close();
914914
sessionB.close();
@@ -1785,17 +1785,13 @@ describe("Bidirectional RPC", () => {
17851785

17861786
const serviceA: ServiceA = {
17871787
failA() {
1788-
const err = new Error("Error from A");
1789-
(err as any).code = -32001;
1790-
throw err;
1788+
throw Object.assign(new Error("Error from A"), { code: -32001 });
17911789
},
17921790
};
17931791

17941792
const serviceB: ServiceB = {
17951793
failB() {
1796-
const err = new Error("Error from B");
1797-
(err as any).code = -32002;
1798-
throw err;
1794+
throw Object.assign(new Error("Error from B"), { code: -32002 });
17991795
},
18001796
};
18011797

src/__tests__/spec.test.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { describe, it, expect } from "vitest";
22
import { processRpc } from "../core.js";
3+
import type { JsonRpcResponse } from "../core.js";
34
import { newHttpBatchRpcResponse } from "../http-batch.js";
45

56
// Service for spec compliance tests
@@ -34,7 +35,7 @@ describe("spec compliance: error codes", () => {
3435
body: '{"jsonrpc": "2.0", "method": "foobar, "id": "1"}',
3536
});
3637
const res = await newHttpBatchRpcResponse(req, service);
37-
const json = await res.json();
38+
const json = (await res.json()) as any; // Response.json() returns unknown
3839
expect(json.error.code).toBe(-32700);
3940
expect(json.error.message).toBe("Parse error");
4041
expect(json.id).toBeNull();
@@ -194,12 +195,12 @@ describe("spec examples (adapted for by-position params only)", () => {
194195
service,
195196
);
196197
expect(Array.isArray(result)).toBe(true);
197-
const arr = result as any[];
198+
const arr = result as JsonRpcResponse[];
198199
// Should have 5 responses (notification produces none)
199200
expect(arr).toHaveLength(5);
200201

201202
// sum(1,2,4) = 7
202-
expect(arr.find((r: any) => r.id === "1")).toEqual({
203+
expect(arr.find((r) => r.id === "1")).toEqual({
203204
jsonrpc: "2.0",
204205
result: 7,
205206
id: "1",

src/__tests__/websocket.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { describe, it, expect, vi } from "vitest";
22
import {
33
newWorkersWebSocketRpcResponse,
4+
newWorkersWebSocketRpcSession,
45
newWebSocketRpcSession,
56
createWebSocketTransport,
67
} from "../websocket.js";
@@ -21,4 +22,31 @@ describe("WebSocket transport and RPC", () => {
2122
expect(response.status).toBe(400);
2223
});
2324
});
25+
26+
describe("newWorkersWebSocketRpcSession", () => {
27+
it("should return 400 response for non-upgrade requests", () => {
28+
const request = new Request("http://example.com", { method: "GET" });
29+
const { response } = newWorkersWebSocketRpcSession(request);
30+
31+
expect(response.status).toBe(400);
32+
});
33+
34+
it("should return a remote that allows dispose/close on non-upgrade requests", () => {
35+
const request = new Request("http://example.com", { method: "GET" });
36+
const { remote } = newWorkersWebSocketRpcSession(request);
37+
38+
// dispose and close should be no-ops, not throw
39+
expect(() => remote[Symbol.dispose]()).not.toThrow();
40+
expect(() => remote.close()).not.toThrow();
41+
});
42+
43+
it("should return a remote that throws on method access for non-upgrade requests", () => {
44+
const request = new Request("http://example.com", { method: "GET" });
45+
const { remote } = newWorkersWebSocketRpcSession<{ add(a: number, b: number): number }>(
46+
request,
47+
);
48+
49+
expect(() => (remote as any).add).toThrow(); // bypass typed proxy to test dead proxy behavior
50+
});
51+
});
2452
});

src/__tests__/workers.workers.test.ts

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,15 @@ import {
44
newHttpBatchRpcResponse,
55
newHttpBatchRpcSession,
66
newWorkersWebSocketRpcResponse,
7+
newWorkersWebSocketRpcSession,
78
newWebSocketRpcSession,
89
newWorkersRpcResponse,
910
RpcError,
1011
RpcProtocolError,
1112
} from "../index.js";
1213

13-
describe("All 7 exports resolve from entry point", () => {
14-
it("should export all 7 required symbols with correct types", () => {
14+
describe("All exports resolve from entry point", () => {
15+
it("should export all required symbols with correct types", () => {
1516
// newHttpBatchRpcResponse (function)
1617
expect(typeof newHttpBatchRpcResponse).toBe("function");
1718

@@ -21,6 +22,9 @@ describe("All 7 exports resolve from entry point", () => {
2122
// newWorkersWebSocketRpcResponse (function)
2223
expect(typeof newWorkersWebSocketRpcResponse).toBe("function");
2324

25+
// newWorkersWebSocketRpcSession (function)
26+
expect(typeof newWorkersWebSocketRpcSession).toBe("function");
27+
2428
// newWebSocketRpcSession (function)
2529
expect(typeof newWebSocketRpcSession).toBe("function");
2630

@@ -34,17 +38,18 @@ describe("All 7 exports resolve from entry point", () => {
3438
expect(typeof RpcProtocolError).toBe("function");
3539
});
3640

37-
it("should instantiate RpcError with message", () => {
38-
const error = new RpcError("test error");
41+
it("should instantiate RpcError with message and code", () => {
42+
const error = new RpcError("test error", -32600);
3943
expect(error).toBeInstanceOf(RpcError);
4044
expect(error.message).toBe("test error");
45+
expect(error.code).toBe(-32600);
4146
});
4247

43-
it("should instantiate RpcProtocolError with message", () => {
44-
const error = new RpcProtocolError(-32700, "protocol error");
48+
it("should instantiate RpcProtocolError with code and message", () => {
49+
const error = new RpcProtocolError("PARSE_ERROR", "protocol error");
4550
expect(error).toBeInstanceOf(RpcProtocolError);
4651
expect(error.message).toBe("protocol error");
47-
expect(error.code).toBe(-32700);
52+
expect(error.code).toBe("PARSE_ERROR");
4853
});
4954
});
5055

@@ -86,7 +91,7 @@ describe("Workers runtime integration tests", () => {
8691

8792
expect(response.status).toBe(200);
8893

89-
const data = await response.json();
94+
const data = (await response.json()) as any; // Response.json() returns unknown
9095
expect(Array.isArray(data)).toBe(true);
9196
expect(data).toHaveLength(3);
9297
expect(data[0]).toEqual({ jsonrpc: "2.0", id: 1, result: 3 });
@@ -112,7 +117,7 @@ describe("Workers runtime integration tests", () => {
112117

113118
expect(response.status).toBe(200);
114119

115-
const data = await response.json();
120+
const data = (await response.json()) as any; // Response.json() returns unknown
116121
expect(data.error).toBeDefined();
117122
expect(data.error.code).toBe(-32601); // Method not found
118123
});

src/core.ts

Lines changed: 8 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -68,12 +68,11 @@ export type RpcHandlerOptions = {
6868
*/
6969
export function isJsonRpcResponse(res: unknown): res is JsonRpcResponse {
7070
if (typeof res !== "object" || res === null) return false;
71-
if (!("jsonrpc" in res) || (res as any).jsonrpc !== "2.0") return false; // narrowing unknown in type guard
71+
const obj = res as Record<string, unknown>;
72+
if (obj.jsonrpc !== "2.0") return false;
7273
if (
73-
!("id" in res) ||
74-
(typeof (res as any).id !== "string" && // narrowing unknown in type guard
75-
typeof (res as any).id !== "number" &&
76-
(res as any).id !== null)
74+
!("id" in obj) ||
75+
(typeof obj.id !== "string" && typeof obj.id !== "number" && obj.id !== null)
7776
)
7877
return false;
7978

@@ -176,24 +175,10 @@ export function extractError(err: unknown): {
176175
message: string;
177176
data?: unknown;
178177
} {
179-
const code =
180-
typeof err === "object" &&
181-
err !== null &&
182-
"code" in err &&
183-
typeof (err as any).code === "number" // narrowing unknown in type guard
184-
? (err as any).code // narrowing unknown in type guard
185-
: -32000;
186-
const message =
187-
typeof err === "object" &&
188-
err !== null &&
189-
"message" in err &&
190-
typeof (err as any).message === "string" // narrowing unknown in type guard
191-
? (err as any).message // narrowing unknown in type guard
192-
: "";
193-
const data =
194-
typeof err === "object" && err !== null && "data" in err
195-
? (err as any).data // narrowing unknown in type guard
196-
: undefined;
178+
const obj = typeof err === "object" && err !== null ? (err as Record<string, unknown>) : null;
179+
const code = obj && typeof obj.code === "number" ? obj.code : -32000;
180+
const message = obj && typeof obj.message === "string" ? obj.message : "";
181+
const data = obj && "data" in obj ? obj.data : undefined;
197182
return { code, message, data };
198183
}
199184

0 commit comments

Comments
 (0)