1import {
2 makeServerRpcSession,
3 type ServerWebSocketLike,
4} from "@/Local/RpcServerSession.ts";
5import { describe, expect, it } from "alchemy-test";
6import * as Effect from "effect/Effect";
7import * as Exit from "effect/Exit";
8
9describe("Local.RpcServerSession", { tags: ["unit", "local"] }, () => {
10 it.effect("happy path: paired sessions round-trip a method call", () =>
11 Effect.gen(function* () {
12 const mainA = { ping: async (x: number) => x + 1 };
13 const mainB = { pong: async (x: string) => `${x}!` };
14 const { a } = pairSessions(mainA, mainB);
15 const remote = a.session.getRemoteMain() as unknown as {
16 pong: (x: string) => Promise<string>;
17 };
18 const result = yield* Effect.promise(() => remote.pong("hi"));
19 expect(result).toBe("hi!");
20 }),
21 );
22
23 it.effect("string and Buffer payloads both decode", () =>
24 Effect.gen(function* () {
25 const received: Array<string> = [];
26 const ws: ServerWebSocketLike = {
27 send: () => {},
28 close: () => {},
29 };
30 // Build a session purely to observe `dispatch.message` decoding.
31 // Use a noop main; we won't call anything on it.
32 const { dispatch } = makeServerRpcSession(ws, {});
33 const origMessage = dispatch.message;
34 dispatch.message = (data: any) => {
35 if (typeof data === "string") {
36 received.push(`str:${data}`);
37 } else {
38 received.push(`buf:${data.toString("utf-8")}`);
39 }
40 return origMessage(data);
41 };
42 dispatch.message("hello");
43 dispatch.message(Buffer.from("world", "utf-8") as Buffer<ArrayBuffer>);
44 expect(received).toEqual(["str:hello", "buf:world"]);
45 }),
46 );
47
48 it.effect("dispatch.message after dispatch.close is dropped silently", () =>
49 Effect.gen(function* () {
50 const ws: ServerWebSocketLike = {
51 send: () => {},
52 close: () => {},
53 };
54 const { dispatch } = makeServerRpcSession(ws, {});
55 dispatch.close(1000, "bye");
56 // Should not throw, should be a no-op.
57 expect(() => dispatch.message("anything")).not.toThrow();
58 }),
59 );
60
61 it.effect("dispatch.close rejects a pending getRemoteMain call", () =>
62 Effect.gen(function* () {
63 // mainB never resolves on the wire because we never deliver anything
64 // back; the close event should reject the in-flight call.
65 const closes: Array<{ code?: number; reason?: string }> = [];
66 const ws: ServerWebSocketLike = {
67 send: () => {
68 // intentionally drop outbound traffic
69 },
70 close: (code, reason) => {
71 closes.push({ code, reason });
72 },
73 };
74 const { session, dispatch } = makeServerRpcSession(ws, {});
75 const remote = session.getRemoteMain() as unknown as {
76 ping: (n: number) => Promise<number>;
77 };
78 const inFlight = remote.ping(1);
79 dispatch.close(1006, "abnormal");
80 // The dropped outbound traffic means the only way `inFlight` can
81 // resolve is by rejection after `dispatch.close`. Wrap it as an
82 // Effect that fails on rejection so we can assert with `Exit`.
83 const exit = yield* Effect.exit(Effect.tryPromise(() => inFlight));
84 expect(Exit.isFailure(exit)).toBe(true);
85 }),
86 );
87});
88
89/**
90 * Wire two `ServerRpcSession`s together over in-memory fake websockets so we
91 * can drive `RpcSession.getRemoteMain()` end-to-end without touching the
92 * platform WS server.
93 */
94const pairSessions = <
95 A extends Record<string, any>,
96 B extends Record<string, any>,
97>(
98 mainA: A,
99 mainB: B,
100) => {
101 const closes: Array<{ side: "a" | "b"; code?: number; reason?: string }> = [];
102 let a!: ReturnType<typeof makeServerRpcSession<A>>;
103 let b!: ReturnType<typeof makeServerRpcSession<B>>;
104
105 const wsA: ServerWebSocketLike = {
106 send: (msg) => {
107 queueMicrotask(() => b.dispatch.message(msg));
108 },
109 close: (code, reason) => {
110 closes.push({ side: "a", code, reason });
111 },
112 };
113 const wsB: ServerWebSocketLike = {
114 send: (msg) => {
115 queueMicrotask(() => a.dispatch.message(msg));
116 },
117 close: (code, reason) => {
118 closes.push({ side: "b", code, reason });
119 },
120 };
121 a = makeServerRpcSession<A>(wsA, mainA);
122 b = makeServerRpcSession<B>(wsB, mainB);
123 return { a, b, wsA, wsB, closes };
124};