Skip to content

Commit 38781c9

Browse files
committed
feat: add Node fiber context bridge for framework request scope
also refactor repo into workspace and add Hono example
1 parent 0c81aec commit 38781c9

34 files changed

Lines changed: 1159 additions & 70 deletions

.changeset/swift-bikes-doubt.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"effect-orpc": minor
3+
---
4+
5+
Add `withFiberContext` helper at `effect-orpc/node` to
6+
propagate Effect `FiberRef` state across framework async boundaries, and add a
7+
workspace Hono example showing request-scoped log and trace propagation.

README.md

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ pnpm add effect-orpc
2323
bun add effect-orpc
2424
```
2525

26+
Runnable demos live in the repository's `examples/` directory.
27+
2628
## Demo
2729

2830
```ts
@@ -87,6 +89,46 @@ export const router = {
8789
export type Router = typeof router;
8890
```
8991

92+
## Request-Scoped Fiber Context
93+
94+
If you run `effect-orpc` inside a framework such as Hono, the handler executes
95+
through the runtime boundary and will not automatically inherit request-local
96+
`FiberRef` state from outer middleware. Import `makeEffectORPC` from the main
97+
package, and wrap the framework continuation with `withFiberContext` from
98+
`effect-orpc/node` to preserve request-scoped logs, tracing annotations, and
99+
other fiber-local state.
100+
101+
```ts
102+
import { Hono } from "hono";
103+
import { Effect, ManagedRuntime } from "effect";
104+
import { makeEffectORPC } from "effect-orpc";
105+
import { withFiberContext } from "effect-orpc/node";
106+
107+
const runtime = ManagedRuntime.make(AppLive);
108+
const effectOs = makeEffectORPC(runtime);
109+
const app = new Hono();
110+
111+
app.use("*", async (c, next) => {
112+
await Effect.runPromise(
113+
Effect.gen(function* () {
114+
yield* Effect.annotateLogsScoped({
115+
requestId: c.get("requestId"),
116+
});
117+
118+
yield* withFiberContext(() => next());
119+
}),
120+
);
121+
});
122+
```
123+
124+
The reason for the separate `/node` entrypoint is that `withFiberContext` relies
125+
on Node/Bun's `AsyncLocalStorage` from `node:async_hooks` to carry Effect
126+
`FiberRef` state across framework async boundaries. The main package stays
127+
runtime-agnostic.
128+
129+
If you do not need framework-to-handler fiber propagation, you do not need the
130+
`/node` entrypoint at all.
131+
90132
## Type Safety
91133

92134
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:

bun.lock

Lines changed: 168 additions & 18 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

examples/README.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
# Examples
2+
3+
Runnable examples for `effect-orpc`.
4+
5+
Install dependencies from the repository root with `bun install`, then run an
6+
example from its own folder. Examples depend on the local library through Bun
7+
workspaces.
8+
9+
## Available Examples
10+
11+
- `hono-request-context`: Hono + oRPC + Effect with request-scoped `FiberRef`
12+
propagation using `withFiberContext` from `effect-orpc/node`.
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# Hono Request Context
2+
3+
This example mirrors the flow from Rezrazi's `effect-orpc-hono` demo, adapted to
4+
the split import API in this repository.
5+
6+
It demonstrates:
7+
8+
- Hono request middleware annotating logs with a request ID
9+
- `makeEffectORPC` imported from `effect-orpc`
10+
- `withFiberContext(() => next())` imported from `effect-orpc/node`
11+
- nested Effect services preserving the same request-scoped annotations
12+
13+
## Run
14+
15+
```bash
16+
cd /path/to/effect-orpc
17+
bun install
18+
cd examples/hono-request-context
19+
bun start
20+
```
21+
22+
The API is served on `http://localhost:3000/api` by default.
23+
24+
To override the port:
25+
26+
```bash
27+
cd /path/to/effect-orpc/examples/hono-request-context
28+
PORT=43123 bun start
29+
```
30+
31+
## Optional Telemetry Stack
32+
33+
```bash
34+
docker compose up
35+
```
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
services:
2+
otel:
3+
image: grafana/otel-lgtm:latest
4+
ports:
5+
- "3001:3000"
6+
- "4317:4317"
7+
- "4318:4318"
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { OpenAPIHandler } from "@orpc/openapi/fetch";
2+
import { OpenAPIReferencePlugin } from "@orpc/openapi/plugins";
3+
import { onError, os } from "@orpc/server";
4+
import { CORSPlugin } from "@orpc/server/plugins";
5+
import { ZodToJsonSchemaConverter } from "@orpc/zod/zod4";
6+
import { serve } from "bun";
7+
import { type Effect as EffectType, Effect, pipe } from "effect";
8+
import { makeEffectORPC } from "effect-orpc";
9+
import { withFiberContext } from "effect-orpc/node";
10+
import { Hono } from "hono";
11+
import { requestId } from "hono/request-id";
12+
13+
import { runtime } from "./runtime";
14+
import { OrderService } from "./services/order";
15+
16+
const port = Number(process.env.PORT ?? "3000");
17+
18+
const app = new Hono();
19+
app.use("*", requestId());
20+
21+
app.use("/*", async (c, next) => {
22+
const { method, path } = c.req;
23+
const currentRequestId = c.get("requestId");
24+
25+
const requestEffect = Effect.gen(function* () {
26+
yield* Effect.annotateLogsScoped({
27+
requestId: currentRequestId,
28+
service: "backend-service",
29+
});
30+
yield* Effect.logInfo(`[Request] ${method} ${path}`);
31+
yield* withFiberContext(() => next());
32+
yield* Effect.logInfo(`[Response] ${method} ${path} (${c.res.status})`);
33+
}).pipe(Effect.scoped, Effect.withSpan(`${method} ${path}`));
34+
35+
await runtime.runPromise(
36+
requestEffect as EffectType.Effect<void, unknown, never>,
37+
);
38+
});
39+
40+
const o = makeEffectORPC(runtime, os);
41+
42+
const router = {
43+
orders: o.route({ path: "/orders", method: "GET" }).effect(function* () {
44+
yield* Effect.logInfo("Handler: GET /orders - listing all orders");
45+
return yield* OrderService.listOrders();
46+
}),
47+
test: o.route({ path: "/test", method: "GET" }).effect(function* () {
48+
return "ok";
49+
}),
50+
};
51+
52+
const openAPIHandler = new OpenAPIHandler(router, {
53+
plugins: [
54+
new CORSPlugin(),
55+
new OpenAPIReferencePlugin({
56+
schemaConverters: [new ZodToJsonSchemaConverter()],
57+
}),
58+
],
59+
interceptors: [
60+
onError(async (error) => {
61+
await runtime.runPromise(pipe(Effect.logError("oRPC Error", error)));
62+
}),
63+
],
64+
});
65+
66+
app.use("/*", async (c, next) => {
67+
const { matched, response } = await openAPIHandler.handle(c.req.raw, {
68+
prefix: "/api",
69+
});
70+
71+
if (matched) {
72+
return c.newResponse(response.body, response);
73+
}
74+
75+
await next();
76+
});
77+
78+
const server = serve({
79+
fetch: app.fetch,
80+
port,
81+
});
82+
83+
await runtime.runPromise(
84+
pipe(
85+
Effect.logInfo(`Server started on http://localhost:${port}`),
86+
Effect.annotateLogs("service", "backend-service"),
87+
),
88+
);
89+
90+
process.on("SIGINT", () => {
91+
server.stop();
92+
process.exit(0);
93+
});
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
{
2+
"name": "effect-orpc-example-hono-request-context",
3+
"private": true,
4+
"type": "module",
5+
"scripts": {
6+
"start": "bun --watch index.ts"
7+
},
8+
"dependencies": {
9+
"@effect/opentelemetry": "^0.60.0",
10+
"@opentelemetry/exporter-trace-otlp-http": "^0.210.0",
11+
"@opentelemetry/sdk-metrics": "^2.4.0",
12+
"@opentelemetry/sdk-trace-base": "^2.4.0",
13+
"@opentelemetry/sdk-trace-node": "^2.4.0",
14+
"@opentelemetry/sdk-trace-web": "^2.4.0",
15+
"@orpc/openapi": "^1.13.4",
16+
"@orpc/server": "^1.13.4",
17+
"@orpc/zod": "^1.13.4",
18+
"@types/bun": "latest",
19+
"effect": "^3.19.14",
20+
"effect-orpc": "workspace:*",
21+
"hono": "^4.11.4",
22+
"typescript": "^5.9.3",
23+
"zod": "^4.3.5"
24+
}
25+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { NodeSdk } from "@effect/opentelemetry";
2+
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
3+
import { BatchSpanProcessor } from "@opentelemetry/sdk-trace-base";
4+
import { Layer, Logger, ManagedRuntime } from "effect";
5+
6+
import { OrderService } from "./services/order";
7+
8+
const NodeSdkLive = NodeSdk.layer(() => ({
9+
resource: { serviceName: "effect-orpc-hono-request-context" },
10+
spanProcessor: new BatchSpanProcessor(
11+
new OTLPTraceExporter({
12+
url: "http://localhost:4318/v1/traces",
13+
}),
14+
),
15+
}));
16+
17+
export const AppLive = Layer.mergeAll(Logger.pretty, OrderService.Default).pipe(
18+
Layer.provideMerge(NodeSdkLive),
19+
);
20+
21+
export const runtime = ManagedRuntime.make(AppLive);
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { Effect } from "effect";
2+
3+
import { DatabaseService } from "./database";
4+
5+
export class CacheService extends Effect.Service<CacheService>()(
6+
"CacheService",
7+
{
8+
accessors: true,
9+
dependencies: [DatabaseService.Default],
10+
effect: Effect.gen(function* () {
11+
const db = yield* DatabaseService;
12+
const cache = new Map<string, unknown>();
13+
14+
return {
15+
get: (key: string) =>
16+
Effect.gen(function* () {
17+
yield* Effect.logInfo(`Cache lookup: ${key}`);
18+
19+
if (cache.has(key)) {
20+
yield* Effect.logInfo(`Cache HIT: ${key}`);
21+
return cache.get(key);
22+
}
23+
24+
yield* Effect.logInfo(`Cache MISS: ${key}, fetching from DB`);
25+
const result = yield* db.query(
26+
`SELECT * FROM cache WHERE key = '${key}'`,
27+
);
28+
cache.set(key, result);
29+
return result;
30+
}).pipe(
31+
Effect.annotateLogs("service", "CacheService"),
32+
Effect.withSpan("CacheService.get"),
33+
),
34+
set: (key: string, value: unknown) =>
35+
Effect.gen(function* () {
36+
yield* Effect.logInfo(`Cache set: ${key}`);
37+
cache.set(key, value);
38+
yield* db.insert("cache", { key, value });
39+
return true;
40+
}).pipe(
41+
Effect.annotateLogs("service", "CacheService"),
42+
Effect.withSpan("CacheService.set"),
43+
),
44+
};
45+
}),
46+
},
47+
) {}

0 commit comments

Comments
 (0)