1import {
2 unwrapRpcHandlers,
3 wrapRpcHandlers,
4 type RpcWrapped,
5} from "@/Local/RpcSerialization.ts";
6import * as Output from "@/Output.ts";
7import { describe, expect, it } from "alchemy-test";
8import * as Cause from "effect/Cause";
9import * as Duration from "effect/Duration";
10import * as Effect from "effect/Effect";
11import * as Exit from "effect/Exit";
12import * as Redacted from "effect/Redacted";
13import * as Stream from "effect/Stream";
14
15/**
16 * Builds a client whose wrap→unwrap path mirrors the production wire:
17 * `unwrapRpcEffectHandler` serializes its args; capnweb JSON-encodes them
18 * over the websocket; `wrapRpcEffectHandler` deserializes them on the other
19 * end. We thread `JSON.parse(JSON.stringify(...))` between the wrap and
20 * unwrap layers to model that hop. Streams skip the JSON hop because they
21 * are bridged via a real `ReadableStream`.
22 */
23const roundTrip = <T extends Record<string, any>>(
24 handlers: T,
25 streamKeys?: Array<keyof T>,
26): T => {
27 const wrapped = wrapRpcHandlers(handlers, streamKeys);
28 const piped = Object.fromEntries(
29 Object.entries(wrapped).map(([key, value]) => {
30 if (typeof value !== "function") {
31 return [key, value];
32 }
33 if (streamKeys?.includes(key as keyof T)) {
34 // Streams bypass JSON; the underlying ReadableStream is shipped.
35 return [key, value];
36 }
37 const fn = value as (args: Array<unknown>) => Promise<unknown>;
38 const piped = async (args: Array<unknown>) => {
39 const wireArgs = JSON.parse(JSON.stringify(args));
40 const result = await fn(wireArgs);
41 return JSON.parse(JSON.stringify(result));
42 };
43 return [key, piped];
44 }),
45 ) as RpcWrapped<T>;
46 return unwrapRpcHandlers<T>(piped, streamKeys) as T;
47};
48
49describe("Local.RpcSerialization", { tags: ["unit", "local"] }, () => {
50 describe("argument serialization", () => {
51 it.effect("round-trips a top-level Redacted argument", () =>
52 Effect.gen(function* () {
53 const handlers = {
54 echo: (s: Redacted.Redacted<string>) =>
55 Effect.succeed(Redacted.value(s)),
56 };
57 const client = roundTrip(handlers);
58 expect(yield* client.echo(Redacted.make("hush"))).toBe("hush");
59 }),
60 );
61
62 it.effect("round-trips a Redacted nested inside an object", () =>
63 Effect.gen(function* () {
64 const handlers = {
65 password: (env: { password: Redacted.Redacted<string> }) =>
66 Effect.succeed(Redacted.value(env.password)),
67 };
68 const client = roundTrip(handlers);
69 expect(
70 yield* client.password({ password: Redacted.make("hush") }),
71 ).toBe("hush");
72 }),
73 );
74
75 it.effect("round-trips a Duration nested inside an object", () =>
76 Effect.gen(function* () {
77 const handlers = {
78 timeout: (env: { timeout: Duration.Duration }) =>
79 Effect.succeed(Duration.toSeconds(env.timeout)),
80 };
81 const client = roundTrip(handlers);
82 expect(yield* client.timeout({ timeout: Duration.seconds(15) })).toBe(
83 15,
84 );
85 }),
86 );
87
88 it.effect("round-trips Duration.negativeInfinity", () =>
89 Effect.gen(function* () {
90 const handlers = {
91 echo: (d: Duration.Duration) => Effect.succeed(d),
92 };
93 const client = roundTrip(handlers);
94 const result = yield* client.echo(Duration.negativeInfinity);
95 expect(Duration.equals(result, Duration.negativeInfinity)).toBe(true);
96 }),
97 );
98
99 it.effect("preserves objects with their own toJSON (Date)", () =>
100 Effect.gen(function* () {
101 const handlers = {
102 withDate: (env: { when: Date | string }) =>
103 Effect.succeed(String(env.when)),
104 };
105 const client = roundTrip(handlers);
106 const d = new Date("2026-05-16T12:00:00.000Z");
107 expect(yield* client.withDate({ when: d })).toBe(d.toISOString());
108 }),
109 );
110
111 it.effect("drops function-valued args (replaced with null)", () =>
112 Effect.gen(function* () {
113 const handlers = {
114 take: (arg: { cb: (() => void) | null }) =>
115 Effect.succeed(arg.cb === null ? "null" : typeof arg.cb),
116 };
117 const client = roundTrip(handlers);
118 expect(yield* client.take({ cb: () => {} })).toBe("null");
119 }),
120 );
121
122 it.effect("serializes arrays element-wise", () =>
123 Effect.gen(function* () {
124 const handlers = {
125 sum: (xs: ReadonlyArray<Redacted.Redacted<number>>) =>
126 Effect.succeed(xs.reduce((a, x) => a + Redacted.value(x), 0)),
127 };
128 const client = roundTrip(handlers);
129 const result = yield* client.sum([
130 Redacted.make(1),
131 Redacted.make(2),
132 Redacted.make(3),
133 ]);
134 expect(result).toBe(6);
135 }),
136 );
137
138 it.effect("converts Output sentinel into a NamedExpr", () =>
139 Effect.gen(function* () {
140 let received: unknown;
141 const handlers = {
142 take: (arg: { val: Output.Output<string> }) =>
143 Effect.sync(() => {
144 received = arg.val;
145 return "ok";
146 }),
147 };
148 const client = roundTrip(handlers);
149 const fakeOutput = new Output.NamedExpr(
150 new Output.EffectExpr(Output.VoidExpr, () => Effect.succeed("x")),
151 "myBinding",
152 );
153 const result = yield* client.take({
154 val: fakeOutput as unknown as Output.Output<string>,
155 });
156 expect(result).toBe("ok");
157 expect(Output.isOutput(received)).toBe(true);
158 expect((received as Output.NamedExpr<unknown>).kind).toBe("NamedExpr");
159 }),
160 );
161 });
162
163 describe("effect exit serialization", () => {
164 it.effect("propagates success", () =>
165 Effect.gen(function* () {
166 const handlers = {
167 ok: () => Effect.succeed(42),
168 };
169 expect(yield* roundTrip(handlers).ok()).toBe(42);
170 }),
171 );
172
173 it.effect("propagates Fail (typed error)", () =>
174 Effect.gen(function* () {
175 const handlers = {
176 boom: (): Effect.Effect<never, { _tag: "Boom"; msg: string }> =>
177 Effect.fail({ _tag: "Boom" as const, msg: "kaboom" }),
178 };
179 const exit = yield* Effect.exit(roundTrip(handlers).boom());
180 expect(Exit.isFailure(exit)).toBe(true);
181 if (Exit.isFailure(exit)) {
182 expect(Cause.hasFails(exit.cause)).toBe(true);
183 const found = Cause.findErrorOption(exit.cause);
184 expect(found._tag).toBe("Some");
185 if (found._tag === "Some") {
186 expect(found.value).toEqual({ _tag: "Boom", msg: "kaboom" });
187 }
188 }
189 }),
190 );
191
192 it.effect("propagates Die (defect)", () =>
193 Effect.gen(function* () {
194 const handlers = {
195 boom: () => Effect.die(new Error("defect")),
196 };
197 const exit = yield* Effect.exit(roundTrip(handlers).boom());
198 expect(Exit.isFailure(exit)).toBe(true);
199 if (Exit.isFailure(exit)) {
200 expect(Cause.hasDies(exit.cause)).toBe(true);
201 }
202 }),
203 );
204
205 it.effect("propagates Interrupt", () =>
206 Effect.gen(function* () {
207 const handlers = {
208 boom: () => Effect.interrupt,
209 };
210 const exit = yield* Effect.exit(roundTrip(handlers).boom());
211 expect(Exit.isFailure(exit)).toBe(true);
212 if (Exit.isFailure(exit)) {
213 expect(Cause.hasInterrupts(exit.cause)).toBe(true);
214 }
215 }),
216 );
217 });
218
219 describe("return value serialization", () => {
220 it.effect("round-trips a Redacted return value", () =>
221 Effect.gen(function* () {
222 const handlers = {
223 secret: () => Effect.succeed(Redacted.make("hush")),
224 };
225 const client = roundTrip(handlers);
226 const result = yield* client.secret();
227 expect(Redacted.isRedacted(result)).toBe(true);
228 expect(Redacted.value(result)).toBe("hush");
229 }),
230 );
231
232 it.effect("round-trips Redacted values nested in a returned object", () =>
233 Effect.gen(function* () {
234 const handlers = {
235 env: () =>
236 Effect.succeed({
237 env: { TOKEN: Redacted.make("t0k3n") },
238 plain: "value",
239 }),
240 };
241 const client = roundTrip(handlers);
242 const result = yield* client.env();
243 expect(Redacted.value(result.env.TOKEN)).toBe("t0k3n");
244 expect(result.plain).toBe("value");
245 }),
246 );
247 });
248
249 describe("streams", () => {
250 it.effect("round-trips a stream when streamKeys is honored", () =>
251 Effect.gen(function* () {
252 const handlers = {
253 tail: (_n: number) => Stream.fromIterable([1, 2, 3, 4]),
254 };
255 const client = roundTrip(handlers, ["tail"] as const);
256 const out: Array<number> = [];
257 yield* Stream.runForEach(client.tail(0), (x) =>
258 Effect.sync(() => {
259 out.push(x);
260 }),
261 );
262 expect(out).toEqual([1, 2, 3, 4]);
263 }),
264 );
265 });
266
267 describe("nested handlers", () => {
268 it.effect("recurses into nested handler objects", () =>
269 Effect.gen(function* () {
270 const handlers = {
271 group: {
272 echo: (s: Redacted.Redacted<string>) =>
273 Effect.succeed(Redacted.value(s)),
274 },
275 };
276 const client = roundTrip(handlers);
277 expect(yield* client.group.echo(Redacted.make("nested"))).toBe(
278 "nested",
279 );
280 }),
281 );
282 });
283});