Skip to content

Commit 94b00c8

Browse files
marbemactim-smart
andauthored
feat(rpc): add defect schema option to Rpc.make (#6065)
Signed-off-by: Marc MacLeod <marbemac+gh@gmail.com> Co-authored-by: Tim <hello@timsmart.co>
1 parent 12b1f1e commit 94b00c8

File tree

6 files changed

+116
-17
lines changed

6 files changed

+116
-17
lines changed

.changeset/custom-defect-schema.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@effect/rpc": patch
3+
---
4+
5+
Add optional `defect` parameter to `Rpc.make` for customizing defect serialization per-RPC. Defaults to `Schema.Defect`, preserving existing behavior.

packages/platform-node/test/RpcServer.test.ts

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@ import { HttpClient, HttpClientRequest, HttpRouter, HttpServer, SocketServer } f
22
import { NodeHttpServer, NodeSocket, NodeSocketServer, NodeWorker } from "@effect/platform-node"
33
import { RpcClient, RpcSerialization, RpcServer } from "@effect/rpc"
44
import { assert, describe, it } from "@effect/vitest"
5-
import { Effect, Layer } from "effect"
5+
import { Cause, Effect, Layer } from "effect"
66
import * as CP from "node:child_process"
7-
import { RpcLive, User, UsersClient } from "./fixtures/rpc-schemas.js"
7+
import { RpcLive, RpcLiveDisableFatalDefects, User, UsersClient } from "./fixtures/rpc-schemas.js"
88
import { e2eSuite } from "./rpc-e2e.js"
99

1010
describe("RpcServer", () => {
@@ -148,4 +148,38 @@ describe("RpcServer", () => {
148148
assert.deepStrictEqual(user, new User({ id: "1", name: "Logged in user" }))
149149
}).pipe(Effect.provide(UsersClient.layerTest)))
150150
})
151+
152+
describe("custom defect schema", () => {
153+
const CustomDefectServer = HttpRouter.Default.serve().pipe(
154+
Layer.provide(RpcLiveDisableFatalDefects),
155+
Layer.provideMerge(RpcServer.layerProtocolHttp({ path: "/rpc" }))
156+
)
157+
const CustomDefectClient = UsersClient.layer.pipe(
158+
Layer.provide(
159+
RpcClient.layerProtocolHttp({
160+
url: "",
161+
transformClient: HttpClient.mapRequest(HttpClientRequest.appendUrl("/rpc"))
162+
})
163+
)
164+
)
165+
const CustomDefectLayer = CustomDefectClient.pipe(
166+
Layer.provideMerge(CustomDefectServer),
167+
Layer.provide([NodeHttpServer.layerTest, RpcSerialization.layerNdjson])
168+
)
169+
170+
it.effect("preserves full defect with Schema.Unknown", () =>
171+
Effect.gen(function*() {
172+
const client = yield* UsersClient
173+
const cause = yield* client.ProduceDefectCustom().pipe(
174+
Effect.sandbox,
175+
Effect.flip
176+
)
177+
const defect = Cause.squash(cause)
178+
assert.deepStrictEqual(defect, {
179+
message: "detailed error",
180+
stack: "Error: detailed error\n at handler.ts:1",
181+
code: 42
182+
})
183+
}).pipe(Effect.provide(CustomDefectLayer)))
184+
})
151185
})

