Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fluffy-times-shave.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"effect-orpc": major
---

migrate to effect-v4 (effect-smol)
12 changes: 12 additions & 0 deletions .changeset/pre.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"mode": "pre",
"tag": "effect-v4",
"initialVersions": {
"effect-orpc": "0.1.2"
},
"changesets": [
"dull-spiders-smash",
"fluffy-times-shave",
"silly-doodles-cover"
]
}
5 changes: 5 additions & 0 deletions .changeset/silly-doodles-cover.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"effect-orpc": patch
---

docs: remove duplicate request-scoped context section
1 change: 1 addition & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ on:
push:
branches:
- main
- effect-v4

concurrency: ${{ github.workflow }}-${{ github.ref }}

Expand Down
39 changes: 24 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ Runnable demos live in the repository's `examples/` directory.

```ts
import { os } from "@orpc/server";
import { Effect, ManagedRuntime } from "effect";
import { Effect, Layer, ManagedRuntime, ServiceMap } from "effect";
import { makeEffectORPC, ORPCTaggedError } from "effect-orpc";

interface User {
Expand All @@ -53,20 +53,24 @@ const authedOs = os
});

// Define your services
class UsersRepo extends Effect.Service<UsersRepo>()("UsersRepo", {
accessors: true,
sync: () => ({
class UsersRepo extends ServiceMap.Service<
UsersRepo,
{
readonly get: (id: number) => User | undefined;
}
>()("UsersRepo") {
static readonly layer = Layer.succeed(this, {
get: (id: number) => users.find((u) => u.id === id),
}),
}) {}
});
}

// Special yieldable oRPC error class
class UserNotFoundError extends ORPCTaggedError("UserNotFoundError", {
status: 404,
}) {}

// Create runtime with your services
const runtime = ManagedRuntime.make(UsersRepo.Default);
const runtime = ManagedRuntime.make(UsersRepo.layer);
// Create Effect-aware oRPC builder from an other (optional) base oRPC builder and provide tagged errors
const effectOs = makeEffectORPC(runtime, authedOs).errors({
UserNotFoundError,
Expand All @@ -77,7 +81,8 @@ export const router = {
health: os.handler(() => "ok"),
users: {
me: effectOs.effect(function* ({ context: { userId } }) {
const user = yield* UsersRepo.get(userId);
const usersRepo = yield* UsersRepo;
const user = usersRepo.get(userId);
if (!user) {
return yield* new UserNotFoundError();
}
Expand All @@ -94,18 +99,22 @@ export type Router = typeof router;
The wrapper enforces that Effect procedures only use services provided by the `ManagedRuntime`. If you try to use a service that isn't in the runtime, you'll get a compile-time error:

```ts
import { Context, Effect, Layer, ManagedRuntime } from "effect";
import { Effect, Layer, ManagedRuntime, ServiceMap } from "effect";
import { makeEffectORPC } from "effect-orpc";

class ProvidedService extends Context.Tag("ProvidedService")<
class ProvidedService extends ServiceMap.Service<
ProvidedService,
{ doSomething: () => Effect.Effect<string> }
>() {}
{
readonly doSomething: () => Effect.Effect<string>;
}
>()("ProvidedService") {}

class MissingService extends Context.Tag("MissingService")<
class MissingService extends ServiceMap.Service<
MissingService,
{ doSomething: () => Effect.Effect<string> }
>() {}
{
readonly doSomething: () => Effect.Effect<string>;
}
>()("MissingService") {}

