effect-orpc 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,410 @@
1
+ import { oc } from "@orpc/contract";
2
+ import { call, createRouterClient } from "@orpc/server";
3
+ import type { Context as ORPCContext } from "@orpc/server";
4
+ import { Context, Effect, Layer, ManagedRuntime, Option, Tracer } from "effect";
5
+ import { describe, expect, it } from "vitest";
6
+ import z from "zod";
7
+
8
+ import { implementEffect } from "../contract";
9
+ import { eos, makeEffectORPC } from "../effect-builder";
10
+ import type { EffectOrORPCMiddleware } from "../types";
11
+
12
+ type TestMiddleware<
13
+ TOutContext extends ORPCContext = { readonly value: string },
14
+ > = EffectOrORPCMiddleware<
15
+ ORPCContext | Record<never, never>,
16
+ TOutContext,
17
+ unknown,
18
+ unknown,
19
+ Record<never, never>,
20
+ never,
21
+ Record<never, never>
22
+ >;
23
+
24
+ type TestMiddlewareOptions = Parameters<TestMiddleware>[0];
25
+ type SpanMiddlewareOptions = Parameters<
26
+ TestMiddleware<Record<never, never>>
27
+ >[0];
28
+
29
+ type MiddlewareShape = {
30
+ readonly name: string;
31
+ readonly middleware: TestMiddleware;
32
+ readonly expected: string;
33
+ };
34
+
35
+ function effectHandlerShapes() {
36
+ return [
37
+ {
38
+ name: "function*",
39
+ handler: function* ({ input }: { input: number }) {
40
+ const increment = yield* Effect.succeed(1);
41
+ return input + increment;
42
+ },
43
+ },
44
+ {
45
+ name: "named Effect.fn",
46
+ handler: Effect.fn("test.effect.named")(function* ({
47
+ input,
48
+ }: {
49
+ input: number;
50
+ }) {
51
+ const increment = yield* Effect.succeed(1);
52
+ return input + increment;
53
+ }),
54
+ },
55
+ {
56
+ name: "anonymous Effect.fn",
57
+ handler: Effect.fn(function* ({ input }: { input: number }) {
58
+ const increment = yield* Effect.succeed(1);
59
+ return input + increment;
60
+ }),
61
+ },
62
+ {
63
+ name: "Effect.gen-returning function",
64
+ handler: ({ input }: { input: number }) =>
65
+ Effect.gen(function* () {
66
+ const increment = yield* Effect.succeed(1);
67
+ return input + increment;
68
+ }),
69
+ },
70
+ ] as const;
71
+ }
72
+
73
+ function providerShapes(suffix: string) {
74
+ return [
75
+ {
76
+ name: "function*",
77
+ provider: function* ({ context }: { context: { value: string } }) {
78
+ yield* Effect.void;
79
+ return { value: `${context.value}:${suffix}:generator` };
80
+ },
81
+ },
82
+ {
83
+ name: "named Effect.fn",
84
+ provider: Effect.fn("test.provider.named")(function* ({
85
+ context,
86
+ }: {
87
+ context: { value: string };
88
+ }) {
89
+ yield* Effect.void;
90
+ return { value: `${context.value}:${suffix}:named` };
91
+ }),
92
+ },
93
+ {
94
+ name: "anonymous Effect.fn",
95
+ provider: Effect.fn(function* ({
96
+ context,
97
+ }: {
98
+ context: { value: string };
99
+ }) {
100
+ yield* Effect.void;
101
+ return { value: `${context.value}:${suffix}:anonymous` };
102
+ }),
103
+ },
104
+ {
105
+ name: "Effect.gen-returning function",
106
+ provider: ({ context }: { context: { value: string } }) =>
107
+ Effect.gen(function* () {
108
+ yield* Effect.void;
109
+ return { value: `${context.value}:${suffix}:effect-gen` };
110
+ }),
111
+ },
112
+ ] as const;
113
+ }
114
+
115
+ function optionalProviderShapes(suffix: string) {
116
+ return [
117
+ {
118
+ name: "function*",
119
+ provider: function* ({ context }: { context: { value?: string } }) {
120
+ yield* Effect.void;
121
+ return Option.map(Option.fromNullable(context.value), (value) => ({
122
+ value: `${value}:${suffix}:generator`,
123
+ }));
124
+ },
125
+ },
126
+ {
127
+ name: "named Effect.fn",
128
+ provider: Effect.fn("test.optional-provider.named")(function* ({
129
+ context,
130
+ }: {
131
+ context: { value?: string };
132
+ }) {
133
+ yield* Effect.void;
134
+ return Option.map(Option.fromNullable(context.value), (value) => ({
135
+ value: `${value}:${suffix}:named`,
136
+ }));
137
+ }),
138
+ },
139
+ {
140
+ name: "anonymous Effect.fn",
141
+ provider: Effect.fn(function* ({
142
+ context,
143
+ }: {
144
+ context: { value?: string };
145
+ }) {
146
+ yield* Effect.void;
147
+ return Option.map(Option.fromNullable(context.value), (value) => ({
148
+ value: `${value}:${suffix}:anonymous`,
149
+ }));
150
+ }),
151
+ },
152
+ {
153
+ name: "Effect.gen-returning function",
154
+ provider: ({ context }: { context: { value?: string } }) =>
155
+ Effect.gen(function* () {
156
+ yield* Effect.void;
157
+ return Option.map(Option.fromNullable(context.value), (value) => ({
158
+ value: `${value}:${suffix}:effect-gen`,
159
+ }));
160
+ }),
161
+ },
162
+ ] as const;
163
+ }
164
+
165
+ function middlewareShapes(): ReadonlyArray<MiddlewareShape> {
166
+ return [
167
+ {
168
+ name: "native guard-only function",
169
+ middleware: () => {},
170
+ expected: "handler",
171
+ },
172
+ {
173
+ name: "native next-returning function",
174
+ middleware: ({ next }) => next({ context: { value: "native-next" } }),
175
+ expected: "native-next",
176
+ },
177
+ {
178
+ name: "function*",
179
+ middleware: function* ({ next }: TestMiddlewareOptions) {
180
+ yield* Effect.void;
181
+ return yield* next({ context: { value: "generator" } });
182
+ },
183
+ expected: "generator",
184
+ },
185
+ {
186
+ name: "named Effect.fn",
187
+ middleware: Effect.fn("test.middleware.named")(function* ({
188
+ next,
189
+ }: TestMiddlewareOptions) {
190
+ yield* Effect.void;
191
+ return yield* next({ context: { value: "named" } });
192
+ }),
193
+ expected: "named",
194
+ },
195
+ {
196
+ name: "anonymous Effect.fn",
197
+ middleware: Effect.fn(function* ({ next }: TestMiddlewareOptions) {
198
+ yield* Effect.void;
199
+ return yield* next({ context: { value: "anonymous" } });
200
+ }),
201
+ expected: "anonymous",
202
+ },
203
+ {
204
+ name: "Effect.gen-returning function",
205
+ middleware: ({ next }) =>
206
+ Effect.gen(function* () {
207
+ yield* Effect.void;
208
+ return yield* next({ context: { value: "effect-gen" } });
209
+ }),
210
+ expected: "effect-gen",
211
+ },
212
+ {
213
+ name: "Effect.gen-returning guard-only function",
214
+ middleware: () =>
215
+ Effect.gen(function* () {
216
+ yield* Effect.void;
217
+ }),
218
+ expected: "handler",
219
+ },
220
+ ];
221
+ }
222
+
223
+ describe("Effect callback shapes", () => {
224
+ for (const { name, handler } of effectHandlerShapes()) {
225
+ it(`.effect supports ${name}`, async () => {
226
+ const procedure = eos.input(z.number()).effect(handler);
227
+
228
+ await expect(call(procedure, 41)).resolves.toBe(42);
229
+ });
230
+ }
231
+
232
+ it("contract implementer .effect supports Effect-returning handlers", async () => {
233
+ const contract = {
234
+ increment: oc.input(z.number()).output(z.number()),
235
+ };
236
+ const implementer = implementEffect(contract, Layer.empty);
237
+
238
+ const named = implementer.increment.effect(
239
+ Effect.fn("test.contract.effect")(function* ({ input }) {
240
+ const increment = yield* Effect.succeed(1);
241
+ return input + increment;
242
+ }),
243
+ );
244
+
245
+ await expect(call(named, 41)).resolves.toBe(42);
246
+ });
247
+
248
+ for (const { name, provider } of providerShapes("provide")) {
249
+ it(`.provide supports ${name}`, async () => {
250
+ class RequestValue extends Context.Tag(`RequestValue:${name}`)<
251
+ RequestValue,
252
+ { readonly value: string }
253
+ >() {}
254
+
255
+ const procedure = eos
256
+ .$context<{ value: string }>()
257
+ .provide(RequestValue, provider)
258
+ .effect(function* () {
259
+ const service = yield* RequestValue;
260
+ return service.value;
261
+ });
262
+
263
+ await expect(
264
+ call(procedure, undefined, { context: { value: "request" } }),
265
+ ).resolves.toContain("request:provide");
266
+ });
267
+ }
268
+
269
+ for (const { name, provider } of optionalProviderShapes("optional")) {
270
+ it(`.provideOptional supports ${name}`, async () => {
271
+ class RequestValue extends Context.Tag(`OptionalRequestValue:${name}`)<
272
+ RequestValue,
273
+ { readonly value: string }
274
+ >() {}
275
+
276
+ const procedure = eos
277
+ .$context<{ value?: string }>()
278
+ .provideOptional(RequestValue, provider)
279
+ .effect(function* () {
280
+ return yield* Effect.serviceOption(RequestValue);
281
+ });
282
+
283
+ await expect(
284
+ call(procedure, undefined, { context: { value: "request" } }),
285
+ ).resolves.toSatisfy(
286
+ (option: Option.Option<{ readonly value: string }>) =>
287
+ Option.isSome(option) &&
288
+ option.value.value.includes("request:optional"),
289
+ );
290
+ await expect(
291
+ call(procedure, undefined, { context: {} }),
292
+ ).resolves.toEqual(Option.none());
293
+ });
294
+ }
295
+
296
+ for (const { name, middleware, expected } of middlewareShapes()) {
297
+ it(`.use supports ${name}`, async () => {
298
+ const procedure = eos.use(middleware).effect(function* ({ context }) {
299
+ return "value" in context ? context.value : "handler";
300
+ });
301
+
302
+ await expect(call(procedure, undefined)).resolves.toBe(expected);
303
+ });
304
+ }
305
+
306
+ for (const { name, middleware, expected } of middlewareShapes().filter(
307
+ ({ name }) => !name.startsWith("native"),
308
+ )) {
309
+ it(`.middleware supports ${name}`, async () => {
310
+ const reusable = eos.middleware(middleware);
311
+ const procedure = eos.use(reusable).effect(function* ({ context }) {
312
+ return "value" in context ? context.value : "handler";
313
+ });
314
+
315
+ await expect(call(procedure, undefined)).resolves.toBe(expected);
316
+ });
317
+ }
318
+
319
+ it("Effect handlers keep their own spans inside routed procedure spans", async () => {
320
+ const spans: Array<{
321
+ readonly name: string;
322
+ readonly parentName: string | undefined;
323
+ }> = [];
324
+ const spanNamesById = new Map<string, string>();
325
+ const tracer = Tracer.make({
326
+ context: (f) => f(),
327
+ span(name, parent, context, links, startTime, kind) {
328
+ const spanId = `span-${spans.length + 1}`;
329
+ spans.push({
330
+ name,
331
+ parentName: Option.match(parent, {
332
+ onNone: () => undefined,
333
+ onSome: (span) => spanNamesById.get(span.spanId),
334
+ }),
335
+ });
336
+ spanNamesById.set(spanId, name);
337
+ const attributes = new Map<string, unknown>();
338
+
339
+ return {
340
+ _tag: "Span" as const,
341
+ name,
342
+ spanId,
343
+ traceId: "trace",
344
+ parent,
345
+ context,
346
+ status: { _tag: "Started" as const, startTime },
347
+ attributes,
348
+ links,
349
+ sampled: true,
350
+ kind,
351
+ end() {},
352
+ attribute(key: string, value: unknown) {
353
+ attributes.set(key, value);
354
+ },
355
+ event() {},
356
+ addLinks() {},
357
+ };
358
+ },
359
+ });
360
+ const tracedRuntime = ManagedRuntime.make(Layer.setTracer(tracer));
361
+ const effectFnProcedure = makeEffectORPC(tracedRuntime)
362
+ .use(
363
+ Effect.fn("custom.middleware.span")(function* ({
364
+ next,
365
+ }: SpanMiddlewareOptions) {
366
+ return yield* next();
367
+ }),
368
+ )
369
+ .effect(
370
+ Effect.fn("custom.handler.span")(function* () {
371
+ return "ok";
372
+ }),
373
+ );
374
+ const withSpanProcedure = makeEffectORPC(tracedRuntime).effect(() =>
375
+ Effect.succeed("ok").pipe(Effect.withSpan("custom.with-span.handler")),
376
+ );
377
+ const client = createRouterClient({
378
+ users: {
379
+ effectFn: effectFnProcedure,
380
+ withSpan: withSpanProcedure,
381
+ },
382
+ });
383
+
384
+ try {
385
+ await expect(client.users.effectFn(undefined)).resolves.toBe("ok");
386
+ await expect(client.users.withSpan(undefined)).resolves.toBe("ok");
387
+ expect(spans).toContainEqual({
388
+ name: "users.effectFn",
389
+ parentName: undefined,
390
+ });
391
+ expect(spans).toContainEqual({
392
+ name: "custom.handler.span",
393
+ parentName: "users.effectFn",
394
+ });
395
+ expect(spans).toContainEqual({
396
+ name: "users.withSpan",
397
+ parentName: undefined,
398
+ });
399
+ expect(spans).toContainEqual({
400
+ name: "custom.with-span.handler",
401
+ parentName: "users.withSpan",
402
+ });
403
+ expect(
404
+ spans.filter(({ name }) => name === "custom.middleware.span"),
405
+ ).toHaveLength(1);
406
+ } finally {
407
+ await tracedRuntime.dispose();
408
+ }
409
+ });
410
+ });
@@ -1,9 +1,9 @@
1
1
  import { fallbackORPCErrorMessage, ORPCError } from "@orpc/client";
