| 1 | import { |
| 2 | makeServerRpcSession, |
| 3 | type ServerWebSocketLike, |
| 4 | } from "@/Local/RpcServerSession.ts"; |
| 5 | import { describe, expect, it } from "alchemy-test"; |
| 6 | import * as Effect from "effect/Effect"; |
| 7 | import * as Exit from "effect/Exit"; |
| 8 | |
| 9 | describe("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 | */ |
| 94 | const 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 | }; |