const runtime = ManagedRuntime.make(
Layer.succeed(ProvidedService, {
Expand Down
58 changes: 33 additions & 25 deletions bun.lock

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,5 @@ workspaces.

## Available Examples

- `hono-request-context`: Hono + oRPC + Effect with request-scoped `FiberRef`
- `hono-request-context`: Hono + oRPC + Effect v4 with request-scoped context
propagation using `withFiberContext` from `effect-orpc/node`.
3 changes: 2 additions & 1 deletion examples/hono/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ It demonstrates:
- `makeEffectORPC` imported from `effect-orpc`
- `eoc` + `implementEffect` contract routes imported from `effect-orpc`
- `withFiberContext(() => next())` imported from `effect-orpc/node`
- nested Effect services preserving the same request-scoped annotations
- nested Effect services preserving the same request-scoped annotations and
references
- a contract router with:
- shared router-level errors, prefixes, and OpenAPI tags
- per-procedure metadata, inputs, outputs, and route definitions
Expand Down
26 changes: 17 additions & 9 deletions examples/hono/orpc/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,8 @@ const directRouter = {
.output(z.array(orderSchema))
.effect(function* () {
yield* Effect.logInfo("Handler: GET /orders - listing all orders");
const orders = yield* OrderService.listOrders();
const service = yield* OrderService;
const orders = yield* service.listOrders();
return orders.map(toTypedOrder);
}),
test: directProcedureBuilder
Expand Down Expand Up @@ -104,7 +105,10 @@ const contractRouter = contractImplementer.router({
},
orders: {
list: ordersImplementer.list.effect(function* ({ context, input }) {
const orders = (yield* OrderService.listOrders()).map(toTypedOrder);
const service = yield* OrderService;
const orders = yield* service
.listOrders()
.pipe(Effect.map((o) => o.map(toTypedOrder)));
const filtered = input.status
? orders.filter((order) => order.status === input.status)
: orders;
Expand Down Expand Up @@ -135,7 +139,8 @@ const contractRouter = contractImplementer.router({
)
.effect(function* ({ context, input, errors }) {
const normalizedOrderId = input.orderId.trim().toUpperCase();
const order = yield* OrderService.getOrder(normalizedOrderId);
const service = yield* OrderService;
const order = yield* service.getOrder(normalizedOrderId);

if (order.status === "not found") {
return yield* Effect.fail(
Expand Down Expand Up @@ -215,9 +220,8 @@ const contractRouter = contractImplementer.router({
);
}

const order = yield* OrderService.getOrder(
input.orderId.trim().toUpperCase(),
);
const service = yield* OrderService;
const order = yield* service.getOrder(input.orderId.trim().toUpperCase());

if (order.status === "not found") {
return yield* Effect.fail(
Expand Down Expand Up @@ -264,8 +268,9 @@ const contractRouter = contractImplementer.router({
);
}

const service = yield* OrderService;
const orders = yield* Effect.forEach(input.orderIds, (orderId) =>
OrderService.getOrder(orderId.trim().toUpperCase()),
service.getOrder(orderId.trim().toUpperCase()),
);

return {
Expand Down Expand Up @@ -293,8 +298,11 @@ const contractRouter = contractImplementer.router({
);
}

const orders = (yield* OrderService.listOrders()).map(toTypedOrder);
yield* Effect.forEach(orders, (order) => OrderService.getOrder(order.id));
const service = yield* OrderService;
const orders = yield* service
.listOrders()
.pipe(Effect.map((o) => o.map(toTypedOrder)));
yield* Effect.forEach(orders, (order) => service.getOrder(order.id));

return {
requestId: context.requestId,
Expand Down
14 changes: 7 additions & 7 deletions examples/hono/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,19 +7,19 @@
"test": "vitest *.test.ts"
},
"dependencies": {
"@effect/opentelemetry": "^0.60.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.210.0",
"@opentelemetry/sdk-metrics": "^2.4.0",
"@opentelemetry/sdk-trace-base": "^2.4.0",
"@opentelemetry/sdk-trace-node": "^2.4.0",
"@opentelemetry/sdk-trace-web": "^2.4.0",
"@effect/opentelemetry": "^4.0.0-beta.27",
"@opentelemetry/exporter-trace-otlp-http": "^0.211.0",
"@opentelemetry/sdk-metrics": "^2.5.0",
"@opentelemetry/sdk-trace-base": "^2.5.0",
"@opentelemetry/sdk-trace-node": "^2.5.0",
"@opentelemetry/sdk-trace-web": "^2.5.0",
"@orpc/client": "^1.13.4",
"@orpc/contract": "^1.13.4",
"@orpc/openapi": "^1.13.4",
"@orpc/server": "^1.13.4",
"@orpc/zod": "^1.13.4",
"@types/bun": "latest",
"effect": "^3.19.14",
"effect": "^4.0.0-beta.27",
"effect-orpc": "workspace:*",
"hono": "^4.11.4",
"typescript": "^5.9.3",
Expand Down
8 changes: 6 additions & 2 deletions examples/hono/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,12 @@ const NodeSdkLive = NodeSdk.layer(() => ({
),
}));

export const AppLive = Layer.mergeAll(Logger.pretty, OrderService.Default).pipe(
Layer.provideMerge(NodeSdkLive),
const LoggerLive = Logger.layer([Logger.consolePretty()]);

export const AppLive = Layer.mergeAll(
LoggerLive,
NodeSdkLive,
OrderService.layer,
);

export const runtime = ManagedRuntime.make(AppLive);
83 changes: 44 additions & 39 deletions examples/hono/services/cache.ts
Original file line number Diff line number Diff line change
@@ -1,46 +1,51 @@
import { Effect } from "effect";
import { Effect, Layer, ServiceMap } from "effect";

import { DatabaseService } from "./database";

export class CacheService extends Effect.Service<CacheService>()(
"CacheService",
export class CacheService extends ServiceMap.Service<
CacheService,
{
accessors: true,
dependencies: [DatabaseService.Default],
effect: Effect.gen(function* () {
const db = yield* DatabaseService;
const cache = new Map<string, unknown>();
readonly get: (key: string) => Effect.Effect<unknown>;
readonly set: (key: string, value: unknown) => Effect.Effect<boolean>;
}
>()("CacheService", {
make: Effect.gen(function* () {
const db = yield* DatabaseService;
const cache = new Map<string, unknown>();

return {
get: (key: string) =>
Effect.gen(function* () {
yield* Effect.logInfo(`Cache lookup: ${key}`);
return {
get: (key: string) =>
Effect.gen(function* () {
yield* Effect.logInfo(`Cache lookup: ${key}`);

if (cache.has(key)) {
yield* Effect.logInfo(`Cache HIT: ${key}`);
return cache.get(key);
}
if (cache.has(key)) {
yield* Effect.logInfo(`Cache HIT: ${key}`);
return cache.get(key);
}

yield* Effect.logInfo(`Cache MISS: ${key}, fetching from DB`);
const result =
yield* db.query`SELECT * FROM cache WHERE key = '${key}'`;
cache.set(key, result);
return result;
}).pipe(
Effect.annotateLogs("service", "CacheService"),
Effect.withSpan("CacheService.get"),
),
set: (key: string, value: unknown) =>
Effect.gen(function* () {
yield* Effect.logInfo(`Cache set: ${key}`);
cache.set(key, value);
yield* db.insert("cache", { key, value });
return true;
}).pipe(
Effect.annotateLogs("service", "CacheService"),
Effect.withSpan("CacheService.set"),
),
};
}),
},
) {}
yield* Effect.logInfo(`Cache MISS: ${key}, fetching from DB`);
const result =
yield* db.query`SELECT * FROM cache WHERE key = '${key}'`;
cache.set(key, result);
return result;
}).pipe(
Effect.annotateLogs("service", "CacheService"),
Effect.withSpan("CacheService.get"),
),
set: (key: string, value: unknown) =>
Effect.gen(function* () {
yield* Effect.logInfo(`Cache set: ${key}`);
cache.set(key, value);
yield* db.insert("cache", { key, value });
return true;
}).pipe(
Effect.annotateLogs("service", "CacheService"),
Effect.withSpan("CacheService.set"),
),
};
}),
}) {
static readonly layer = Layer.effect(this, this.make).pipe(
Layer.provide(DatabaseService.layer),
);
}
62 changes: 37 additions & 25 deletions examples/hono/services/database.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,39 @@
import { Effect } from "effect";
import { Effect, Layer, ServiceMap } from "effect";

export class DatabaseService extends Effect.Service<DatabaseService>()(
"DatabaseService",
export class DatabaseService extends ServiceMap.Service<
DatabaseService,
{
sync: () => ({
query: (sql: TemplateStringsArray, ..._args: unknown[]) =>
Effect.gen(function* () {
yield* Effect.logInfo(`Executing SQL: ${sql}`);
yield* Effect.sleep("10 millis");
return { rows: [{ id: 1, data: "result" }], rowCount: 1 };
}).pipe(
Effect.annotateLogs("service", "DatabaseService"),
Effect.withSpan("DatabaseService.query"),
),
insert: (table: string, _data: unknown) =>
Effect.gen(function* () {
yield* Effect.logInfo(`Inserting into ${table}`);
yield* Effect.sleep("15 millis");
return { id: Math.floor(Math.random() * 1000), success: true };
}).pipe(
Effect.annotateLogs("service", "DatabaseService"),
Effect.withSpan("DatabaseService.insert"),
),
}),
},
) {}
readonly query: (
sql: TemplateStringsArray,
...args: unknown[]
) => Effect.Effect<{
rows: Array<{ id: number; data: string }>;
rowCount: number;
}>;
readonly insert: (
table: string,
data: unknown,
) => Effect.Effect<{ id: number; success: boolean }>;
}
>()("DatabaseService") {
static readonly layer = Layer.succeed(this, {
query: (sql: TemplateStringsArray, ..._args: unknown[]) =>
Effect.gen(function* () {
yield* Effect.logInfo(`Executing SQL: ${sql}`);
yield* Effect.sleep("10 millis");
return { rows: [{ id: 1, data: "result" }], rowCount: 1 };
}).pipe(
Effect.annotateLogs("service", "DatabaseService"),
Effect.withSpan("DatabaseService.query"),
),
insert: (table: string, _data: unknown) =>
Effect.gen(function* () {
yield* Effect.logInfo(`Inserting into ${table}`);
yield* Effect.sleep("15 millis");
return { id: Math.floor(Math.random() * 1000), success: true };
}).pipe(
Effect.annotateLogs("service", "DatabaseService"),
Effect.withSpan("DatabaseService.insert"),
),
});
}
Loading
Loading