2
- import { Effect, Layer, ManagedRuntime } from "effect";
2
+ import { Effect } from "effect";
3
3
  import { describe, expect, expectTypeOf, it } from "vitest";
4
4
  import { z } from "zod";
5
5
 
6
- import { makeEffectORPC } from "../effect-builder";
6
+ import { eos } from "../effect-builder";
7
7
  import type {
8
8
  EffectErrorMap,
9
9
  EffectErrorMapToUnion,
@@ -192,11 +192,10 @@ describe("effectErrorMapToErrorMap", () => {
192
192
  });
193
193
 
194
194
  describe("effectBuilder with EffectErrorMap", () => {
195
- const runtime = ManagedRuntime.make(Layer.empty);
196
- const effectOs = makeEffectORPC(runtime);
195
+ const effectProcedure = eos;
197
196
 
198
197
  it("should support errors() with traditional format", () => {
199
- const builder = effectOs.errors({
198
+ const builder = effectProcedure.errors({
200
199
  BAD_REQUEST: { status: 400, message: "Bad request" },
201
200
  });
202
201
 
@@ -206,7 +205,7 @@ describe("effectBuilder with EffectErrorMap", () => {
206
205
  });
207
206
 
208
207
  it("should support errors() with tagged error classes", () => {
209
- const builder = effectOs.errors({
208
+ const builder = effectProcedure.errors({
210
209
  USER_NOT_FOUND_ERROR: UserNotFoundError,
211
210
  FORBIDDEN: PermissionDenied,
212
211
  });
@@ -218,7 +217,7 @@ describe("effectBuilder with EffectErrorMap", () => {
218
217
  });
219
218
 
220
219
  it("should support mixed error format", () => {
221
- const builder = effectOs.errors({
220
+ const builder = effectProcedure.errors({
222
221
  BAD_REQUEST: { status: 400 },
223
222
  USER_NOT_FOUND_ERROR: UserNotFoundError,
224
223
  });
@@ -232,7 +231,7 @@ describe("effectBuilder with EffectErrorMap", () => {
232
231
  });
233
232
 
234
233
  it("should merge errors correctly", () => {
235
- const builder = effectOs
234
+ const builder = effectProcedure
236
235
  .errors({ BAD_REQUEST: { status: 400 } })
237
236
  .errors({ USER_NOT_FOUND_ERROR: UserNotFoundError })
238
237
  .errors({ FORBIDDEN: PermissionDenied });
@@ -245,7 +244,7 @@ describe("effectBuilder with EffectErrorMap", () => {
245
244
  });
246
245
 
247
246
  it("should create procedure with effect handler", async () => {
248
- const procedure = effectOs
247
+ const procedure = effectProcedure
249
248
  .errors({
250
249
  USER_NOT_FOUND_ERROR: UserNotFoundError,
251
250
  BAD_REQUEST: { status: 400 },
@@ -267,7 +266,7 @@ describe("effectBuilder with EffectErrorMap", () => {
267
266
  });
268
267
 
269
268
  it("should allow throwing tagged errors in effect handler", async () => {
270
- const procedure = effectOs
269
+ const procedure = effectProcedure
271
270
  .errors({
272
271
  USER_NOT_FOUND_ERROR: UserNotFoundError,
273
272
  })
@@ -309,11 +308,10 @@ describe("effectBuilder with EffectErrorMap", () => {
309
308
  });
310
309
 
311
310
  describe("effectDecoratedProcedure.errors()", () => {
312
- const runtime = ManagedRuntime.make(Layer.empty);
313
- const effectOs = makeEffectORPC(runtime);
311
+ const effectProcedure = eos;
314
312
 
315
313
  it("should support adding errors to procedure", () => {
316
- const procedure = effectOs
314
+ const procedure = effectProcedure
317
315
  .input(z.object({ id: z.string() }))
318
316
  .effect(function* ({ input }) {
319
317
  return { id: input.id };
@@ -326,7 +324,7 @@ describe("effectDecoratedProcedure.errors()", () => {
326
324
  });
327
325
 
328
326
  it("should merge errors on procedure", () => {
329
- const procedure = effectOs
327
+ const procedure = effectProcedure
330
328
  .errors({ BAD_REQUEST: { status: 400 } })
331
329
  .input(z.object({ id: z.string() }))
332
330
  .effect(function* ({ input }) {
@@ -4,6 +4,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
4
4
  import z from "zod";
5
5
 
6
6
  import { EffectDecoratedProcedure } from "../effect-procedure";
7
+ import { makeEffectRuntimeRunner } from "../runtime-source";
7
8
  import {
8
9
  baseErrorMap,
9
10
  baseMeta,
@@ -25,6 +26,7 @@ vi.mock("@orpc/server", async (importOriginal) => {
25
26
  });
26
27
 
27
28
  const runtime = ManagedRuntime.make(Layer.empty);
29
+ const runner = makeEffectRuntimeRunner(runtime);
28
30
 
29
31
  const handler = vi.fn();
30
32
  const middleware = vi.fn();
@@ -40,6 +42,7 @@ const def = {
40
42
  meta: baseMeta,
41
43
  route: baseRoute,
42
44
  handler,
45
+ runner,
43
46
  runtime,
44
47
  };
45
48
 
@@ -80,7 +83,7 @@ describe("effectDecoratedProcedure", () => {
80
83
  });
81
84
 
82
85
  // Preserves runtime
83
- expect(applied["~effect"].runtime).toBe(runtime);
86
+ expect(applied["~effect"].runner.runtime).toBe(runtime);
84
87
  });
85
88
 
86
89
  it(".meta", () => {
@@ -96,7 +99,7 @@ describe("effectDecoratedProcedure", () => {
96
99
  });
97
100
 
98
101
  // Preserves runtime
99
- expect(applied["~effect"].runtime).toBe(runtime);
102
+ expect(applied["~effect"].runner.runtime).toBe(runtime);
100
103
  });
101
104
 
102
105
  it(".route", () => {
@@ -112,24 +115,63 @@ describe("effectDecoratedProcedure", () => {
112
115
  });
113
116
 
114
117
  // Preserves runtime
115
- expect(applied["~effect"].runtime).toBe(runtime);
118
+ expect(applied["~effect"].runner.runtime).toBe(runtime);
116
119
  });
117
120
 
118
121
  describe(".use", () => {
119
- it("without map input", () => {
120
- const mid = vi.fn();
122
+ it("without map input", async () => {
123
+ const mid = vi.fn(({ next }) =>
124
+ next({ context: { fromMiddleware: true } }),
125
+ );
121
126
 
122
127
  const applied = decorated.use(mid);
123
128
  expect(applied).not.toBe(decorated);
124
129
  expect(applied).toBeInstanceOf(EffectDecoratedProcedure);
125
130
 
126
- expect(applied["~effect"]).toEqual({
127
- ...def,
128
- middlewares: [...def.middlewares, mid],
131
+ expect(applied["~effect"].middlewares).toHaveLength(
132
+ def.middlewares.length + 1,
133
+ );
134
+ expect(applied["~effect"].middlewares[0]).toBe(def.middlewares[0]);
135
+
136
+ const wrapped = applied["~effect"].middlewares[1]!;
137
+ let nextCalls = 0;
138
+ let nextOptions: unknown;
139
+ const next = <TContext extends Record<PropertyKey, unknown>>(options?: {
140
+ context?: TContext;
141
+ }) => {
142
+ nextCalls++;
143
+ nextOptions = options;
144
+ return Promise.resolve({
145
+ output: "ok",
146
+ context: options?.context ?? ({} as TContext),
147
+ });
148
+ };
149
+ await expect(
150
+ wrapped(
151
+ {
152
+ context: {},
153
+ errors: {},
154
+ path: [],
155
+ procedure: applied,
156
+ signal: undefined,
157
+ lastEventId: undefined,
158
+ next,
159
+ },
160
+ "input",
161
+ vi.fn(),
162
+ ),
163
+ ).resolves.toEqual({
164
+ output: "ok",
165
+ context: { fromMiddleware: true },
166
+ });
167
+ expect(mid).toHaveBeenCalledOnce();
168
+ expect(nextCalls).toBe(1);
169
+ expect(nextOptions).toEqual({
170
+ context: { fromMiddleware: true },
129
171
  });
130
172
 
131
173
  // Preserves runtime
132
- expect(applied["~effect"].runtime).toBe(runtime);
174
+ expect(applied["~effect"].runner.runtime).toBe(runtime);
133
175
  });
134
176
 
135
177
  it("with map input", () => {
@@ -146,7 +188,7 @@ describe("effectDecoratedProcedure", () => {
146
188
  });
147
189
 
148
190
  // Preserves runtime
149
- expect(applied["~effect"].runtime).toBe(runtime);
191
+ expect(applied["~effect"].runner.runtime).toBe(runtime);
150
192
  });
151
193
  });
152
194
 
@@ -207,7 +249,7 @@ describe("effectDecoratedProcedure chaining", () => {
207
249
  .route({ path: "/custom" });
208
250
 
209
251
  expect(applied).toBeInstanceOf(EffectDecoratedProcedure);
210
- expect(applied["~effect"].runtime).toBe(runtime);
252
+ expect(applied["~effect"].runner.runtime).toBe(runtime);
211
253
  expect(applied["~effect"].errorMap).toHaveProperty("CUSTOM");
212
254
  });
213
255
  });
@@ -1,4 +1,4 @@
1
- import { Layer, ManagedRuntime } from "effect";
1
+ import { Layer } from "effect";
2
2
 
3
3
  import { eoc } from "../index";
4
4
  import {
@@ -12,7 +12,7 @@ import {
12
12
  export type InitialContext = { db: string };
13
13
  export type CurrentContext = InitialContext & { auth: boolean };
14
14
 
15
- export const runtime = ManagedRuntime.make(Layer.empty);
15
+ export const runtime = Layer.empty;
16
16
 
17
17
  export const typedContract = {
18
18
  ping: eoc
@@ -474,7 +474,7 @@ export interface EffectBuilderSurface<
474
474
  >;
475
475
  /**
476
476
  * Defines the handler of the procedure using an Effect.
477
- * The Effect is executed using the ManagedRuntime provided during builder creation.
477
+ * The Effect is executed using the configured Effect runtime source.
478
478
  * The effect is automatically wrapped with `Effect.withSpan`.
479
479
  *
480
480
  * @see {@link https://orpc.dev/docs/procedure Procedure Docs}