packages/platform-node/test/fixtures/rpc-schemas.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,9 @@ export const UserRpcs = RpcGroup.make(
5959
}),
6060
Rpc.make("ProduceDefect"),
6161
Rpc.make("ProduceErrorDefect"),
62+
Rpc.make("ProduceDefectCustom", {
63+
defect: Schema.Unknown
64+
}),
6265
Rpc.make("Never"),
6366
Rpc.make("nested.test"),
6467
Rpc.make("TimedMethod", {
@@ -134,6 +137,8 @@ const UsersLive = UserRpcs.toLayer(Effect.gen(function*() {
134137
GetEmits: () => Effect.sync(() => emits),
135138
ProduceDefect: () => Effect.die("boom"),
136139
ProduceErrorDefect: () => Effect.die(new Error("error defect message")),
140+
ProduceDefectCustom: () =>
141+
Effect.die({ message: "detailed error", stack: "Error: detailed error\n at handler.ts:1", code: 42 }),
137142
Never: () => Effect.never.pipe(Effect.onInterrupt(() => Effect.sync(() => interrupts++))),
138143
"nested.test": () => Effect.void,
139144
TimedMethod: (_) => _.shouldFail ? Effect.die("boom") : Effect.succeed(1),
@@ -154,6 +159,14 @@ export const RpcLive = RpcServer.layer(UserRpcs).pipe(
154159
])
155160
)
156161

162+
export const RpcLiveDisableFatalDefects = RpcServer.layer(UserRpcs, { disableFatalDefects: true }).pipe(
163+
Layer.provide([
164+
UsersLive,
165+
AuthLive,
166+
TimingLive
167+
])
168+
)
169+
157170
const AuthClient = RpcMiddleware.layerClient(AuthMiddleware, ({ request }) =>
158171
Effect.succeed({
159172
...request,

packages/rpc/src/Rpc.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ export interface Rpc<
5858
readonly payloadSchema: Payload
5959
readonly successSchema: Success
6060
readonly errorSchema: Error
61+
readonly defectSchema: Schema.Schema<unknown, any>
6162
readonly annotations: Context_.Context<never>
6263
readonly middlewares: ReadonlySet<Middleware>
6364

@@ -171,6 +172,7 @@ export interface AnyWithProps {
171172
readonly payloadSchema: AnySchema
172173
readonly successSchema: Schema.Schema.Any
173174
readonly errorSchema: Schema.Schema.All
175+
readonly defectSchema: Schema.Schema<unknown, any>
174176
readonly annotations: Context_.Context<never>
175177
readonly middlewares: ReadonlySet<RpcMiddleware.TagClassAnyWithProps>
176178
}
@@ -541,6 +543,7 @@ const Proto = {
541543
payloadSchema: this.payloadSchema,
542544
successSchema,
543545
errorSchema: this.errorSchema,
546+
defectSchema: this.defectSchema,
544547
annotations: this.annotations,
545548
middlewares: this.middlewares
546549
})
@@ -551,6 +554,7 @@ const Proto = {
551554
payloadSchema: this.payloadSchema,
552555
successSchema: this.successSchema,
553556
errorSchema,
557+
defectSchema: this.defectSchema,
554558
annotations: this.annotations,
555559
middlewares: this.middlewares
556560
})
@@ -561,6 +565,7 @@ const Proto = {
561565
payloadSchema: Schema.isSchema(payloadSchema) ? payloadSchema as any : Schema.Struct(payloadSchema as any),
562566
successSchema: this.successSchema,
563567
errorSchema: this.errorSchema,
568+
defectSchema: this.defectSchema,
564569
annotations: this.annotations,
565570
middlewares: this.middlewares
566571
})
@@ -571,6 +576,7 @@ const Proto = {
571576
payloadSchema: this.payloadSchema,
572577
successSchema: this.successSchema,
573578
errorSchema: this.errorSchema,
579+
defectSchema: this.defectSchema,
574580
annotations: this.annotations,
575581
middlewares: new Set([...this.middlewares, middleware])
576582
})
@@ -581,6 +587,7 @@ const Proto = {
581587
payloadSchema: this.payloadSchema,
582588
successSchema: this.successSchema,
583589
errorSchema: this.errorSchema,
590+
defectSchema: this.defectSchema,
584591
annotations: this.annotations,
585592
middlewares: this.middlewares
586593
})
@@ -591,6 +598,7 @@ const Proto = {
591598
payloadSchema: this.payloadSchema,
592599
successSchema: this.successSchema,
593600
errorSchema: this.errorSchema,
601+
defectSchema: this.defectSchema,
594602
middlewares: this.middlewares,
595603
annotations: Context_.add(this.annotations, tag, value)
596604
})
@@ -601,6 +609,7 @@ const Proto = {
601609
payloadSchema: this.payloadSchema,
602610
successSchema: this.successSchema,
603611
errorSchema: this.errorSchema,
612+
defectSchema: this.defectSchema,
604613
middlewares: this.middlewares,
605614
annotations: Context_.merge(this.annotations, context)
606615
})
@@ -618,6 +627,7 @@ const makeProto = <
618627
readonly payloadSchema: Payload
619628
readonly successSchema: Success
620629
readonly errorSchema: Error
630+
readonly defectSchema: Schema.Schema<unknown, any>
621631
readonly annotations: Context_.Context<never>
622632
readonly middlewares: ReadonlySet<Middleware>
623633
}): Rpc<Tag, Payload, Success, Error, Middleware> => {
@@ -643,6 +653,7 @@ export const make = <
643653
readonly success?: Success
644654
readonly error?: Error
645655
readonly stream?: Stream
656+
readonly defect?: Schema.Schema<unknown, any>
646657
readonly primaryKey?: [Payload] extends [Schema.Struct.Fields] ?
647658
((payload: Schema.Simplify<Schema.Struct.Type<NoInfer<Payload>>>) => string) :
648659
never
@@ -678,6 +689,7 @@ export const make = <
678689
}) :
679690
successSchema,
680691
errorSchema: options?.stream ? Schema.Never : errorSchema,
692+
defectSchema: options?.defect ?? Schema.Defect,
681693
annotations: Context_.empty(),
682694
middlewares: new Set<never>()
683695
}) as any
@@ -719,6 +731,7 @@ export const fromTaggedRequest = <S extends AnyTaggedRequestSchema>(
719731
payloadSchema: schema as any,
720732
successSchema: schema.success as any,
721733
errorSchema: schema.failure,
734+
defectSchema: Schema.Defect,
722735
annotations: Context_.empty(),
723736
middlewares: new Set()
724737
})
@@ -747,7 +760,7 @@ export const exitSchema = <R extends Any>(
747760
const schema = Schema.Exit({
748761
success: Option.isSome(streamSchemas) ? Schema.Void : rpc.successSchema,
749762
failure: Schema.Union(...failures),
750-
defect: Schema.Defect
763+
defect: rpc.defectSchema
751764
})
752765
exitSchemaCache.set(self, schema)
753766
return schema as any

packages/rpc/src/RpcServer.ts

Lines changed: 26 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -510,6 +510,7 @@ export const make: <Rpcs extends Rpc.Any>(
510510
return handleEncode(
511511
client,
512512
response.requestId,
513+
schemas.encodeDefect,
513514
schemas.collector,
514515
Effect.provide(schemas.encodeChunk(response.values), schemas.context),
515516
(values) => ({ _tag: "Chunk", requestId: String(response.requestId), values })
@@ -522,6 +523,7 @@ export const make: <Rpcs extends Rpc.Any>(
522523
return handleEncode(
523524
client,
524525
response.requestId,
526+
schemas.encodeDefect,
525527
schemas.collector,
526528
Effect.provide(schemas.encodeExit(response.exit), schemas.context),
527529
(exit) => ({ _tag: "Exit", requestId: String(response.requestId), exit })
@@ -552,6 +554,7 @@ export const make: <Rpcs extends Rpc.Any>(
552554
readonly decode: (u: unknown) => Effect.Effect<Rpc.Payload<Rpcs>, ParseError>
553555
readonly encodeChunk: (u: ReadonlyArray<unknown>) => Effect.Effect<NonEmptyReadonlyArray<unknown>, ParseError>
554556
readonly encodeExit: (u: unknown) => Effect.Effect<Schema.ExitEncoded<unknown, unknown, unknown>, ParseError>
557+
readonly encodeDefect: (u: unknown) => Effect.Effect<unknown, ParseError>
555558
readonly context: Context.Context<never>
556559
readonly collector?: Transferable.CollectorService | undefined
557560
}
@@ -568,6 +571,7 @@ export const make: <Rpcs extends Rpc.Any>(
568571
Schema.Array(Option.isSome(streamSchemas) ? streamSchemas.value.success : Schema.Any)
569572
) as any,
570573
encodeExit: Schema.encodeUnknown(Rpc.exitSchema(rpc as any)) as any,
574+
encodeDefect: Schema.encodeUnknown(rpc.defectSchema) as any,
571575
context: entry.context
572576
}
573577
schemasCache.set(rpc, schemas)
@@ -584,6 +588,7 @@ export const make: <Rpcs extends Rpc.Any>(
584588
const handleEncode = <A, R>(
585589
client: Client,
586590
requestId: RequestId,
591+
encodeDefect: (u: unknown) => Effect.Effect<unknown, ParseError>,
587592
collector: Transferable.CollectorService | undefined,
588593
effect: Effect.Effect<A, ParseError, R>,
589594
onSuccess: (a: A) => FromServerEncoded
@@ -594,27 +599,34 @@ export const make: <Rpcs extends Rpc.Any>(
594599
client.schemas.delete(requestId)
595600
const defect = Cause.squash(Cause.map(cause, TreeFormatter.formatErrorSync))
596601
return Effect.zipRight(
597-
sendRequestDefect(client, requestId, defect),
602+
sendRequestDefect(client, requestId, encodeDefect, defect),
598603
server.write(client.id, { _tag: "Interrupt", requestId, interruptors: [] })
599604
)
600605
})
601606
)
602607

603608
const encodeDefect = Schema.encodeSync(Schema.Defect)
604609

605-
const sendRequestDefect = (client: Client, requestId: RequestId, defect: unknown) =>
610+
const sendRequestDefect = (
611+
client: Client,
612+
requestId: RequestId,
613+
encodeDefect: (u: unknown) => Effect.Effect<unknown, ParseError>,
614+
defect: unknown
615+
) =>
606616
Effect.catchAllCause(
607-
send(client.id, {
608-
_tag: "Exit",
609-
requestId: String(requestId),
610-
exit: {
611-
_tag: "Failure",
612-
cause: {
613-
_tag: "Die",
614-
defect: encodeDefect(defect)
617+
encodeDefect(defect).pipe(Effect.flatMap((encodedDefect) =>
618+
send(client.id, {
619+
_tag: "Exit",
620+
requestId: String(requestId),
621+
exit: {
622+
_tag: "Failure",
623+
cause: {
624+
_tag: "Die",
625+
defect: encodedDefect
626+
}
615627
}
616-
}
617-
}),
628+
})
629+
)),
618630
(cause) => sendDefect(client, Cause.squash(cause))
619631
)
620632

@@ -661,7 +673,8 @@ export const make: <Rpcs extends Rpc.Any>(
661673
return Effect.matchEffect(
662674
Effect.provide(schemas.decode(request.payload), schemas.context),
663675
{
664-
onFailure: (error) => sendRequestDefect(client, requestId, TreeFormatter.formatErrorSync(error)),
676+
onFailure: (error) =>
677+
sendRequestDefect(client, requestId, schemas.encodeDefect, TreeFormatter.formatErrorSync(error)),
665678
onSuccess: (payload) => {
666679
client.schemas.set(
667680
requestId,

packages/rpc/test/Rpc.test.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { Headers } from "@effect/platform"
22
import { Rpc, RpcGroup } from "@effect/rpc"
33
import { assert, describe, it } from "@effect/vitest"
4-
import { Effect, Schema } from "effect"
4+
import { Cause, Effect, Exit, Schema } from "effect"
55

66
const TestGroup = RpcGroup.make(
77
Rpc.make("one"),
@@ -20,4 +20,25 @@ describe("Rpc", () => {
2020
const result = yield* handler(void 0, Headers.empty)
2121
assert.strictEqual(result, "two")
2222
}))
23+
24+
it("exitSchema uses custom defect schema", () => {
25+
const myRpc = Rpc.make("customDefect", {
26+
success: Schema.String,
27+
defect: Schema.Unknown
28+
})
29+
30+
const schema = Rpc.exitSchema(myRpc)
31+
const encode = Schema.encodeSync(schema)
32+
const decode = Schema.decodeSync(schema)
33+
34+
const error = { message: "boom", stack: "Error: boom\n at foo.ts:1", code: 42 }
35+
const exit = Exit.die(error)
36+
37+
// Schema.Unknown preserves the full defect value, unlike the default Schema.Defect
38+
const roundTripped = decode(encode(exit))
39+
40+
assert.isTrue(Exit.isFailure(roundTripped))
41+
const defect = Cause.squash((roundTripped as Exit.Failure<any, any>).cause)
42+
assert.deepStrictEqual(defect, error)
43+
})
2344
})

0 commit comments

Comments
 (0)