opencode-effect-enforcer 0.2.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.
- package/LICENSE +21 -0
- package/README.md +278 -0
- package/guidance/effect-first-development.md +1247 -0
- package/guidance/post__effect-and-the-near-inexpressible-majesty-of-layers.md +490 -0
- package/guidance/post__parse-dont-validate.md +109 -0
- package/guidance/progressive-disclosure-guidance.md +38 -0
- package/package.json +63 -0
- package/patterns/avoid-any.md +37 -0
- package/patterns/avoid-data-tagged-error.md +34 -0
- package/patterns/avoid-direct-json.md +51 -0
- package/patterns/avoid-direct-tag-checks.md +54 -0
- package/patterns/avoid-expect-in-if.md +52 -0
- package/patterns/avoid-mutable-state.md +70 -0
- package/patterns/avoid-native-fetch.md +61 -0
- package/patterns/avoid-node-imports.md +86 -0
- package/patterns/avoid-non-null-assertion.md +44 -0
- package/patterns/avoid-object-type.md +46 -0
- package/patterns/avoid-option-getorthrow.md +39 -0
- package/patterns/avoid-platform-coupling.md +43 -0
- package/patterns/avoid-process-env.md +43 -0
- package/patterns/avoid-react-hooks.md +73 -0
- package/patterns/avoid-schema-suffix.md +45 -0
- package/patterns/avoid-sync-fs.md +68 -0
- package/patterns/avoid-try-catch.md +47 -0
- package/patterns/avoid-ts-ignore.md +38 -0
- package/patterns/avoid-untagged-errors.md +67 -0
- package/patterns/avoid-yield-ref.md +46 -0
- package/patterns/casting-awareness.md +46 -0
- package/patterns/context-tag-extends.md +84 -0
- package/patterns/effect-catchall-default.md +61 -0
- package/patterns/effect-promise-vs-trypromise.md +47 -0
- package/patterns/effect-run-in-body.md +58 -0
- package/patterns/imperative-loops.md +76 -0
- package/patterns/prefer-arr-sort.md +52 -0
- package/patterns/prefer-duration-values.md +56 -0
- package/patterns/prefer-effect-fn.md +161 -0
- package/patterns/prefer-match-over-switch.md +48 -0
- package/patterns/prefer-option-over-null.md +56 -0
- package/patterns/prefer-redacted-config.md +70 -0
- package/patterns/prefer-schema-class.md +54 -0
- package/patterns/require-effect-concurrency.md +83 -0
- package/patterns/stream-large-files.md +63 -0
- package/patterns/throw-in-effect-gen.md +62 -0
- package/patterns/use-clock-service.md +45 -0
- package/patterns/use-command-executor-service.md +54 -0
- package/patterns/use-console-service.md +54 -0
- package/patterns/use-filesystem-service.md +59 -0
- package/patterns/use-http-client-service.md +77 -0
- package/patterns/use-path-service.md +53 -0
- package/patterns/use-random-service.md +45 -0
- package/patterns/use-temp-file-scoped.md +66 -0
- package/patterns/vm-in-wrong-file.md +51 -0
- package/patterns/yield-in-for-loop.md +61 -0
- package/skills/effect-ai-chat/SKILL.md +472 -0
- package/skills/effect-ai-language-model/SKILL.md +652 -0
- package/skills/effect-ai-prompt/SKILL.md +752 -0
- package/skills/effect-ai-provider/SKILL.md +668 -0
- package/skills/effect-ai-streaming/SKILL.md +418 -0
- package/skills/effect-ai-tool/SKILL.md +1132 -0
- package/skills/effect-atom-rpc/SKILL.md +488 -0
- package/skills/effect-atom-state/SKILL.md +640 -0
- package/skills/effect-batching/SKILL.md +614 -0
- package/skills/effect-cache/SKILL.md +570 -0
- package/skills/effect-cli/SKILL.md +523 -0
- package/skills/effect-command-executor/SKILL.md +675 -0
- package/skills/effect-concurrency-testing/SKILL.md +612 -0
- package/skills/effect-config/SKILL.md +580 -0
- package/skills/effect-context-witness/SKILL.md +274 -0
- package/skills/effect-domain-modeling/SKILL.md +1212 -0
- package/skills/effect-domain-predicates/SKILL.md +867 -0
- package/skills/effect-error-handling/SKILL.md +1581 -0
- package/skills/effect-fiber/SKILL.md +731 -0
- package/skills/effect-filesystem/SKILL.md +624 -0
- package/skills/effect-graph/SKILL.md +571 -0
- package/skills/effect-http-api/SKILL.md +1760 -0
- package/skills/effect-http-client/SKILL.md +989 -0
- package/skills/effect-http-server/SKILL.md +920 -0
- package/skills/effect-incremental-migration/SKILL.md +362 -0
- package/skills/effect-layer-design/SKILL.md +642 -0
- package/skills/effect-managed-runtime/SKILL.md +395 -0
- package/skills/effect-mcp-server/SKILL.md +608 -0
- package/skills/effect-observability/SKILL.md +719 -0
- package/skills/effect-optics/SKILL.md +554 -0
- package/skills/effect-parallelization/SKILL.md +668 -0
- package/skills/effect-path/SKILL.md +296 -0
- package/skills/effect-pattern-matching/SKILL.md +914 -0
- package/skills/effect-platform-abstraction/SKILL.md +1175 -0
- package/skills/effect-platform-layers/SKILL.md +514 -0
- package/skills/effect-pubsub-event-bus/SKILL.md +384 -0
- package/skills/effect-react-composition/SKILL.md +986 -0
- package/skills/effect-react-vm/SKILL.md +675 -0
- package/skills/effect-rpc-api/SKILL.md +624 -0
- package/skills/effect-rpc-client/SKILL.md +666 -0
- package/skills/effect-rpc-cluster/SKILL.md +1623 -0
- package/skills/effect-rpc-server/SKILL.md +767 -0
- package/skills/effect-scheduling/SKILL.md +124 -0
- package/skills/effect-schema-composition/SKILL.md +975 -0
- package/skills/effect-schema-v4/SKILL.md +691 -0
- package/skills/effect-scope/SKILL.md +682 -0
- package/skills/effect-service-implementation/SKILL.md +656 -0
- package/skills/effect-socket/SKILL.md +703 -0
- package/skills/effect-sql/SKILL.md +781 -0
- package/skills/effect-stream/SKILL.md +765 -0
- package/skills/effect-testing/SKILL.md +1331 -0
- package/skills/effect-typeclass-design/SKILL.md +161 -0
- package/skills/effect-wide-events/Article.md +66 -0
- package/skills/effect-wide-events/SKILL.md +95 -0
- package/skills/effect-workflow/SKILL.md +810 -0
- package/src/agent-policy.ts +22 -0
- package/src/enforcer.ts +104 -0
- package/src/frontmatter.ts +34 -0
- package/src/guidance.ts +66 -0
- package/src/index.ts +38 -0
- package/src/pattern-catalog.ts +115 -0
- package/src/pattern-matcher.ts +178 -0
- package/src/pattern.ts +97 -0
- package/src/skills.ts +29 -0
- package/src/write-projection.ts +66 -0
|
@@ -0,0 +1,1623 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: effect-rpc-cluster
|
|
3
|
+
description: Build typed RPC endpoints and cluster-distributed entities, singletons, cron jobs, and durable workflows with Effect's RPC and Cluster modules (Rpc/RpcGroup/RpcServer/RpcClient, Entity/Sharding/Singleton, Node/Bun bundles). Use when building RPC services or distributed/clustered Effect systems.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
You are an Effect TypeScript expert specializing in `effect/unstable/rpc` and `effect/unstable/cluster`.
|
|
7
|
+
|
|
8
|
+
These modules live under `effect/unstable/*`. There are no `@effect/rpc` or `@effect/cluster` packages in v4 — everything ships from the `effect` package. APIs may move between betas.
|
|
9
|
+
|
|
10
|
+
## Effect Source Reference
|
|
11
|
+
|
|
12
|
+
The Effect v4 source is at `~/.local/share/opencode/repos/github.com/Effect-TS/effect@main/`. Read it directly when in doubt — the shape of these modules changes more often than the website docs.
|
|
13
|
+
|
|
14
|
+
Key files:
|
|
15
|
+
|
|
16
|
+
- `packages/effect/src/unstable/rpc/Rpc.ts` — `Rpc.make`, custom constructors, `Wrapper`, `ServerClient`, `exitSchema`
|
|
17
|
+
- `packages/effect/src/unstable/rpc/RpcGroup.ts` — group construction, handler wiring (`toLayer` / `toHandlers` / `toLayerHandler` / `accessHandler`), prefixing, omit/merge, annotations
|
|
18
|
+
- `packages/effect/src/unstable/rpc/RpcServer.ts` — `make`, `layer`, `layerHttp`, every `layerProtocol*` and `toHttpEffect*`
|
|
19
|
+
- `packages/effect/src/unstable/rpc/RpcClient.ts` — `make`, `Protocol`, every `layerProtocol*`, `withHeaders`, `CurrentHeaders`, `ConnectionHooks`
|
|
20
|
+
- `packages/effect/src/unstable/rpc/RpcMiddleware.ts` — `Service` constructor, `layerClient`
|
|
21
|
+
- `packages/effect/src/unstable/rpc/RpcSerialization.ts` — json/ndjson/jsonRpc/ndJsonRpc/msgPack codecs and their layers
|
|
22
|
+
- `packages/effect/src/unstable/rpc/RpcTest.ts` — in-process test client
|
|
23
|
+
- `packages/effect/src/unstable/rpc/RpcWorker.ts` — `InitialMessage` for worker transports
|
|
24
|
+
- `packages/effect/src/unstable/rpc/RpcSchema.ts` — `Stream` schema marker, `ClientAbort` cause annotation
|
|
25
|
+
- `packages/effect/src/unstable/rpc/RpcClientError.ts` — client-side error union
|
|
26
|
+
- `packages/effect/src/unstable/cluster/Entity.ts` — `Entity.make` / `fromRpcGroup`, handler envelopes, `Replier`, `CurrentAddress`, `keepAlive`, `makeTestClient`
|
|
27
|
+
- `packages/effect/src/unstable/cluster/ClusterSchema.ts` — `Persisted`, `Uninterruptible`, `WithTransaction`, `ShardGroup`, `ClientTracingEnabled`, `Dynamic`
|
|
28
|
+
- `packages/effect/src/unstable/cluster/ClusterError.ts` — `MailboxFull`, `AlreadyProcessingMessage`, `PersistenceError`, `EntityNotAssignedToRunner`, `MalformedMessage`, `RunnerUnavailable`, `RunnerNotRegistered`
|
|
29
|
+
- `packages/effect/src/unstable/cluster/Sharding.ts` — the `Sharding` service surface
|
|
30
|
+
- `packages/effect/src/unstable/cluster/ShardingConfig.ts` — config schema + env loader
|
|
31
|
+
- `packages/effect/src/unstable/cluster/Singleton.ts` — singleton-per-cluster effects
|
|
32
|
+
- `packages/effect/src/unstable/cluster/ClusterCron.ts` — cron-driven singletons
|
|
33
|
+
- `packages/effect/src/unstable/cluster/SingleRunner.ts` — single-node sql-backed bundle
|
|
34
|
+
- `packages/effect/src/unstable/cluster/TestRunner.ts` — in-memory testing bundle
|
|
35
|
+
- `packages/effect/src/unstable/cluster/EntityProxy.ts` + `EntityProxyServer.ts` — entity ↔ RPC/HTTP bridge
|
|
36
|
+
- `packages/effect/src/unstable/workflow/WorkflowProxy.ts` + `WorkflowProxyServer.ts` — workflow ↔ RPC/HTTP bridge
|
|
37
|
+
- `packages/effect/src/unstable/cluster/ClusterWorkflowEngine.ts` — production workflow engine backed by sharding + storage
|
|
38
|
+
- `packages/effect/src/unstable/reactivity/AtomRpc.ts` — reactive RPC client for Atom UIs (see also `effect-atom-rpc` skill)
|
|
39
|
+
- `packages/platform-node/src/NodeClusterHttp.ts` / `NodeClusterSocket.ts` — Node "all-in-one" cluster layers
|
|
40
|
+
- `packages/platform-bun/src/BunClusterHttp.ts` / `BunClusterSocket.ts` — Bun equivalents
|
|
41
|
+
- `packages/platform-node/test/RpcServer.test.ts` + `test/fixtures/rpc-{schemas,e2e}.ts` — best end-to-end reference for real RPC wiring
|
|
42
|
+
- `packages/effect/test/cluster/TestEntity.ts` + `test/cluster/Entity.test.ts` — best reference for Entity + makeTestClient
|
|
43
|
+
|
|
44
|
+
## Imports
|
|
45
|
+
|
|
46
|
+
```ts
|
|
47
|
+
// RPC
|
|
48
|
+
import {
|
|
49
|
+
Rpc,
|
|
50
|
+
RpcClient,
|
|
51
|
+
RpcGroup,
|
|
52
|
+
RpcMiddleware,
|
|
53
|
+
RpcSchema,
|
|
54
|
+
RpcSerialization,
|
|
55
|
+
RpcServer,
|
|
56
|
+
RpcTest,
|
|
57
|
+
RpcWorker
|
|
58
|
+
} from 'effect/unstable/rpc';
|
|
59
|
+
import { RpcClientError } from 'effect/unstable/rpc/RpcClientError';
|
|
60
|
+
|
|
61
|
+
// Cluster
|
|
62
|
+
import {
|
|
63
|
+
ClusterCron,
|
|
64
|
+
ClusterError,
|
|
65
|
+
ClusterSchema,
|
|
66
|
+
Entity,
|
|
67
|
+
EntityProxy,
|
|
68
|
+
EntityProxyServer,
|
|
69
|
+
MessageStorage,
|
|
70
|
+
RunnerHealth,
|
|
71
|
+
Runners,
|
|
72
|
+
RunnerStorage,
|
|
73
|
+
Sharding,
|
|
74
|
+
ShardingConfig,
|
|
75
|
+
SingleRunner,
|
|
76
|
+
Singleton,
|
|
77
|
+
SqlMessageStorage,
|
|
78
|
+
SqlRunnerStorage,
|
|
79
|
+
TestRunner
|
|
80
|
+
} from 'effect/unstable/cluster';
|
|
81
|
+
|
|
82
|
+
// Workflow (see effect-workflow skill for the full surface)
|
|
83
|
+
import {
|
|
84
|
+
Activity,
|
|
85
|
+
DurableClock,
|
|
86
|
+
DurableDeferred,
|
|
87
|
+
Workflow,
|
|
88
|
+
WorkflowProxy,
|
|
89
|
+
WorkflowProxyServer
|
|
90
|
+
} from 'effect/unstable/workflow';
|
|
91
|
+
import { ClusterWorkflowEngine } from 'effect/unstable/cluster';
|
|
92
|
+
|
|
93
|
+
// Platform "all-in-one" cluster bundles
|
|
94
|
+
import { NodeClusterHttp, NodeClusterSocket } from '@effect/platform-node';
|
|
95
|
+
// or
|
|
96
|
+
import { BunClusterHttp, BunClusterSocket } from '@effect/platform-bun';
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## Architecture at a Glance
|
|
100
|
+
|
|
101
|
+
```
|
|
102
|
+
wire format (json | ndjson | msgpack | jsonRpc | ndJsonRpc)
|
|
103
|
+
│
|
|
104
|
+
┌──────────────┐ Protocol │ Protocol ┌──────────────┐
|
|
105
|
+
│ RpcClient │ ───────────────► │ ◄───────────────── │ RpcServer │
|
|
106
|
+
│ (make) │ http/ws/socket/ │ http/ws/socket/ │ (layer) │
|
|
107
|
+
└──────┬───────┘ stdio/worker │ stdio/worker └──────┬───────┘
|
|
108
|
+
│ │
|
|
109
|
+
client middlewares server middlewares
|
|
110
|
+
│ │
|
|
111
|
+
▼ ▼
|
|
112
|
+
RpcGroup.make(...rpcs) ◄── shared definition ──► RpcGroup.toLayer(handlers)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
For distributed actor-style state:
|
|
116
|
+
|
|
117
|
+
┌──────────────────────────┐ entity rpcs travel through
|
|
118
|
+
│ Entity.make(type,rpcs) │ ───► MessageStorage (durable) and
|
|
119
|
+
│ ─ toLayer(handlers) │ routed by Sharding to the
|
|
120
|
+
│ ─ toLayerQueue(...) │ runner that owns the entityId's shard
|
|
121
|
+
│ ─ client │
|
|
122
|
+
└──────────────────────────┘
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Two big invariants:
|
|
126
|
+
|
|
127
|
+
1. **An `Rpc` is a definition.** The same `Rpc` value can be served by an `RpcServer`, called by an `RpcClient`, mounted in an `Entity`, exposed via `EntityProxy.toRpcGroup`/`toHttpApiGroup`, or driven from `AtomRpc.query`/`mutation`. Define rpcs in a shared module so all sides share types.
|
|
128
|
+
2. **`RpcGroup` handlers and `Entity` handlers have different signatures.** RpcGroup handlers take `(payload, options)`; Entity handlers take `(envelope)`. Mixing them up is the most common mistake.
|
|
129
|
+
|
|
130
|
+
## Defining RPCs
|
|
131
|
+
|
|
132
|
+
`Rpc.make(tag, options?)` returns an `Rpc` value. It is *both* a value and a constructor — you can use it either way:
|
|
133
|
+
|
|
134
|
+
```ts
|
|
135
|
+
import { Schema } from 'effect';
|
|
136
|
+
import { Rpc } from 'effect/unstable/rpc';
|
|
137
|
+
|
|
138
|
+
// Style A — const value. Compact, fine for ad-hoc rpcs.
|
|
139
|
+
export const Ping = Rpc.make('Ping', { success: Schema.String });
|
|
140
|
+
|
|
141
|
+
// Style B — class extends. Gives the rpc a nominal class identity, useful
|
|
142
|
+
// when you want to import it as a type and pattern-match on it.
|
|
143
|
+
export class GetUser extends Rpc.make('GetUser', {
|
|
144
|
+
success: User,
|
|
145
|
+
payload: { id: Schema.String }
|
|
146
|
+
}) {}
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
Both styles are official. The platform-node test fixtures and the cluster test fixtures use both deliberately. Pick by feel:
|
|
150
|
+
|
|
151
|
+
- `class extends` when the rpc is shared across many modules and the nominal type helps documentation/imports
|
|
152
|
+
- `const` when you're listing a dozen rpcs in one file and the boilerplate hurts more than the nominal type helps
|
|
153
|
+
|
|
154
|
+
> Note: this is **not** the same as `Workflow.make`, `Activity.make`, `Entity.make`, or `RpcGroup.make` — those all return plain values you assign with `const`. The class-extends pattern is unique to `Rpc.make` (and to `Schema.Class`-style constructors) because `Rpc` declares `new (_: never): {}` in its interface.
|
|
155
|
+
|
|
156
|
+
### `Rpc.make` options
|
|
157
|
+
|
|
158
|
+
```ts
|
|
159
|
+
Rpc.make(tag, {
|
|
160
|
+
payload?: Schema.Top | Schema.Struct.Fields, // struct fields or a Schema
|
|
161
|
+
success?: Schema.Top, // default Schema.Void
|
|
162
|
+
error?: Schema.Top, // default Schema.Never
|
|
163
|
+
defect?: Schema.Top, // default Schema.Defect()
|
|
164
|
+
stream?: boolean, // default false
|
|
165
|
+
primaryKey?: (payload) => string // for cluster dedup / persistence
|
|
166
|
+
})
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
#### Payload as struct fields vs Schema
|
|
170
|
+
|
|
171
|
+
Passing a `Schema.Struct.Fields` literal lets `Rpc.make` build the struct for you. Passing a `Schema.Class` (or any `Schema.Top`) lets you reuse a named type:
|
|
172
|
+
|
|
173
|
+
```ts
|
|
174
|
+
// inline fields
|
|
175
|
+
Rpc.make('CreateUser', {
|
|
176
|
+
payload: { name: Schema.String, email: Schema.String },
|
|
177
|
+
success: User
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
// named class — preferred when the payload is reused
|
|
181
|
+
class CreateUserInput extends Schema.Class<CreateUserInput>('CreateUserInput')({
|
|
182
|
+
name: Schema.String,
|
|
183
|
+
email: Schema.String
|
|
184
|
+
}) {}
|
|
185
|
+
Rpc.make('CreateUser', { payload: CreateUserInput, success: User });
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
#### `defect` — custom defect schema (round-trip preservation)
|
|
189
|
+
|
|
190
|
+
By default `Rpc.make` uses `Schema.Defect()`, which round-trips defects as `unknown`. To keep stack traces, custom error names, or other defect properties intact across the wire, set an explicit defect schema:
|
|
191
|
+
|
|
192
|
+
```ts
|
|
193
|
+
import { Schema } from 'effect';
|
|
194
|
+
|
|
195
|
+
const DiagnosticDefect = Schema.Struct({
|
|
196
|
+
name: Schema.String,
|
|
197
|
+
message: Schema.String,
|
|
198
|
+
stack: Schema.OptionFromNullishOr(Schema.String)
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
const Risky = Rpc.make('Risky', {
|
|
202
|
+
success: Schema.Void,
|
|
203
|
+
defect: Schema.Defect({ includeStack: true })
|
|
204
|
+
});
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
The cluster test fixture uses this: a handler does `Effect.die({ message, stack, name: 'CustomDefect' })` and the client receives the full object with stack intact.
|
|
208
|
+
|
|
209
|
+
#### `primaryKey` — deterministic envelope identity
|
|
210
|
+
|
|
211
|
+
`primaryKey` is required for cluster persistence to dedupe a request: the same payload that produces the same key will be treated as the same envelope, so retried sends are safe. It also makes `Rpc.make` build a `Schema.Class` for the payload (with `PrimaryKey.symbol` implemented) so `instanceof` works.
|
|
212
|
+
|
|
213
|
+
```ts
|
|
214
|
+
const Charge = Rpc.make('Charge', {
|
|
215
|
+
payload: { invoiceId: Schema.String, amountCents: Schema.Int },
|
|
216
|
+
success: ChargeReceipt,
|
|
217
|
+
error: ChargeError,
|
|
218
|
+
primaryKey: ({ invoiceId }) => invoiceId
|
|
219
|
+
});
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
#### `stream: true`
|
|
223
|
+
|
|
224
|
+
When true, `success` becomes the *element* schema, not the Effect's success. The actual return type the handler must produce is `Stream<success, error, R>` (or an `Effect<Queue.Dequeue<success, error | Cause.Done>, ...>` if the handler wants to control the queue itself). The client sees `Stream<success, error, R>` (default) or `Queue.Dequeue<success, error | Cause.Done>` if you pass `{ asQueue: true }`.
|
|
225
|
+
|
|
226
|
+
```ts
|
|
227
|
+
const Subscribe = Rpc.make('Subscribe', {
|
|
228
|
+
payload: { topic: Schema.String },
|
|
229
|
+
success: EventMessage, // element type
|
|
230
|
+
error: SubscriptionError,
|
|
231
|
+
stream: true
|
|
232
|
+
});
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
### Pipeable rpc combinators
|
|
236
|
+
|
|
237
|
+
An `Rpc` is `Pipeable`. The instance methods you'll actually use:
|
|
238
|
+
|
|
239
|
+
```ts
|
|
240
|
+
Rpc.make('GetUser', { ... })
|
|
241
|
+
.middleware(AuthMiddleware) // attach middleware
|
|
242
|
+
.annotate(ClusterSchema.Persisted, true) // single annotation
|
|
243
|
+
.annotateMerge(otherContext) // merge a Context.Context<I>
|
|
244
|
+
.prefix('users.') // becomes 'users.GetUser'
|
|
245
|
+
.setSuccess(NewSuccessSchema) // swap the success schema
|
|
246
|
+
.setError(NewErrorSchema) // swap the error schema
|
|
247
|
+
.setPayload({ id: Schema.String }) // swap the payload schema
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
`prefix` is the right way to namespace rpcs when merging groups. `annotate` puts data on the rpc itself; `RpcGroup` has a separate `annotateRpcs` for marking *every rpc currently in the group*.
|
|
251
|
+
|
|
252
|
+
### `Rpc.fork` and `Rpc.uninterruptible`
|
|
253
|
+
|
|
254
|
+
These are **wrappers**, not options. They wrap a handler's *return value* (Effect or Stream) and tell the server to:
|
|
255
|
+
|
|
256
|
+
- `Rpc.fork(value)` — bypass the server instance's shared concurrency semaphore. Use for read-only or idempotent handlers that should not back up behind sequential ones.
|
|
257
|
+
- `Rpc.uninterruptible(value)` — run the handler in `Effect.uninterruptible`. Use for handlers that must complete (cleanup, finalize-then-return) regardless of client cancellation.
|
|
258
|
+
- `Rpc.wrap({ fork?, uninterruptible? })(value)` — apply both at once.
|
|
259
|
+
|
|
260
|
+
```ts
|
|
261
|
+
GetCount: () => Ref.get(count).pipe(Rpc.fork);
|
|
262
|
+
Charge: (payload) => chargeIdempotent(payload).pipe(Rpc.uninterruptible);
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
If you ever need to introspect: `Rpc.isWrapper(value)`, `Rpc.unwrap(value)`, `Rpc.wrapMap(value, f)`.
|
|
266
|
+
|
|
267
|
+
### `Rpc.exitSchema(rpc)`
|
|
268
|
+
|
|
269
|
+
Returns a `Schema.Exit<Success, Error, Defect>` for the rpc that includes any middleware-added errors. Useful for testing serialization or building generic envelope inspectors.
|
|
270
|
+
|
|
271
|
+
### `Rpc.custom` — higher-order rpc constructors
|
|
272
|
+
|
|
273
|
+
Rare but powerful: build a constructor that transforms every rpc's success/error schemas. Lets you encode a convention like "every list endpoint returns a paginated wrapper":
|
|
274
|
+
|
|
275
|
+
```ts
|
|
276
|
+
import { Rpc } from 'effect/unstable/rpc';
|
|
277
|
+
import { Schema } from 'effect';
|
|
278
|
+
|
|
279
|
+
interface PaginatedRpc extends Rpc.Custom {
|
|
280
|
+
readonly out: Rpc.Custom.Out<
|
|
281
|
+
Schema.Struct<{
|
|
282
|
+
offset: typeof Schema.Number;
|
|
283
|
+
total: typeof Schema.Number;
|
|
284
|
+
results: Schema.$Array<this['success']>;
|
|
285
|
+
}>,
|
|
286
|
+
this['error']
|
|
287
|
+
>;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const paginatedRpc = Rpc.custom<PaginatedRpc>((schemas) => ({
|
|
291
|
+
...schemas,
|
|
292
|
+
success: Schema.Struct({
|
|
293
|
+
offset: Schema.Number,
|
|
294
|
+
total: Schema.Number,
|
|
295
|
+
results: Schema.Array(schemas.success)
|
|
296
|
+
})
|
|
297
|
+
}));
|
|
298
|
+
|
|
299
|
+
// then use exactly like Rpc.make
|
|
300
|
+
const ListUsers = paginatedRpc('listUsers', { success: User });
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
## RpcGroup
|
|
304
|
+
|
|
305
|
+
`RpcGroup.make(...rpcs)` collects rpcs. Variadic — not named.
|
|
306
|
+
|
|
307
|
+
```ts
|
|
308
|
+
const UsersGroup = RpcGroup.make(GetUser, CreateUser, DeleteUser);
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
### Combining groups
|
|
312
|
+
|
|
313
|
+
```ts
|
|
314
|
+
UsersGroup
|
|
315
|
+
.add(ListUsers, UpdateUser) // append rpcs
|
|
316
|
+
.merge(OrdersGroup, PaymentsGroup) // union of groups (later annotations win)
|
|
317
|
+
.omit('DeleteUser') // remove by tag
|
|
318
|
+
.prefix('v2.'); // namespace every rpc
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
`merge` is shallow on both rpcs and group annotations — the *latest* value for any annotation key wins. If you need deeper composition, build the group from scratch.
|
|
322
|
+
|
|
323
|
+
### Group-level annotations
|
|
324
|
+
|
|
325
|
+
There are two flavors and both have a `Merge` variant:
|
|
326
|
+
|
|
327
|
+
```ts
|
|
328
|
+
group.annotate(SomeKey, value); // attach to the group itself
|
|
329
|
+
group.annotateMerge(context); // merge a Context.Context<I> into the group
|
|
330
|
+
group.annotateRpcs(SomeKey, value); // attach to every rpc currently in the group
|
|
331
|
+
group.annotateRpcsMerge(context); // merge a Context.Context<I> into every rpc
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
`annotateRpcs*` is the canonical way to mark a whole group `Persisted`, `Uninterruptible`, etc., without touching each rpc:
|
|
335
|
+
|
|
336
|
+
```ts
|
|
337
|
+
const PersistedUsers = UsersGroup.annotateRpcs(ClusterSchema.Persisted, true);
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
### Adding middleware to a group
|
|
341
|
+
|
|
342
|
+
`group.middleware(M)` appends `M` to *every rpc currently in the group* and returns a new group:
|
|
343
|
+
|
|
344
|
+
```ts
|
|
345
|
+
const AuthedUsers = UsersGroup.middleware(AuthMiddleware);
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
Rpcs added afterward via `.add(...)` won't have the middleware automatically — apply `.middleware(...)` again, or call it on the rpc directly before adding.
|
|
349
|
+
|
|
350
|
+
## Server-side handlers
|
|
351
|
+
|
|
352
|
+
A handler for an `RpcGroup` rpc has this shape:
|
|
353
|
+
|
|
354
|
+
```ts
|
|
355
|
+
type Handler<R extends Rpc.Any> = (
|
|
356
|
+
payload: Rpc.Payload<R>,
|
|
357
|
+
options: {
|
|
358
|
+
readonly client: Rpc.ServerClient; // per-connection identity + annotations
|
|
359
|
+
readonly requestId: RequestId;
|
|
360
|
+
readonly headers: Headers;
|
|
361
|
+
readonly rpc: R;
|
|
362
|
+
}
|
|
363
|
+
) => Effect<Result, Error, Services> | Stream<Result, Error, Services>;
|
|
364
|
+
```
|
|
365
|
+
|
|
366
|
+
Note: the option is `client: ServerClient`, not `clientId: number`. `ServerClient` exposes `client.id: number` and a mutable `annotations: Context.Context<never>` you can extend with `client.annotate(key, value)` from middleware.
|
|
367
|
+
|
|
368
|
+
Entity handlers have a *different* signature — see the Entity section.
|
|
369
|
+
|
|
370
|
+
### Deferred responses
|
|
371
|
+
|
|
372
|
+
A non-stream handler may return an `Effect` that succeeds with a `Deferred<Success, Error>` instead of the success value directly. The server acknowledges the request but **does not send the final `Exit`** until that `Deferred` completes — useful when the result depends on a later external event and you don't want to hold a streaming connection open:
|
|
373
|
+
|
|
374
|
+
```ts
|
|
375
|
+
import { Deferred, Effect } from 'effect';
|
|
376
|
+
|
|
377
|
+
GetUserDeferred: () => {
|
|
378
|
+
const deferred = Deferred.makeUnsafe<User>();
|
|
379
|
+
// complete it later — e.g. from a webhook, another fiber, or a queue worker
|
|
380
|
+
Deferred.doneUnsafe(deferred, Effect.succeed(new User({ id: '1', name: 'John' })));
|
|
381
|
+
return Effect.succeed(deferred);
|
|
382
|
+
};
|
|
383
|
+
```
|
|
384
|
+
|
|
385
|
+
The client still sees a plain `Effect<Success, Error>`; the deferred round-trip is invisible on the wire.
|
|
386
|
+
|
|
387
|
+
### `group.toLayer(handlers | Effect<handlers>)`
|
|
388
|
+
|
|
389
|
+
The 80% case. Build all handlers and turn the result into a Layer that the server picks up:
|
|
390
|
+
|
|
391
|
+
```ts
|
|
392
|
+
const UsersLive = UsersGroup.toLayer(
|
|
393
|
+
Effect.gen(function*() {
|
|
394
|
+
const db = yield* Database;
|
|
395
|
+
return UsersGroup.of({
|
|
396
|
+
GetUser: (payload) => db.findUser(payload.id),
|
|
397
|
+
CreateUser: (payload, { client, headers }) =>
|
|
398
|
+
db
|
|
399
|
+
.createUser(payload)
|
|
400
|
+
.pipe(Effect.tap(() => Effect.logInfo('user created by', client.id))),
|
|
401
|
+
DeleteUser: (payload) => db.deleteUser(payload.id)
|
|
402
|
+
});
|
|
403
|
+
})
|
|
404
|
+
);
|
|
405
|
+
```
|
|
406
|
+
|
|
407
|
+
`group.of(handlers)` is a no-op identity helper that *typechecks* the handler shape against the group. Always use it inside `toLayer` so type errors point at the wrong handler.
|
|
408
|
+
|
|
409
|
+
### `group.toLayerHandler(tag, handler | Effect<handler>)`
|
|
410
|
+
|
|
411
|
+
Implement *one* handler at a time. Useful when handlers have wildly different dependencies and you want to keep them in separate files:
|
|
412
|
+
|
|
413
|
+
```ts
|
|
414
|
+
const GetUserLive = UsersGroup.toLayerHandler(
|
|
415
|
+
'GetUser',
|
|
416
|
+
Effect.gen(function*() {
|
|
417
|
+
const db = yield* Database;
|
|
418
|
+
return (payload) => db.findUser(payload.id);
|
|
419
|
+
})
|
|
420
|
+
);
|
|
421
|
+
|
|
422
|
+
// Compose them:
|
|
423
|
+
const UsersLive = Layer.mergeAll(GetUserLive, CreateUserLive, DeleteUserLive);
|
|
424
|
+
```
|
|
425
|
+
|
|
426
|
+
Each `toLayerHandler` produces `Layer<Rpc.Handler<Tag>, ...>`. The server requires the union `Rpc.ToHandler<Rpcs>` so leaving any tag unimplemented is a compile-time error.
|
|
427
|
+
|
|
428
|
+
### `group.toHandlers(handlers)`
|
|
429
|
+
|
|
430
|
+
Returns an `Effect<Context.Context<Rpc.ToHandler<R>>>` — the unprovided form of `toLayer`. Use it when composing manually inside `RpcServer.make` or `RpcTest.makeClient`.
|
|
431
|
+
|
|
432
|
+
### `group.accessHandler(tag)`
|
|
433
|
+
|
|
434
|
+
Returns an Effect that resolves to a single handler function with `services` already provided. The handler is callable as `(payload, options)` directly. This is the easiest way to unit-test one rpc handler in isolation:
|
|
435
|
+
|
|
436
|
+
```ts
|
|
437
|
+
import { Headers } from 'effect/unstable/http';
|
|
438
|
+
import { RequestId } from 'effect/unstable/rpc/RpcMessage';
|
|
439
|
+
|
|
440
|
+
const result =
|
|
441
|
+
yield*
|
|
442
|
+
UsersGroup.accessHandler('GetUser').pipe(
|
|
443
|
+
Effect.flatMap((handler) =>
|
|
444
|
+
handler({ id: 'u1' }, {
|
|
445
|
+
client: new Rpc.ServerClient(0),
|
|
446
|
+
requestId: RequestId(1),
|
|
447
|
+
headers: Headers.empty,
|
|
448
|
+
rpc: GetUser
|
|
449
|
+
})
|
|
450
|
+
),
|
|
451
|
+
Effect.provide(UsersLive)
|
|
452
|
+
);
|
|
453
|
+
```
|
|
454
|
+
|
|
455
|
+
## Running an RPC server
|
|
456
|
+
|
|
457
|
+
Two layers of API: the **transport-agnostic** server and the **transport-specific** glue.
|
|
458
|
+
|
|
459
|
+
### `RpcServer.layer(group, options?)` — transport-agnostic
|
|
460
|
+
|
|
461
|
+
Requires a `Protocol` in context (one of the `RpcServer.layerProtocol*`), the handlers (`Rpc.ToHandler<Rpcs>`), and any middleware (`Rpc.Middleware<Rpcs>`):
|
|
462
|
+
|
|
463
|
+
```ts
|
|
464
|
+
import { Layer } from 'effect';
|
|
465
|
+
import { HttpRouter } from 'effect/unstable/http';
|
|
466
|
+
|
|
467
|
+
const ServerLayer = RpcServer.layer(UsersGroup, {
|
|
468
|
+
concurrency: 'unbounded', // default; set a number to backpressure handlers
|
|
469
|
+
disableTracing: false,
|
|
470
|
+
disableFatalDefects: false, // see below
|
|
471
|
+
spanPrefix: 'RpcServer', // default; controls span naming
|
|
472
|
+
spanAttributes: { service: 'users' }
|
|
473
|
+
}).pipe(
|
|
474
|
+
Layer.provide(UsersLive), // handlers
|
|
475
|
+
Layer.provide(RpcServer.layerProtocolHttp({ path: '/rpc' })),
|
|
476
|
+
Layer.provide(RpcSerialization.layerNdjson),
|
|
477
|
+
Layer.provide(HttpRouter.layer)
|
|
478
|
+
);
|
|
479
|
+
```
|
|
480
|
+
|
|
481
|
+
Server options:
|
|
482
|
+
|
|
483
|
+
- **`concurrency: number | 'unbounded'`** (default `'unbounded'`) — one semaphore around handler execution for the whole server instance, shared by all clients. `Rpc.fork(...)` opts a single handler out of this limit.
|
|
484
|
+
- **`disableFatalDefects: boolean`** (default `false`) — by default, a `die` inside a handler is treated as a *connection-level* defect and crashes the whole connection's response stream. With `true`, defects come back to the client as a normal `Cause.Die` in the request's exit. Production servers usually want `true`; the cluster fixture uses it.
|
|
485
|
+
- **`disableTracing: boolean`** + **`spanPrefix`** + **`spanAttributes`** — span control. Each rpc gets a span named `${spanPrefix}.${rpc._tag}`.
|
|
486
|
+
|
|
487
|
+
### `RpcServer.layerHttp({ group, path, protocol })` — convenience
|
|
488
|
+
|
|
489
|
+
One-call HTTP+server setup. Picks `layerProtocolHttp` or `layerProtocolWebsocket` for you (default `'websocket'`):
|
|
490
|
+
|
|
491
|
+
```ts
|
|
492
|
+
const ServerLayer = RpcServer.layerHttp({
|
|
493
|
+
group: UsersGroup,
|
|
494
|
+
path: '/api/rpc',
|
|
495
|
+
protocol: 'http', // or 'websocket' (default)
|
|
496
|
+
disableFatalDefects: true,
|
|
497
|
+
concurrency: 'unbounded',
|
|
498
|
+
streamBufferSize: 16 // framed HTTP response queue; default 16
|
|
499
|
+
}).pipe(
|
|
500
|
+
Layer.provide(UsersLive),
|
|
501
|
+
Layer.provide(RpcSerialization.layerNdjson),
|
|
502
|
+
Layer.provide(HttpRouter.layer)
|
|
503
|
+
);
|
|
504
|
+
```
|
|
505
|
+
|
|
506
|
+
### `RpcServer.toHttpEffect(group, options?)` and `toHttpEffectWebsocket`
|
|
507
|
+
|
|
508
|
+
For when you want to mount the RPC handler as a single `HttpServerResponse` Effect on a router you control (Hono adapter, custom routes, etc.) rather than registering a route on `HttpRouter`. Returns `Effect<Effect<HttpServerResponse, never, Scope | HttpServerRequest>, ...>`:
|
|
509
|
+
|
|
510
|
+
```ts
|
|
511
|
+
const makeHttpApp = RpcServer.toHttpEffect(UsersGroup).pipe(
|
|
512
|
+
Effect.provide(UsersLive),
|
|
513
|
+
Effect.provide(RpcSerialization.layerNdjson)
|
|
514
|
+
);
|
|
515
|
+
```
|
|
516
|
+
|
|
517
|
+
Run `makeHttpApp` in the scope that owns the router or framework adapter, then mount the returned request/response effect there.
|
|
518
|
+
|
|
519
|
+
### Protocol layers (server side)
|
|
520
|
+
|
|
521
|
+
Pick one and `Layer.provide` it to `RpcServer.layer`/`layerHttp`:
|
|
522
|
+
|
|
523
|
+
| Layer | Requires | Notes |
|
|
524
|
+
|---|---|---|
|
|
525
|
+
| `RpcServer.layerProtocolHttp({ path, streamBufferSize? })` | `RpcSerialization`, `HttpRouter` | request/response, **no streaming acks** (`supportsAck: false`), no transferables, no span propagation |
|
|
526
|
+
| `RpcServer.layerProtocolWebsocket({ path })` | `RpcSerialization`, `HttpRouter` | full duplex, supports acks, supports span propagation |
|
|
527
|
+
| `RpcServer.layerProtocolSocketServer` | `RpcSerialization`, `SocketServer` | raw TCP socket server |
|
|
528
|
+
| `RpcServer.layerProtocolStdio` | `RpcSerialization`, `Stdio` | process stdin/stdout — for CLI subprocess RPC |
|
|
529
|
+
| `RpcServer.layerProtocolWorkerRunner` | `WorkerRunner.WorkerRunnerPlatform` | run inside a web/node worker; supports `RpcWorker.InitialMessage` |
|
|
530
|
+
|
|
531
|
+
Each layer also has a `make*` Effect counterpart (`makeProtocolHttp`, `makeProtocolWebsocket`, etc.) when you need to compose it inline. There are also `makeProtocolWithHttpEffect({ streamBufferSize? })` / `makeProtocolWithHttpEffectWebsocket` for "give me both the protocol and the http handler Effect" use cases. `makeProtocolWithHttpEffect` is a function, so call it as `yield* RpcServer.makeProtocolWithHttpEffect()` when using defaults.
|
|
532
|
+
|
|
533
|
+
Framed HTTP response queues are bounded to `16` messages by default. Configure `streamBufferSize` on `layerHttp`, `layerProtocolHttp`, `makeProtocolHttp`, `makeProtocolWithHttpEffect`, or `toHttpEffect`; pass `'unbounded'` only when unbounded buffering is intentional.
|
|
534
|
+
|
|
535
|
+
### `RpcServer.Protocol` service
|
|
536
|
+
|
|
537
|
+
The `Protocol` service exposes runtime capabilities tests and middleware can inspect:
|
|
538
|
+
|
|
539
|
+
```ts
|
|
540
|
+
const {
|
|
541
|
+
supportsAck,
|
|
542
|
+
supportsTransferables,
|
|
543
|
+
supportsSpanPropagation,
|
|
544
|
+
supportsNotifications,
|
|
545
|
+
clientIds,
|
|
546
|
+
initialMessage
|
|
547
|
+
} =
|
|
548
|
+
yield* RpcServer.Protocol;
|
|
549
|
+
```
|
|
550
|
+
|
|
551
|
+
E2E tests use this to skip backpressure assertions on transports that don't support acks. `supportsNotifications` is true for sockets, stdio, workers, and framed HTTP; unframed buffered HTTP drops server notifications.
|
|
552
|
+
|
|
553
|
+
`RpcMessage.FromServerEncoded` now includes `RequestEncoded` for server-originated requests and notifications. Notifications set `isNotification: true`; JSON-RPC then omits the id. Custom server protocols must declare `supportsNotifications`.
|
|
554
|
+
|
|
555
|
+
## RPC clients
|
|
556
|
+
|
|
557
|
+
`RpcClient.make(group, options?)` returns an Effect producing a typed client object. Default error channel is `RpcClientError`.
|
|
558
|
+
|
|
559
|
+
```ts
|
|
560
|
+
const client = yield* RpcClient.make(UsersGroup, {
|
|
561
|
+
spanPrefix: 'UsersClient',
|
|
562
|
+
disableTracing: false,
|
|
563
|
+
flatten: false,
|
|
564
|
+
generateRequestId: undefined,
|
|
565
|
+
spanAttributes: { service: 'users' }
|
|
566
|
+
}).pipe(
|
|
567
|
+
Effect.provide(RpcClient.layerProtocolHttp({ url: '/api/rpc' })),
|
|
568
|
+
Effect.provide(RpcSerialization.layerNdjson),
|
|
569
|
+
Effect.provide(FetchHttpClient.layer)
|
|
570
|
+
);
|
|
571
|
+
|
|
572
|
+
const user = yield* client.GetUser({ id: 'u1' });
|
|
573
|
+
```
|
|
574
|
+
|
|
575
|
+
You will almost always wrap this in a `Context.Service` so consumers get the client by name instead of plumbing the Effect:
|
|
576
|
+
|
|
577
|
+
```ts
|
|
578
|
+
class UsersClient extends Context.Service<
|
|
579
|
+
UsersClient,
|
|
580
|
+
RpcClient.RpcClient<RpcGroup.Rpcs<typeof UsersGroup>, RpcClientError>
|
|
581
|
+
>()('UsersClient') {
|
|
582
|
+
static readonly layer = Layer.effect(UsersClient)(
|
|
583
|
+
RpcClient.make(UsersGroup)
|
|
584
|
+
).pipe(Layer.provide(AuthClient));
|
|
585
|
+
}
|
|
586
|
+
```
|
|
587
|
+
|
|
588
|
+
### Per-call options
|
|
589
|
+
|
|
590
|
+
Each generated method is `(payload, options?) => Effect | Stream`. The option shape differs by stream-vs-non-stream:
|
|
591
|
+
|
|
592
|
+
```ts
|
|
593
|
+
// Non-stream rpc
|
|
594
|
+
client.GetUser({ id: 'u1' }, {
|
|
595
|
+
headers?: Headers.Input, // per-call headers
|
|
596
|
+
context?: Context<never>, // per-call context (rare)
|
|
597
|
+
discard?: true // returns Effect<void, transport | middleware errors>; no response decoding
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
// Stream rpc
|
|
601
|
+
client.Subscribe({ topic: 't' }, {
|
|
602
|
+
headers?: Headers.Input,
|
|
603
|
+
context?: Context<never>,
|
|
604
|
+
asQueue?: true, // returns Effect<Queue.Dequeue<A, E | Cause.Done>>
|
|
605
|
+
streamBufferSize?: number // default 16
|
|
606
|
+
});
|
|
607
|
+
```
|
|
608
|
+
|
|
609
|
+
`discard: true` removes the error channel — the request is sent and acknowledged; the result and any failure are discarded. Use for fire-and-forget commands (especially against persistent entities).
|
|
610
|
+
|
|
611
|
+
`asQueue: true` is useful when you need finer control than a `Stream` gives you — e.g., you want to take only one chunk, then drop it. The end-of-stream signal is `Cause.Done` in the queue's error channel.
|
|
612
|
+
|
|
613
|
+
### Headers
|
|
614
|
+
|
|
615
|
+
For one-off headers, use the per-call `headers` option. For region-scoped headers, use `RpcClient.withHeaders` (which updates the `RpcClient.CurrentHeaders` Reference):
|
|
616
|
+
|
|
617
|
+
```ts
|
|
618
|
+
import { RpcClient } from 'effect/unstable/rpc';
|
|
619
|
+
|
|
620
|
+
yield* program.pipe(
|
|
621
|
+
RpcClient.withHeaders({ authorization: `Bearer ${token}`, userid: '123' })
|
|
622
|
+
);
|
|
623
|
+
```
|
|
624
|
+
|
|
625
|
+
`RpcClient.CurrentHeaders` is a `Context.Reference<Headers.Headers>` you can also set directly with `Effect.updateService`. Headers from `withHeaders` and the per-call option are merged; per-call wins on conflict.
|
|
626
|
+
|
|
627
|
+
### `flatten: true` mode
|
|
628
|
+
|
|
629
|
+
When set, the client becomes a single function `(tag, payload, options?)` instead of a property-per-tag object. `AtomRpc` uses this internally; you'll want it when proxying generically:
|
|
630
|
+
|
|
631
|
+
```ts
|
|
632
|
+
const client =
|
|
633
|
+
yield*
|
|
634
|
+
RpcClient.make(UsersGroup, { flatten: true });
|
|
635
|
+
const user = yield* client('GetUser', { id: 'u1' });
|
|
636
|
+
```
|
|
637
|
+
|
|
638
|
+
### Client error channel
|
|
639
|
+
|
|
640
|
+
Every method has the error channel:
|
|
641
|
+
|
|
642
|
+
```
|
|
643
|
+
Rpc.Error<R> // your declared rpc error
|
|
644
|
+
| MiddlewareError // any middleware errors
|
|
645
|
+
| MiddlewareClientError // any client-side middleware errors
|
|
646
|
+
| RpcClientError // transport-level
|
|
647
|
+
```
|
|
648
|
+
|
|
649
|
+
`RpcClientError` is a tagged union itself:
|
|
650
|
+
|
|
651
|
+
```ts
|
|
652
|
+
class RpcClientError extends Schema.Error(...)({
|
|
653
|
+
_tag: 'RpcClientError',
|
|
654
|
+
reason: Schema.Union([
|
|
655
|
+
WorkerErrorReason,
|
|
656
|
+
SocketErrorReason,
|
|
657
|
+
HttpClientErrorSchema,
|
|
658
|
+
RpcClientDefect
|
|
659
|
+
])
|
|
660
|
+
})
|
|
661
|
+
```
|
|
662
|
+
|
|
663
|
+
Pattern-match on `error.reason._tag` to handle transport faults (network down, malformed response, worker crash). The `RpcClientDefect` case wraps non-error throws and protocol bugs.
|
|
664
|
+
|
|
665
|
+
### Client protocol layers
|
|
666
|
+
|
|
667
|
+
| Layer | Requires | Notes |
|
|
668
|
+
|---|---|---|
|
|
669
|
+
| `RpcClient.layerProtocolHttp({ url, transformClient? })` | `RpcSerialization`, `HttpClient` | request/response. `transformClient` lets you rewrite the underlying `HttpClient` (e.g., add auth headers, prepend URL paths) |
|
|
670
|
+
| `RpcClient.layerProtocolSocket({ retryTransientErrors?, onTransientError? })` | `RpcSerialization`, `Socket.Socket` | full duplex. Auto-pings every 5s; reconnects on transient socket errors; reports retried open failures through `onTransientError` |
|
|
671
|
+
| `RpcClient.layerProtocolWorker(options)` | `Worker.WorkerPlatform`, `Worker.Spawner` | pool of worker-backed clients. Options: either `{ size, concurrency?, targetUtilization? }` or `{ minSize, maxSize, timeToLive, concurrency?, targetUtilization? }` |
|
|
672
|
+
|
|
673
|
+
For each there's a corresponding `make*` Effect (`makeProtocolHttp`, `makeProtocolSocket`, `makeProtocolWorker`) when you need finer control over context.
|
|
674
|
+
|
|
675
|
+
### `RpcClient.ConnectionHooks`
|
|
676
|
+
|
|
677
|
+
A `Context.Service` you can provide to get `onConnect` / `onDisconnect` callbacks for socket and worker transports. Use it to (re-)hydrate auth state on reconnect:
|
|
678
|
+
|
|
679
|
+
```ts
|
|
680
|
+
const ConnectionHooksLayer = Layer.succeed(RpcClient.ConnectionHooks, {
|
|
681
|
+
onConnect: refreshAuthToken,
|
|
682
|
+
onDisconnect: Effect.logWarning('rpc disconnected')
|
|
683
|
+
});
|
|
684
|
+
```
|
|
685
|
+
|
|
686
|
+
### `RpcSchema.ClientAbort`
|
|
687
|
+
|
|
688
|
+
When a client interrupts a streaming subscription, the server-side handler's `onInterrupt` finalizer sees a `Cause` carrying the `ClientAbort` annotation. Use it to distinguish client cancel from server shutdown:
|
|
689
|
+
|
|
690
|
+
```ts
|
|
691
|
+
import { RpcSchema } from 'effect/unstable/rpc';
|
|
692
|
+
import { Cause, Context } from 'effect';
|
|
693
|
+
|
|
694
|
+
const subscribeHandler = stream.pipe(
|
|
695
|
+
Effect.onInterrupt((cause) => {
|
|
696
|
+
const isClientAbort = Context.has(cause, RpcSchema.ClientAbort);
|
|
697
|
+
return Effect.logInfo('subscribe ended', { isClientAbort });
|
|
698
|
+
})
|
|
699
|
+
);
|
|
700
|
+
```
|
|
701
|
+
|
|
702
|
+
## Middleware
|
|
703
|
+
|
|
704
|
+
`RpcMiddleware.Service<Self, Config>()(name, options)` defines a middleware service. The config positionally encodes what the middleware *provides*, *requires*, and what *client-only* error type it can throw. The options carry the wire-error schema and the `requiredForClient` enforcement flag.
|
|
705
|
+
|
|
706
|
+
```ts
|
|
707
|
+
import { RpcMiddleware } from 'effect/unstable/rpc';
|
|
708
|
+
import { Context, Schema } from 'effect';
|
|
709
|
+
|
|
710
|
+
class CurrentUser extends Context.Service<CurrentUser, User>()('CurrentUser') {}
|
|
711
|
+
|
|
712
|
+
class Unauthorized extends Schema.Error<Unauthorized>('Unauthorized')({
|
|
713
|
+
_tag: Schema.tag('Unauthorized')
|
|
714
|
+
}) {}
|
|
715
|
+
|
|
716
|
+
class AuthMiddleware extends RpcMiddleware.Service<AuthMiddleware, {
|
|
717
|
+
provides: CurrentUser; // injected into the wrapped handler
|
|
718
|
+
requires: never; // services this middleware needs from outer context
|
|
719
|
+
clientError: never; // errors only the client side can produce
|
|
720
|
+
}>()('AuthMiddleware', {
|
|
721
|
+
error: Unauthorized, // wire-form error this middleware can produce
|
|
722
|
+
requiredForClient: true // clients must supply layerClient or fail to compile
|
|
723
|
+
}) {}
|
|
724
|
+
```
|
|
725
|
+
|
|
726
|
+
The full config bag is `{ requires?, provides?, clientError? }` — all optional, all default `never`.
|
|
727
|
+
|
|
728
|
+
`requiredForClient: true` is what makes auth client-side enforcement a *compile-time* error rather than a runtime surprise: clients must `Layer.provide(RpcMiddleware.layerClient(AuthMiddleware, ...))` or `RpcClient.make` won't compile.
|
|
729
|
+
|
|
730
|
+
### Server-side middleware implementation
|
|
731
|
+
|
|
732
|
+
Implement the middleware as a Layer producing the service. The function receives `(effect, options)`:
|
|
733
|
+
|
|
734
|
+
```ts
|
|
735
|
+
import { Layer } from 'effect';
|
|
736
|
+
|
|
737
|
+
const AuthLive = Layer.succeed(AuthMiddleware)(
|
|
738
|
+
AuthMiddleware.of((effect, { client, requestId, rpc, payload, headers }) =>
|
|
739
|
+
Effect.flatMap(verifyToken(headers.authorization), (user) =>
|
|
740
|
+
Effect.provideService(effect, CurrentUser, user)
|
|
741
|
+
)
|
|
742
|
+
)
|
|
743
|
+
);
|
|
744
|
+
```
|
|
745
|
+
|
|
746
|
+
Options shape: `{ client: ServerClient, requestId, rpc, payload, headers }`. The middleware can:
|
|
747
|
+
|
|
748
|
+
- Provide services to the inner effect (matching the `provides` config)
|
|
749
|
+
- Fail with the wire-error schema (`Unauthorized` here)
|
|
750
|
+
- Annotate `client` via `client.annotate(...)` so subsequent middlewares see the per-connection state
|
|
751
|
+
- Read `headers` directly (they're already parsed)
|
|
752
|
+
|
|
753
|
+
Middleware can chain `requires` and `provides` — `DbMiddleware extends RpcMiddleware.Service<…, { provides: DbConnection, requires: CurrentUser }>` will compile only when paired with an `AuthMiddleware` upstream that provides `CurrentUser`.
|
|
754
|
+
|
|
755
|
+
### Client-side middleware (`layerClient`)
|
|
756
|
+
|
|
757
|
+
For middleware that needs to *also* run client-side (most commonly: attach an auth header), provide a `layerClient`:
|
|
758
|
+
|
|
759
|
+
```ts
|
|
760
|
+
import { Headers } from 'effect/unstable/http';
|
|
761
|
+
|
|
762
|
+
export const AuthClient = RpcMiddleware.layerClient(
|
|
763
|
+
AuthMiddleware,
|
|
764
|
+
({ rpc, request, next }) =>
|
|
765
|
+
next({
|
|
766
|
+
...request,
|
|
767
|
+
headers: Headers.set(request.headers, 'authorization', `Bearer ${currentToken}`)
|
|
768
|
+
})
|
|
769
|
+
);
|
|
770
|
+
```
|
|
771
|
+
|
|
772
|
+
Important details:
|
|
773
|
+
|
|
774
|
+
- `request.headers` is `Headers.Headers` (already parsed). Use the helpers from `effect/unstable/http/Headers` (`Headers.set`, `Headers.merge`, `Headers.fromInput`).
|
|
775
|
+
- You **must** call `next(request)` (with the modified or original request) — the middleware's job is to wrap the send, not replace it.
|
|
776
|
+
- The Layer signature is `Layer<ForClient<AuthMiddleware>>` — it's a distinct service from the server-side middleware, and providing both is the norm for client packages.
|
|
777
|
+
|
|
778
|
+
## Serialization
|
|
779
|
+
|
|
780
|
+
The choice of serialization is load-bearing because of *framing*. Some transports (raw HTTP request/response) deliver one logical message at a time; others (sockets, ndjson over HTTP streams) deliver an unbounded stream of bytes that must be split into messages.
|
|
781
|
+
|
|
782
|
+
| Layer | Content-Type | Framed? | Use for | Notes |
|
|
783
|
+
|---|---|---|---|---|
|
|
784
|
+
| `RpcSerialization.layerJson` | `application/json` | no | `layerProtocolHttp` | Default JSON over request/response |
|
|
785
|
+
| `RpcSerialization.layerNdjson` | `application/ndjson` | yes (newline) | `layerProtocolWebsocket`, sockets, http+stream | Newline-delimited JSON; required for streaming |
|
|
786
|
+
| `RpcSerialization.layerJsonRpc()` | `application/json` (configurable) | no | JSON-RPC 2.0 interop | Maps `_tag` to `method`; preserves batched arrays |
|
|
787
|
+
| `RpcSerialization.layerNdJsonRpc()` | `application/json-rpc` (configurable) | yes (newline) | JSON-RPC 2.0 over sockets | |
|
|
788
|
+
| `RpcSerialization.layerMsgPack` | `application/msgpack` | yes (msgpack frames) | binary transports | Smallest wire size; native binary; uses `useRecords: true` |
|
|
789
|
+
|
|
790
|
+
`RpcSerialization.makeMsgPack(options?)` lets you customize msgpackr (`useRecords`, `useFloat32`, etc.).
|
|
791
|
+
|
|
792
|
+
Picking the wrong one is a real bug:
|
|
793
|
+
|
|
794
|
+
- `layerJson` over a websocket → no framing → the first chunk past the first message is misinterpreted
|
|
795
|
+
- `layerMsgPack` against a JSON-only HTTP client → garbled responses
|
|
796
|
+
- `layerNdjson` against `layerProtocolHttp` → works, but framing is wasted; clients have to wait for the response to end
|
|
797
|
+
|
|
798
|
+
## Testing — `RpcTest.makeClient`
|
|
799
|
+
|
|
800
|
+
In-process server+client wired together, no network. The simplest possible RPC test:
|
|
801
|
+
|
|
802
|
+
```ts
|
|
803
|
+
import { Effect, Layer } from 'effect';
|
|
804
|
+
import { RpcTest } from 'effect/unstable/rpc';
|
|
805
|
+
import { it } from '@effect/vitest';
|
|
806
|
+
|
|
807
|
+
const TestClient = Layer.effect(UsersClient)(
|
|
808
|
+
RpcTest.makeClient(UsersGroup)
|
|
809
|
+
).pipe(Layer.provide([UsersLive, AuthLive, AuthClient]));
|
|
810
|
+
|
|
811
|
+
it.effect('GetUser', () =>
|
|
812
|
+
Effect.gen(function*() {
|
|
813
|
+
const client = yield* UsersClient;
|
|
814
|
+
const user = yield* client.GetUser({ id: 'u1' });
|
|
815
|
+
expect(user.id).toBe('u1');
|
|
816
|
+
}).pipe(Effect.provide(TestClient)));
|
|
817
|
+
```
|
|
818
|
+
|
|
819
|
+
`makeClient` accepts `{ flatten?: boolean }` mirroring `RpcClient.make`. Required context is `Scope | Rpc.ToHandler<Rpcs> | Rpc.Middleware<Rpcs> | Rpc.MiddlewareClient<Rpcs>` — i.e. handler layers **and** any client-side middleware layers. Forgetting the latter is a common type error.
|
|
820
|
+
|
|
821
|
+
## Worker transports & `RpcWorker.InitialMessage`
|
|
822
|
+
|
|
823
|
+
For worker-backed clients, you can pass typed initial config at spawn time without a separate rpc round-trip:
|
|
824
|
+
|
|
825
|
+
```ts
|
|
826
|
+
// On the worker (server side):
|
|
827
|
+
import { RpcWorker } from 'effect/unstable/rpc';
|
|
828
|
+
|
|
829
|
+
class WorkerConfig extends Schema.Class<WorkerConfig>('WorkerConfig')({
|
|
830
|
+
apiUrl: Schema.String,
|
|
831
|
+
tenantId: Schema.String
|
|
832
|
+
}) {}
|
|
833
|
+
|
|
834
|
+
// Inside the worker, before serving:
|
|
835
|
+
const config = yield* RpcWorker.initialMessage(WorkerConfig);
|
|
836
|
+
|
|
837
|
+
// On the client (parent side):
|
|
838
|
+
const InitialMessageLayer = RpcWorker.layerInitialMessage(
|
|
839
|
+
WorkerConfig,
|
|
840
|
+
Effect.succeed(new WorkerConfig({ apiUrl: '/api', tenantId: 't1' }))
|
|
841
|
+
);
|
|
842
|
+
|
|
843
|
+
const ClientLayer = UsersClient.layer.pipe(
|
|
844
|
+
Layer.provide(RpcClient.layerProtocolWorker({ size: 4 })),
|
|
845
|
+
Layer.provide(InitialMessageLayer)
|
|
846
|
+
);
|
|
847
|
+
```
|
|
848
|
+
|
|
849
|
+
---
|
|
850
|
+
|
|
851
|
+
# Cluster
|
|
852
|
+
|
|
853
|
+
Cluster turns rpcs into **addressable, distributed actors** (`Entity`). Messages are routed to whichever runner currently owns the entity's shard, optionally persisted to durable storage, and replayed on restart.
|
|
854
|
+
|
|
855
|
+
## Entity
|
|
856
|
+
|
|
857
|
+
```ts
|
|
858
|
+
import { Schema } from 'effect';
|
|
859
|
+
import { ClusterSchema, Entity } from 'effect/unstable/cluster';
|
|
860
|
+
import { Rpc } from 'effect/unstable/rpc';
|
|
861
|
+
|
|
862
|
+
export class Increment extends Rpc.make('Increment', {
|
|
863
|
+
payload: { amount: Schema.Number },
|
|
864
|
+
success: Schema.Number,
|
|
865
|
+
primaryKey: ({ amount }) => `inc-${amount}` // for dedup across retries
|
|
866
|
+
}) {}
|
|
867
|
+
|
|
868
|
+
export const GetCount = Rpc.make('GetCount', { success: Schema.Number });
|
|
869
|
+
|
|
870
|
+
export const Counter = Entity.make('Counter', [Increment, GetCount])
|
|
871
|
+
.annotateRpcs(ClusterSchema.Persisted, true); // persist all messages
|
|
872
|
+
```
|
|
873
|
+
|
|
874
|
+
Two constructors:
|
|
875
|
+
|
|
876
|
+
- **`Entity.make(type, [rpcs])`** — variadic-array form
|
|
877
|
+
- **`Entity.fromRpcGroup(type, rpcGroup)`** — when the protocol already exists as an `RpcGroup` (e.g., shared with a non-clustered RPC server)
|
|
878
|
+
|
|
879
|
+
### Entity handler signature is **different from RpcGroup**
|
|
880
|
+
|
|
881
|
+
Entity handlers receive a single `envelope: Envelope.Request<R>`:
|
|
882
|
+
|
|
883
|
+
```ts
|
|
884
|
+
type EntityHandler<R extends Rpc.Any> = (
|
|
885
|
+
envelope: {
|
|
886
|
+
readonly _tag: 'Request';
|
|
887
|
+
readonly requestId: Snowflake;
|
|
888
|
+
readonly address: EntityAddress; // { entityType, entityId, shardId }
|
|
889
|
+
readonly tag: Rpc.Tag<R>;
|
|
890
|
+
readonly payload: Rpc.Payload<R>;
|
|
891
|
+
readonly headers: Headers;
|
|
892
|
+
readonly traceId?: string;
|
|
893
|
+
readonly spanId?: string;
|
|
894
|
+
readonly sampled?: boolean;
|
|
895
|
+
// for stream rpcs:
|
|
896
|
+
readonly lastSentChunk: Option<Reply.Chunk<R>>;
|
|
897
|
+
readonly lastSentChunkValue: Option<SuccessChunk<R>>;
|
|
898
|
+
readonly nextSequence: number;
|
|
899
|
+
}
|
|
900
|
+
) => Effect | Stream;
|
|
901
|
+
```
|
|
902
|
+
|
|
903
|
+
You destructure `{ payload }` (and sometimes `{ payload, address }`) inside the handler. The full envelope is also useful for streaming rpcs that need to resume after a reconnect — `lastSentChunkValue` and `nextSequence` let you replay from the right offset.
|
|
904
|
+
|
|
905
|
+
```ts
|
|
906
|
+
export const CounterLive = Counter.toLayer(
|
|
907
|
+
Effect.gen(function*() {
|
|
908
|
+
const count = yield* Ref.make(0);
|
|
909
|
+
|
|
910
|
+
return Counter.of({
|
|
911
|
+
Increment: (envelope) =>
|
|
912
|
+
Ref.updateAndGet(count, (n) => n + envelope.payload.amount),
|
|
913
|
+
|
|
914
|
+
GetCount: () => Ref.get(count).pipe(Rpc.fork) // concurrent reads
|
|
915
|
+
});
|
|
916
|
+
}),
|
|
917
|
+
{
|
|
918
|
+
maxIdleTime: Duration.minutes(5),
|
|
919
|
+
concurrency: 1, // sequential by default
|
|
920
|
+
mailboxCapacity: 1000,
|
|
921
|
+
disableFatalDefects: false,
|
|
922
|
+
defectRetryPolicy: Schedule.exponential('200 millis'),
|
|
923
|
+
spanAttributes: { entity: 'Counter' }
|
|
924
|
+
}
|
|
925
|
+
);
|
|
926
|
+
```
|
|
927
|
+
|
|
928
|
+
`toLayer` options:
|
|
929
|
+
|
|
930
|
+
- **`maxIdleTime`** — passivation timeout. After this idle time the entity is stopped and recreated on the next message.
|
|
931
|
+
- **`concurrency`** (default `1`) — handlers run sequentially per entity. Set `'unbounded'` for concurrent handlers, or use `Rpc.fork(...)` per-handler.
|
|
932
|
+
- **`mailboxCapacity`** (default from `ShardingConfig.entityMailboxCapacity`, usually 4096) — backpressure threshold; sends fail with `MailboxFull` past this point.
|
|
933
|
+
- **`disableFatalDefects`** — by default a defect inside an entity handler crashes the entity (then retries per `defectRetryPolicy`); with `true`, defects are reported back to the sender as a normal `Cause.Die`.
|
|
934
|
+
- **`defectRetryPolicy`** — `Schedule` for restarting after a fatal defect.
|
|
935
|
+
- **`spanAttributes`** — added to every per-rpc span.
|
|
936
|
+
|
|
937
|
+
### Cluster-only services in handlers
|
|
938
|
+
|
|
939
|
+
Entity handlers can pull two services from context:
|
|
940
|
+
|
|
941
|
+
```ts
|
|
942
|
+
import { Entity } from 'effect/unstable/cluster';
|
|
943
|
+
|
|
944
|
+
Counter.toLayer(Effect.gen(function*() {
|
|
945
|
+
return Counter.of({
|
|
946
|
+
Increment: Effect.fnUntraced(function*(envelope) {
|
|
947
|
+
const address = yield* Entity.CurrentAddress;
|
|
948
|
+
// EntityAddress: { entityType, entityId: string, shardId: ShardId }
|
|
949
|
+
const runner = yield* Entity.CurrentRunnerAddress;
|
|
950
|
+
// RunnerAddress: { host: string, port: number }
|
|
951
|
+
|
|
952
|
+
yield* Effect.logInfo('handling on runner', {
|
|
953
|
+
entityId: address.entityId,
|
|
954
|
+
runner: `${runner.host}:${runner.port}`
|
|
955
|
+
});
|
|
956
|
+
// ...
|
|
957
|
+
})
|
|
958
|
+
});
|
|
959
|
+
}));
|
|
960
|
+
```
|
|
961
|
+
|
|
962
|
+
Use `Entity.CurrentAddress` instead of threading the entity id through the payload.
|
|
963
|
+
|
|
964
|
+
### `Entity.keepAlive(boolean)`
|
|
965
|
+
|
|
966
|
+
Pin or release the entity's idle timeout from inside a handler. Call `keepAlive(true)` to extend the lifetime while a long async job runs; `keepAlive(false)` to allow normal passivation:
|
|
967
|
+
|
|
968
|
+
```ts
|
|
969
|
+
const RunLongJob = Effect.fn('Counter.runLongJob')(function*() {
|
|
970
|
+
yield* Entity.keepAlive(true);
|
|
971
|
+
yield* longRunningJob;
|
|
972
|
+
yield* Entity.keepAlive(false);
|
|
973
|
+
});
|
|
974
|
+
```
|
|
975
|
+
|
|
976
|
+
### Queue-based handlers — `entity.toLayerQueue`
|
|
977
|
+
|
|
978
|
+
When you want full control over message ordering, batching, or backpressure, use `toLayerQueue`. The handler is `(queue, replier) => Effect<never, never, R>` — a long-running effect that consumes the queue and replies via the `Replier`:
|
|
979
|
+
|
|
980
|
+
```ts
|
|
981
|
+
import { Effect, Queue, Stream } from 'effect';
|
|
982
|
+
|
|
983
|
+
const StreamingCounter = Counter.toLayerQueue(
|
|
984
|
+
Effect.gen(function*() {
|
|
985
|
+
let count = 0;
|
|
986
|
+
return (queue, replier) =>
|
|
987
|
+
Effect.gen(function*() {
|
|
988
|
+
while (true) {
|
|
989
|
+
const request = yield* Queue.take(queue);
|
|
990
|
+
if (request.tag === 'Increment') {
|
|
991
|
+
count += request.payload.amount;
|
|
992
|
+
yield* replier.succeed(request, count);
|
|
993
|
+
} else if (request.tag === 'GetCount') {
|
|
994
|
+
yield* replier.succeed(request, count);
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
});
|
|
998
|
+
}),
|
|
999
|
+
{ maxIdleTime: Duration.minutes(5) }
|
|
1000
|
+
);
|
|
1001
|
+
```
|
|
1002
|
+
|
|
1003
|
+
The `Replier` API:
|
|
1004
|
+
|
|
1005
|
+
```ts
|
|
1006
|
+
interface Replier<R extends Rpc.Any> {
|
|
1007
|
+
succeed: (request, value) => Effect<void>;
|
|
1008
|
+
// for stream rpcs, value can be a Stream or a Queue.Dequeue
|
|
1009
|
+
fail: (request, error) => Effect<void>;
|
|
1010
|
+
failCause: (request, cause) => Effect<void>;
|
|
1011
|
+
complete: (request, exit: Exit<value, error>) => Effect<void>;
|
|
1012
|
+
}
|
|
1013
|
+
```
|
|
1014
|
+
|
|
1015
|
+
For a stream rpc with `toLayerQueue`, you can reply with either a `Stream` or a `Queue.Dequeue` and the runtime adapts:
|
|
1016
|
+
|
|
1017
|
+
```ts
|
|
1018
|
+
StreamEntity.toLayerQueue((mailbox, replier) =>
|
|
1019
|
+
Effect.gen(function*() {
|
|
1020
|
+
while (true) {
|
|
1021
|
+
const req = yield* Queue.take(mailbox);
|
|
1022
|
+
yield* replier.succeed(req, Stream.make(1, 2, 3));
|
|
1023
|
+
}
|
|
1024
|
+
}));
|
|
1025
|
+
```
|
|
1026
|
+
|
|
1027
|
+
`toLayerQueue` always sets `concurrency: 'unbounded'` internally (you handle ordering yourself).
|
|
1028
|
+
|
|
1029
|
+
### Cluster annotations (`ClusterSchema`)
|
|
1030
|
+
|
|
1031
|
+
These are `Context.Reference` annotations you attach via `Rpc.annotate`, `RpcGroup.annotateRpcs`, or `Entity.annotate`/`annotateRpcs`. Defaults in parens.
|
|
1032
|
+
|
|
1033
|
+
| Annotation | Default | Effect |
|
|
1034
|
+
|---|---|---|
|
|
1035
|
+
| `Persisted` | `false` | Persist messages to `MessageStorage` for durable delivery and replay |
|
|
1036
|
+
| `WithTransaction` | `false` | Wrap the handler in a storage transaction so SQL queries inside the handler commit atomically with the message ack |
|
|
1037
|
+
| `Uninterruptible` | `false` | Three values: `true` (both sides), `'client'` (client won't send `Interrupt`), `'server'` (handler runs `Effect.uninterruptible`). Predicates: `isUninterruptibleForServer`, `isUninterruptibleForClient` |
|
|
1038
|
+
| `ShardGroup` | `() => 'default'` | `(entityId) => string` — control which shard group an entity goes to |
|
|
1039
|
+
| `ClientTracingEnabled` | `true` | Disable per-rpc client spans (e.g., for noisy cron jobs) |
|
|
1040
|
+
| `Dynamic` | `identity` | `(annotations, request) => annotations` — compute annotations per-request, e.g., turn on `WithTransaction` only for write methods |
|
|
1041
|
+
|
|
1042
|
+
```ts
|
|
1043
|
+
// Mark an entire entity as persisted with custom shard grouping
|
|
1044
|
+
const Counter = Entity.make('Counter', [Increment, GetCount])
|
|
1045
|
+
.annotateRpcs(ClusterSchema.Persisted, true)
|
|
1046
|
+
.annotate(ClusterSchema.ShardGroup, (entityId) => `tenant-${entityId.split(':')[0]}`);
|
|
1047
|
+
|
|
1048
|
+
// Per-rpc dynamic transaction wrapping
|
|
1049
|
+
const WithTx = Rpc.make('WithTx', {
|
|
1050
|
+
payload: { id: Schema.Number },
|
|
1051
|
+
success: Schema.Boolean
|
|
1052
|
+
}).annotate(
|
|
1053
|
+
ClusterSchema.Dynamic,
|
|
1054
|
+
Context.add(Context.empty(), ClusterSchema.WithTransaction, true)
|
|
1055
|
+
);
|
|
1056
|
+
```
|
|
1057
|
+
|
|
1058
|
+
### Entity client — `entity.client`
|
|
1059
|
+
|
|
1060
|
+
Returns an Effect producing a `(entityId) => RpcClient.From<...>` factory. The factory builds a typed client targeting one specific entity instance:
|
|
1061
|
+
|
|
1062
|
+
```ts
|
|
1063
|
+
const useCounter = Effect.gen(function*() {
|
|
1064
|
+
const clientFor = yield* Counter.client;
|
|
1065
|
+
const counter = clientFor('counter-tenant1-abc');
|
|
1066
|
+
|
|
1067
|
+
const after = yield* counter.Increment({ amount: 1 });
|
|
1068
|
+
const now = yield* counter.GetCount();
|
|
1069
|
+
});
|
|
1070
|
+
```
|
|
1071
|
+
|
|
1072
|
+
Required context: `Sharding`. The client's error channel is augmented with cluster-specific errors:
|
|
1073
|
+
|
|
1074
|
+
```
|
|
1075
|
+
Rpc.Error<R> | MailboxFull | AlreadyProcessingMessage | PersistenceError | EntityNotAssignedToRunner
|
|
1076
|
+
```
|
|
1077
|
+
|
|
1078
|
+
Use `discard: true` for fire-and-forget commands (skip the reply round-trip):
|
|
1079
|
+
|
|
1080
|
+
```ts
|
|
1081
|
+
yield* counter.Increment({ amount: 1 }, { discard: true });
|
|
1082
|
+
```
|
|
1083
|
+
|
|
1084
|
+
A discarded non-persisted message completes after successful delivery to the entity mailbox; it no longer waits for an entity reply. Persisted discard still relies on durable storage acceptance. Delivery, persistence, assignment, and mailbox failures remain visible where the cluster client contract declares them.
|
|
1085
|
+
|
|
1086
|
+
### `Entity.makeTestClient(entity, layer)`
|
|
1087
|
+
|
|
1088
|
+
In-process entity testing without a real cluster. Returns `(entityId) => Effect<RpcClient<...>>`:
|
|
1089
|
+
|
|
1090
|
+
```ts
|
|
1091
|
+
import { Effect } from 'effect';
|
|
1092
|
+
import { Entity, ShardingConfig } from 'effect/unstable/cluster';
|
|
1093
|
+
import { it } from '@effect/vitest';
|
|
1094
|
+
|
|
1095
|
+
const TestShardingConfig = ShardingConfig.layer({
|
|
1096
|
+
shardsPerGroup: 300,
|
|
1097
|
+
entityMailboxCapacity: 10
|
|
1098
|
+
});
|
|
1099
|
+
|
|
1100
|
+
it.effect('Counter increments', () =>
|
|
1101
|
+
Effect.gen(function*() {
|
|
1102
|
+
const makeClient = yield* Entity.makeTestClient(Counter, CounterLive);
|
|
1103
|
+
const client = yield* makeClient('test-1');
|
|
1104
|
+
const result = yield* client.Increment({ amount: 5 });
|
|
1105
|
+
expect(result).toBe(5);
|
|
1106
|
+
}).pipe(Effect.provide(TestShardingConfig)));
|
|
1107
|
+
```
|
|
1108
|
+
|
|
1109
|
+
Required context: `Scope | ShardingConfig | Rpc.MiddlewareClient<Rpcs> | (handler context)`. **You must provide `ShardingConfig`** — `TestRunner.layer` provides one automatically, but `makeTestClient` does not.
|
|
1110
|
+
|
|
1111
|
+
## Singletons (`Singleton.make`)
|
|
1112
|
+
|
|
1113
|
+
A `Singleton` is an effect that runs *exactly once across the cluster*. The shard manager elects a single runner to host it; if that runner dies, another one takes over. Distinct from `ClusterCron` — the latter is built on top of singletons + entities.
|
|
1114
|
+
|
|
1115
|
+
```ts
|
|
1116
|
+
import { Singleton } from 'effect/unstable/cluster';
|
|
1117
|
+
|
|
1118
|
+
const LeaderElection = Singleton.make(
|
|
1119
|
+
'leader-elector',
|
|
1120
|
+
Effect.gen(function*() {
|
|
1121
|
+
yield* Effect.logInfo('elected leader');
|
|
1122
|
+
yield* Effect.never; // hold the position
|
|
1123
|
+
}),
|
|
1124
|
+
{ shardGroup: 'leader-pool' } // optional
|
|
1125
|
+
);
|
|
1126
|
+
|
|
1127
|
+
const AppLayer = Layer.mergeAll(LeaderElection, /* ... */);
|
|
1128
|
+
```
|
|
1129
|
+
|
|
1130
|
+
Use cases: leader election, single-writer queues, pub/sub fan-out coordinators, scheduled rebalancing.
|
|
1131
|
+
|
|
1132
|
+
## Cron jobs (`ClusterCron.make`)
|
|
1133
|
+
|
|
1134
|
+
Cluster-singleton cron executions. The cron schedule is durable: missed runs (within `skipIfOlderThan`) are caught up; future runs are scheduled ahead.
|
|
1135
|
+
|
|
1136
|
+
```ts
|
|
1137
|
+
import { Cron, Effect } from 'effect';
|
|
1138
|
+
import { ClusterCron } from 'effect/unstable/cluster';
|
|
1139
|
+
|
|
1140
|
+
const DailyReport = ClusterCron.make({
|
|
1141
|
+
name: 'DailyReport',
|
|
1142
|
+
cron: Cron.parse('0 8 * * *').pipe(Effect.runSync), // 08:00 daily
|
|
1143
|
+
execute: generateAndSendReport,
|
|
1144
|
+
shardGroup: 'reports', // optional; defaults to 'default'
|
|
1145
|
+
skipIfOlderThan: '1 day', // skip stale executions; default 1 day
|
|
1146
|
+
calculateNextRunFromPrevious: false // default; next run computed from now
|
|
1147
|
+
});
|
|
1148
|
+
```
|
|
1149
|
+
|
|
1150
|
+
`ClusterCron.make` returns `Layer<never, never, Sharding | (R - Scope)>`. Internally it builds:
|
|
1151
|
+
|
|
1152
|
+
- An `Entity` named `ClusterCron/<name>` whose `run` rpc is `Persisted` + `Uninterruptible`
|
|
1153
|
+
- A `Singleton` that schedules the next entity invocation
|
|
1154
|
+
|
|
1155
|
+
The schedule survives runner restarts because the next invocation is durably stored as an entity message with a `DeliverAt` annotation.
|
|
1156
|
+
|
|
1157
|
+
## Sharding service
|
|
1158
|
+
|
|
1159
|
+
Inside any entity handler (or any effect running in the cluster), you can pull `Sharding.Sharding` for cluster-aware operations:
|
|
1160
|
+
|
|
1161
|
+
```ts
|
|
1162
|
+
import { Sharding } from 'effect/unstable/cluster';
|
|
1163
|
+
|
|
1164
|
+
const sharding = yield* Sharding.Sharding;
|
|
1165
|
+
|
|
1166
|
+
const id = yield* sharding.getSnowflake; // unique-per-runner monotonic ID
|
|
1167
|
+
const isShutdown = yield* sharding.isShutdown; // graceful drain signal
|
|
1168
|
+
const count = yield* sharding.activeEntityCount; // metric
|
|
1169
|
+
yield* sharding.pollStorage; // force immediate storage poll
|
|
1170
|
+
```
|
|
1171
|
+
|
|
1172
|
+
Use these for: graceful shutdown gates, generating non-colliding IDs across runners, observability metrics, and forcing a storage check after writing externally to the message store.
|
|
1173
|
+
|
|
1174
|
+
## Cluster runtime layers
|
|
1175
|
+
|
|
1176
|
+
Three levels of convenience.
|
|
1177
|
+
|
|
1178
|
+
### Level 1: testing — `TestRunner.layer`
|
|
1179
|
+
|
|
1180
|
+
```ts
|
|
1181
|
+
import { TestRunner } from 'effect/unstable/cluster';
|
|
1182
|
+
|
|
1183
|
+
const TestLayer = Layer.mergeAll(CounterLive, OrderLive).pipe(
|
|
1184
|
+
Layer.provideMerge(TestRunner.layer)
|
|
1185
|
+
);
|
|
1186
|
+
```
|
|
1187
|
+
|
|
1188
|
+
In-memory `MessageStorage` (with an inspectable `MemoryDriver`), in-memory `RunnerStorage`, no-op `RunnerHealth`. Single process. Provides `ShardingConfig.layer()` automatically.
|
|
1189
|
+
|
|
1190
|
+
The `MemoryDriver` is observable in tests:
|
|
1191
|
+
|
|
1192
|
+
```ts
|
|
1193
|
+
const driver = yield* MessageStorage.MemoryDriver;
|
|
1194
|
+
expect(driver.requests.size).toBe(9);
|
|
1195
|
+
expect(driver.journal[0].address.entityId).toBe('test-1');
|
|
1196
|
+
```
|
|
1197
|
+
|
|
1198
|
+
### Level 2: single-node, sql-backed — `SingleRunner.layer`
|
|
1199
|
+
|
|
1200
|
+
```ts
|
|
1201
|
+
import { SingleRunner } from 'effect/unstable/cluster';
|
|
1202
|
+
|
|
1203
|
+
const ClusterLayer = SingleRunner.layer({
|
|
1204
|
+
shardingConfig: { entityMaxIdleTime: '10 minutes' },
|
|
1205
|
+
runnerStorage: 'sql' // or 'memory'
|
|
1206
|
+
}).pipe(Layer.provide(SqlClientLayer));
|
|
1207
|
+
```
|
|
1208
|
+
|
|
1209
|
+
Ideal for: long-running Node/Bun processes that need durable workflows or persistent entities but don't need to scale across machines.
|
|
1210
|
+
|
|
1211
|
+
### Level 3: production multi-node — platform bundles
|
|
1212
|
+
|
|
1213
|
+
The Node and Bun platform packages ship opinionated all-in-one layers that wire transport + serialization + storage + health checks:
|
|
1214
|
+
|
|
1215
|
+
```ts
|
|
1216
|
+
import { NodeClusterSocket } from '@effect/platform-node';
|
|
1217
|
+
|
|
1218
|
+
const ClusterLayer = NodeClusterSocket.layer({
|
|
1219
|
+
serialization: 'msgpack', // or 'ndjson'; default 'msgpack'
|
|
1220
|
+
clientOnly: false, // true → don't bind a server port
|
|
1221
|
+
storage: 'sql', // 'sql' | 'memory' | 'byo'; default 'sql'
|
|
1222
|
+
runnerHealth: 'ping', // 'ping' | 'k8s'; default 'ping'
|
|
1223
|
+
runnerHealthK8s: { namespace: 'default', labelSelector: 'app=runner' },
|
|
1224
|
+
shardingConfig: {
|
|
1225
|
+
runnerShardWeight: 2,
|
|
1226
|
+
shardsPerGroup: 300
|
|
1227
|
+
}
|
|
1228
|
+
}).pipe(Layer.provide(SqlClientLayer));
|
|
1229
|
+
```
|
|
1230
|
+
|
|
1231
|
+
Variants:
|
|
1232
|
+
|
|
1233
|
+
- **`NodeClusterSocket.layer`** — TCP socket transport
|
|
1234
|
+
- **`NodeClusterHttp.layer({ transport: 'http' | 'websocket', ... })`** — HTTP or WebSocket transport (needs a port; behind ELB/ingress)
|
|
1235
|
+
- **`BunClusterSocket.layer`** / **`BunClusterHttp.layer`** — Bun equivalents
|
|
1236
|
+
|
|
1237
|
+
`clientOnly: true` skips opening a server port. Use for browser/edge clients that send RPCs *into* the cluster but don't host entities themselves.
|
|
1238
|
+
|
|
1239
|
+
`storage: 'byo'` gives you `MessageStorage` and `RunnerStorage` as required services so you can plug your own implementations.
|
|
1240
|
+
|
|
1241
|
+
### Manual assembly (when you need to deviate)
|
|
1242
|
+
|
|
1243
|
+
When the bundles aren't quite right, assemble from the primitives:
|
|
1244
|
+
|
|
1245
|
+
| Layer | Purpose |
|
|
1246
|
+
|---|---|
|
|
1247
|
+
| `MessageStorage.layerNoop` | discard messages; for `clientOnly` setups that don't need replay |
|
|
1248
|
+
| `MessageStorage.layerMemory` | in-process, with `MemoryDriver` |
|
|
1249
|
+
| `SqlMessageStorage.layer` / `layerWith({ prefix? })` | production durable storage |
|
|
1250
|
+
| `RunnerStorage.layerMemory` | in-process |
|
|
1251
|
+
| `SqlRunnerStorage.layer` / `layerWith({ prefix? })` | production |
|
|
1252
|
+
| `RunnerHealth.layerNoop` | testing |
|
|
1253
|
+
| `RunnerHealth.layerPing` | runners ping each other via RPC |
|
|
1254
|
+
| `RunnerHealth.layerK8s({ namespace?, labelSelector? })` | mark runners unhealthy via the k8s API |
|
|
1255
|
+
| `Runners.layerRpc` | RPC-based runner-to-runner communication (default for socket/http bundles) |
|
|
1256
|
+
| `Runners.layerNoop` | single-process; no inter-runner RPC |
|
|
1257
|
+
| `HttpRunner.layerHttp` / `layerWebsocket` | HTTP-based runner transport with default `/` path |
|
|
1258
|
+
| `HttpRunner.layerHttpClientOnly` / `layerWebsocketClientOnly` | client-only variants |
|
|
1259
|
+
| `HttpRunner.layerHttpOptions({ path })` / `layerWebsocketOptions({ path })` | path-customized transport |
|
|
1260
|
+
| `Sharding.layer` | the sharding service itself; requires the four storage/runner/health services |
|
|
1261
|
+
| `ShardingConfig.layer(partial?)` | constant config |
|
|
1262
|
+
| `ShardingConfig.layerFromEnv(partial?)` | reads `RUNNER_ADDRESS_HOST`, `RUNNER_ADDRESS_PORT`, etc. |
|
|
1263
|
+
|
|
1264
|
+
`ShardingConfig` defaults are sane:
|
|
1265
|
+
|
|
1266
|
+
- `shardsPerGroup: 300` — keep consistent across all runners
|
|
1267
|
+
- `availableShardGroups: ['default']` — every shard group that exists across the whole cluster
|
|
1268
|
+
- `assignedShardGroups: ['default']` — the subset of those groups this runner is allowed to own
|
|
1269
|
+
- `entityMaxIdleTime: 1 minute`
|
|
1270
|
+
- `entityMailboxCapacity: 4096`
|
|
1271
|
+
- `maxResidentEntities: 10_000` — runner-wide cap across all entity types
|
|
1272
|
+
- `unprocessedMessageBatchSize: 1024` — maximum storage rows claimed per poll
|
|
1273
|
+
- `entityTerminationTimeout: 15 seconds` — k8s-friendly
|
|
1274
|
+
- `preemptiveShutdown: true` — drain on entity shutdown
|
|
1275
|
+
- `runnerShardWeight: 1` — relative shard allocation
|
|
1276
|
+
|
|
1277
|
+
`availableShardGroups` is **cluster-wide** and must be identical on every runner that shares the same storage backend — shard and advisory-lock numbering is derived from it. `assignedShardGroups` is per-runner and is filtered against `availableShardGroups`, so a runner only ever owns groups that appear in *both*. If your code routes entities or workflows to a non-`default` `ClusterSchema.ShardGroup`, that group must be in `availableShardGroups` everywhere and in `assignedShardGroups` on the runners meant to host it:
|
|
1278
|
+
|
|
1279
|
+
```ts
|
|
1280
|
+
import { ShardingConfig } from 'effect/unstable/cluster';
|
|
1281
|
+
|
|
1282
|
+
const Config = ShardingConfig.layer({
|
|
1283
|
+
availableShardGroups: ['default', 'workflow'], // cluster-wide; same on all runners
|
|
1284
|
+
assignedShardGroups: ['default', 'workflow'] // this runner may own both groups
|
|
1285
|
+
});
|
|
1286
|
+
```
|
|
1287
|
+
|
|
1288
|
+
Under `layerFromEnv` (which constant-cases env keys) these read from `AVAILABLE_SHARD_GROUPS` and `SHARD_GROUPS` — note the assigned-groups env key is `SHARD_GROUPS`, not `ASSIGNED_SHARD_GROUPS`.
|
|
1289
|
+
|
|
1290
|
+
`ShardingConfig.config` is the `Config<ShardingConfig['Service']>` you can compose with other configs in `layerFromEnv`.
|
|
1291
|
+
|
|
1292
|
+
### Residency And Bounded Storage Reads
|
|
1293
|
+
|
|
1294
|
+
`maxResidentEntities` limits the total entities resident on one runner, not the mailbox size of an individual entity. At the cap:
|
|
1295
|
+
|
|
1296
|
+
- Messages already addressed to resident entities continue to make progress.
|
|
1297
|
+
- Volatile sends to a new entity address fail with `MailboxFull`.
|
|
1298
|
+
- Persisted sends still succeed; their messages remain in storage until passivation frees a residency slot.
|
|
1299
|
+
- Set `maxResidentEntities: 'unbounded'` only programmatically to restore the old unbounded behavior. Environment configuration accepts positive integers only.
|
|
1300
|
+
|
|
1301
|
+
The storage poller reads at most `unprocessedMessageBatchSize` messages per batch. Custom `MessageStorage` implementations must support `unprocessedMessages(shardIds, { limit?, addresses? })`; only returned messages may be claimed. The decoded service retains `resetAddress` and adds batched `resetAddresses`, while the low-level `MessageStorage.Encoded` contract uses `resetAddresses` instead of the former `resetAddress` operation.
|
|
1302
|
+
|
|
1303
|
+
The memory driver now uses the same ten-minute claim window as SQL. `resetAddress`/`resetAddresses` or `resetShards` makes claimed messages immediately eligible again, which prevents bounded reads from repeatedly selecting in-flight rows while still allowing explicit recovery.
|
|
1304
|
+
|
|
1305
|
+
For custom SQL composition, `SqlMessageStorage.makeEncoded({ prefix? })` returns the low-level `MessageStorage.Encoded` driver directly. `SqlMessageStorage.make`, `layer`, and `layerWith` remain the decoded service constructors.
|
|
1306
|
+
|
|
1307
|
+
## Bridges — exposing entities and workflows as RPC/HTTP
|
|
1308
|
+
|
|
1309
|
+
Both `Entity` and `Workflow` ship "proxy" helpers that auto-derive `RpcGroup`s and `HttpApiGroup`s from your domain definitions, plus matching server layers that fan messages back into the cluster. This is how you put a public API in front of cluster-only protocols without writing glue.
|
|
1310
|
+
|
|
1311
|
+
### `EntityProxy` — entity → RPC / HTTP
|
|
1312
|
+
|
|
1313
|
+
```ts
|
|
1314
|
+
import { Entity, EntityProxy, EntityProxyServer } from 'effect/unstable/cluster';
|
|
1315
|
+
import { RpcServer } from 'effect/unstable/rpc';
|
|
1316
|
+
import { HttpApi, HttpApiBuilder } from 'effect/unstable/httpapi';
|
|
1317
|
+
|
|
1318
|
+
const Counter = Entity.make('Counter', [Increment, GetCount])
|
|
1319
|
+
.annotateRpcs(ClusterSchema.Persisted, true);
|
|
1320
|
+
|
|
1321
|
+
// --- Expose as RPC ---
|
|
1322
|
+
class CounterRpcs extends EntityProxy.toRpcGroup(Counter) {}
|
|
1323
|
+
|
|
1324
|
+
// CounterRpcs has two rpcs per entity rpc:
|
|
1325
|
+
// "Counter.Increment" → returns Number
|
|
1326
|
+
// "Counter.IncrementDiscard" → fire-and-forget (returns void, only cluster errors)
|
|
1327
|
+
|
|
1328
|
+
const RpcServerLayer = RpcServer.layer(CounterRpcs).pipe(
|
|
1329
|
+
Layer.provide(EntityProxyServer.layerRpcHandlers(Counter))
|
|
1330
|
+
);
|
|
1331
|
+
|
|
1332
|
+
// --- Expose as HTTP ---
|
|
1333
|
+
class Api extends HttpApi.make('api')
|
|
1334
|
+
.add(EntityProxy.toHttpApiGroup('counter', Counter).prefix('/counter'))
|
|
1335
|
+
{}
|
|
1336
|
+
|
|
1337
|
+
// Generates: POST /counter/increment/:entityId, POST /counter/increment/:entityId/discard, etc.
|
|
1338
|
+
|
|
1339
|
+
const ApiLayer = HttpApiBuilder.layer(Api).pipe(
|
|
1340
|
+
Layer.provide(EntityProxyServer.layerHttpApi(Api, 'counter', Counter))
|
|
1341
|
+
);
|
|
1342
|
+
```
|
|
1343
|
+
|
|
1344
|
+
The generated **RPC** payload wraps the original payload as `{ entityId: string, payload: <original payload> }`. The generated **HTTP** endpoints are shaped differently: `entityId` is a route param (`POST /counter/increment/:entityId`) read server-side via `params.entityId`, and the request **body** is the original payload directly — there is no `{ entityId, payload }` wrapper over HTTP. Request/reply endpoints include the original error type plus `MailboxFull | AlreadyProcessingMessage | PersistenceError | EntityNotAssignedToRunner`; discard endpoints remain unchanged because they do not await assignment or a reply.
|
|
1345
|
+
|
|
1346
|
+
### Encrypted Event-Log Compatibility
|
|
1347
|
+
|
|
1348
|
+
As of beta.106, `EventLogEncryption.encrypt` returns `{ iv, encryptedEntry }` for every input entry, and `EventLogMessage.WriteEntries.encryptedEntries` carries `{ entryId, iv, encryptedEntry }` values. A fresh AES-GCM IV is generated per entry. This changes the encrypted replication wire format: upgrade encrypted event-log clients and servers together rather than performing a mixed-version rolling deployment.
|
|
1349
|
+
|
|
1350
|
+
### `WorkflowProxy` — workflow → RPC / HTTP
|
|
1351
|
+
|
|
1352
|
+
```ts
|
|
1353
|
+
import { Workflow, WorkflowProxy, WorkflowProxyServer } from 'effect/unstable/workflow';
|
|
1354
|
+
import { RpcServer } from 'effect/unstable/rpc';
|
|
1355
|
+
|
|
1356
|
+
const myWorkflows = [EmailWorkflow, OrderWorkflow] as const;
|
|
1357
|
+
|
|
1358
|
+
// RPC: generates 3 rpcs per workflow: <Name>, <Name>Discard, <Name>Resume
|
|
1359
|
+
class WorkflowRpcs extends WorkflowProxy.toRpcGroup(myWorkflows) {}
|
|
1360
|
+
|
|
1361
|
+
const ServerLayer = RpcServer.layer(WorkflowRpcs).pipe(
|
|
1362
|
+
Layer.provide(WorkflowProxyServer.layerRpcHandlers(myWorkflows))
|
|
1363
|
+
);
|
|
1364
|
+
|
|
1365
|
+
// HTTP: generates 3 endpoints per workflow under e.g. /emailworkflow, /emailworkflow/discard, /emailworkflow/resume
|
|
1366
|
+
class Api extends HttpApi.make('api')
|
|
1367
|
+
.add(WorkflowProxy.toHttpApiGroup('workflows', myWorkflows))
|
|
1368
|
+
{}
|
|
1369
|
+
```
|
|
1370
|
+
|
|
1371
|
+
To namespace the generated rpcs, pass `prefix` as the **second** argument: `WorkflowProxy.toRpcGroup(myWorkflows, { prefix: 'wf.' })`. The server handlers must use the same prefix: `WorkflowProxyServer.layerRpcHandlers(myWorkflows, { prefix: 'wf.' })`.
|
|
1372
|
+
|
|
1373
|
+
These proxies are how you give a frontend or an external system a typed RPC/HTTP surface that drives durable workflows, without leaking workflow-engine internals.
|
|
1374
|
+
|
|
1375
|
+
## Cluster + workflow integration — `ClusterWorkflowEngine`
|
|
1376
|
+
|
|
1377
|
+
The in-memory `WorkflowEngine.layerMemory` is for testing only. For production, use `ClusterWorkflowEngine.layer`, which wires the workflow engine into the cluster's `Sharding` + `MessageStorage`:
|
|
1378
|
+
|
|
1379
|
+
```ts
|
|
1380
|
+
import { ClusterWorkflowEngine } from 'effect/unstable/cluster';
|
|
1381
|
+
import { Workflow } from 'effect/unstable/workflow';
|
|
1382
|
+
|
|
1383
|
+
const WorkflowsLayer = Layer.mergeAll(
|
|
1384
|
+
EmailWorkflowLayer,
|
|
1385
|
+
OrderWorkflowLayer
|
|
1386
|
+
).pipe(Layer.provideMerge(ClusterWorkflowEngine.layer));
|
|
1387
|
+
|
|
1388
|
+
// then provide ClusterLayer (NodeClusterSocket.layer / SingleRunner.layer / etc.)
|
|
1389
|
+
```
|
|
1390
|
+
|
|
1391
|
+
### Workflow shard-group routing
|
|
1392
|
+
|
|
1393
|
+
A workflow can be annotated with `ClusterSchema.ShardGroup`, exactly like an entity:
|
|
1394
|
+
|
|
1395
|
+
```ts
|
|
1396
|
+
import { ClusterSchema } from 'effect/unstable/cluster';
|
|
1397
|
+
|
|
1398
|
+
const OrderWorkflow = Workflow.make({ /* ... */ })
|
|
1399
|
+
.annotate(ClusterSchema.ShardGroup, () => 'workflow');
|
|
1400
|
+
```
|
|
1401
|
+
|
|
1402
|
+
`ClusterWorkflowEngine` reads that annotation when computing the workflow entity's address, so the workflow's entity messages, durable clock wake-ups, and registered durable-deferred completions all route through the owning workflow's shard group. Any non-`default` group must appear in `ShardingConfig.availableShardGroups` cluster-wide and in `assignedShardGroups` on the runners meant to host it (e.g. `['default', 'workflow']`), or those messages have nowhere to land.
|
|
1403
|
+
|
|
1404
|
+
Workflow execution entities and the durable-clock entity use a fixed `10 seconds` idle timeout. Completed and suspended workflows therefore release runner residency slots quickly; their durable state is reconstructed from storage when the next resume, deferred completion, or clock message arrives. Do not use `Entity.keepAlive` to pin these internal workflow entities.
|
|
1405
|
+
|
|
1406
|
+
See the `effect-workflow` skill for the full `Workflow` / `Activity` / `DurableClock` / `DurableDeferred` / `DurableQueue` API surface.
|
|
1407
|
+
|
|
1408
|
+
## Reactive frontend — `AtomRpc`
|
|
1409
|
+
|
|
1410
|
+
`AtomRpc.Service()(...)` produces an Atom-aware RPC client with `query` (cached, reactive) and `mutation` (invalidating) helpers, designed for React + Atom apps. See the `effect-atom-rpc` skill for the full surface; brief teaser:
|
|
1411
|
+
|
|
1412
|
+
```ts
|
|
1413
|
+
import { AtomRpc } from 'effect/unstable/reactivity';
|
|
1414
|
+
|
|
1415
|
+
class UsersClient extends AtomRpc.Service<UsersClient>()('UsersClient', {
|
|
1416
|
+
group: UsersGroup,
|
|
1417
|
+
protocol: RpcClient.layerProtocolHttp({ url: '/api/rpc' }).pipe(
|
|
1418
|
+
Layer.provide(RpcSerialization.layerJson),
|
|
1419
|
+
Layer.provide(FetchHttpClient.layer)
|
|
1420
|
+
)
|
|
1421
|
+
}) {}
|
|
1422
|
+
|
|
1423
|
+
// In a component:
|
|
1424
|
+
const userResult = useAtomValue(
|
|
1425
|
+
UsersClient.query('GetUser', { id: 'u1' }, {
|
|
1426
|
+
timeToLive: '30 seconds',
|
|
1427
|
+
serializationKey: 'user-u1', // for SSR hydration
|
|
1428
|
+
reactivityKeys: ['users', 'user-u1']
|
|
1429
|
+
})
|
|
1430
|
+
);
|
|
1431
|
+
|
|
1432
|
+
const incrementUser = useAtomSet(UsersClient.mutation('IncrementUser'));
|
|
1433
|
+
incrementUser({ payload: { id: 'u1' }, reactivityKeys: ['users'] });
|
|
1434
|
+
```
|
|
1435
|
+
|
|
1436
|
+
## Complete End-to-End Example
|
|
1437
|
+
|
|
1438
|
+
A small users service with auth middleware, websocket transport, and a test client:
|
|
1439
|
+
|
|
1440
|
+
```ts
|
|
1441
|
+
// --- definitions/users.ts (shared between server and client) ---
|
|
1442
|
+
import { Context, Schema } from 'effect';
|
|
1443
|
+
import { Rpc, RpcGroup, RpcMiddleware } from 'effect/unstable/rpc';
|
|
1444
|
+
|
|
1445
|
+
export class User extends Schema.Class<User>('User')({
|
|
1446
|
+
id: Schema.String,
|
|
1447
|
+
name: Schema.String
|
|
1448
|
+
}) {}
|
|
1449
|
+
|
|
1450
|
+
export class UserNotFound extends Schema.Error<UserNotFound>('UserNotFound')({
|
|
1451
|
+
_tag: Schema.tag('UserNotFound'),
|
|
1452
|
+
id: Schema.String
|
|
1453
|
+
}) {}
|
|
1454
|
+
|
|
1455
|
+
export class Unauthorized extends Schema.Error<Unauthorized>('Unauthorized')({
|
|
1456
|
+
_tag: Schema.tag('Unauthorized')
|
|
1457
|
+
}) {}
|
|
1458
|
+
|
|
1459
|
+
export class CurrentUser extends Context.Service<CurrentUser, User>()('CurrentUser') {}
|
|
1460
|
+
|
|
1461
|
+
export class AuthMiddleware extends RpcMiddleware.Service<AuthMiddleware, {
|
|
1462
|
+
provides: CurrentUser;
|
|
1463
|
+
}>()('AuthMiddleware', {
|
|
1464
|
+
error: Unauthorized,
|
|
1465
|
+
requiredForClient: true
|
|
1466
|
+
}) {}
|
|
1467
|
+
|
|
1468
|
+
export class GetUser extends Rpc.make('GetUser', {
|
|
1469
|
+
payload: { id: Schema.String },
|
|
1470
|
+
success: User,
|
|
1471
|
+
error: UserNotFound
|
|
1472
|
+
}) {}
|
|
1473
|
+
|
|
1474
|
+
export class StreamUsers extends Rpc.make('StreamUsers', {
|
|
1475
|
+
payload: { since: Schema.DateTimeUtc },
|
|
1476
|
+
success: User,
|
|
1477
|
+
stream: true
|
|
1478
|
+
}) {}
|
|
1479
|
+
|
|
1480
|
+
export const UsersGroup = RpcGroup.make(GetUser, StreamUsers).middleware(AuthMiddleware);
|
|
1481
|
+
```
|
|
1482
|
+
|
|
1483
|
+
```ts
|
|
1484
|
+
// --- server/handlers.ts ---
|
|
1485
|
+
import { Effect, Layer, Stream } from 'effect';
|
|
1486
|
+
import { Headers } from 'effect/unstable/http';
|
|
1487
|
+
import { Rpc, RpcMiddleware } from 'effect/unstable/rpc';
|
|
1488
|
+
import { CurrentUser, UnauthorizedError, UsersGroup, User, UserNotFound } from '../definitions/users.ts';
|
|
1489
|
+
|
|
1490
|
+
export const UsersHandlersLive = UsersGroup.toLayer(
|
|
1491
|
+
Effect.gen(function*() {
|
|
1492
|
+
const db = yield* Database;
|
|
1493
|
+
return UsersGroup.of({
|
|
1494
|
+
GetUser: (payload) =>
|
|
1495
|
+
db.findUser(payload.id).pipe(
|
|
1496
|
+
Effect.mapError(() => new UserNotFound({ id: payload.id }))
|
|
1497
|
+
),
|
|
1498
|
+
StreamUsers: (payload) =>
|
|
1499
|
+
db.streamUsersSince(payload.since).pipe(Stream.map((row) => new User(row)))
|
|
1500
|
+
});
|
|
1501
|
+
})
|
|
1502
|
+
);
|
|
1503
|
+
|
|
1504
|
+
export const AuthLive = Layer.succeed(AuthMiddleware)(
|
|
1505
|
+
AuthMiddleware.of((effect, { headers }) => {
|
|
1506
|
+
const token = headers.authorization;
|
|
1507
|
+
if (!token) return Effect.fail(new Unauthorized());
|
|
1508
|
+
return verifyToken(token).pipe(
|
|
1509
|
+
Effect.flatMap((user) => Effect.provideService(effect, CurrentUser, user))
|
|
1510
|
+
);
|
|
1511
|
+
})
|
|
1512
|
+
);
|
|
1513
|
+
```
|
|
1514
|
+
|
|
1515
|
+
```ts
|
|
1516
|
+
// --- server/main.ts ---
|
|
1517
|
+
import { Layer } from 'effect';
|
|
1518
|
+
import { NodeHttpServer, NodeRuntime } from '@effect/platform-node';
|
|
1519
|
+
import { HttpRouter } from 'effect/unstable/http';
|
|
1520
|
+
import { RpcSerialization, RpcServer } from 'effect/unstable/rpc';
|
|
1521
|
+
import { createServer } from 'node:http';
|
|
1522
|
+
|
|
1523
|
+
const ServerLayer = RpcServer.layerHttp({
|
|
1524
|
+
group: UsersGroup,
|
|
1525
|
+
path: '/api/rpc',
|
|
1526
|
+
protocol: 'websocket',
|
|
1527
|
+
disableFatalDefects: true
|
|
1528
|
+
}).pipe(
|
|
1529
|
+
Layer.provide([UsersHandlersLive, AuthLive]),
|
|
1530
|
+
Layer.provide(RpcSerialization.layerNdjson)
|
|
1531
|
+
);
|
|
1532
|
+
|
|
1533
|
+
const HttpLayer = HttpRouter.serve(ServerLayer).pipe(
|
|
1534
|
+
Layer.provide(NodeHttpServer.layer(createServer, { port: 3000 }))
|
|
1535
|
+
);
|
|
1536
|
+
|
|
1537
|
+
Layer.launch(HttpLayer).pipe(NodeRuntime.runMain);
|
|
1538
|
+
```
|
|
1539
|
+
|
|
1540
|
+
```ts
|
|
1541
|
+
// --- client/users-client.ts ---
|
|
1542
|
+
import { Context, Effect, Layer } from 'effect';
|
|
1543
|
+
import { FetchHttpClient } from 'effect/unstable/http';
|
|
1544
|
+
import { RpcClient, RpcMiddleware, RpcSerialization } from 'effect/unstable/rpc';
|
|
1545
|
+
import { RpcClientError } from 'effect/unstable/rpc/RpcClientError';
|
|
1546
|
+
|
|
1547
|
+
const AuthClient = RpcMiddleware.layerClient(AuthMiddleware, ({ next, request }) =>
|
|
1548
|
+
next({
|
|
1549
|
+
...request,
|
|
1550
|
+
headers: Headers.set(request.headers, 'authorization', `Bearer ${getToken()}`)
|
|
1551
|
+
}));
|
|
1552
|
+
|
|
1553
|
+
export class UsersClient extends Context.Service<
|
|
1554
|
+
UsersClient,
|
|
1555
|
+
RpcClient.RpcClient<RpcGroup.Rpcs<typeof UsersGroup>, RpcClientError>
|
|
1556
|
+
>()('UsersClient') {
|
|
1557
|
+
static readonly layer = Layer.effect(UsersClient)(
|
|
1558
|
+
RpcClient.make(UsersGroup)
|
|
1559
|
+
).pipe(
|
|
1560
|
+
Layer.provide(AuthClient),
|
|
1561
|
+
Layer.provide(RpcClient.layerProtocolSocket()),
|
|
1562
|
+
Layer.provide(NodeSocket.layerWebSocket('ws://localhost:3000/api/rpc')),
|
|
1563
|
+
Layer.provide(RpcSerialization.layerNdjson)
|
|
1564
|
+
);
|
|
1565
|
+
|
|
1566
|
+
static readonly layerTest = Layer.effect(UsersClient)(
|
|
1567
|
+
RpcTest.makeClient(UsersGroup)
|
|
1568
|
+
).pipe(Layer.provide([UsersHandlersLive, AuthLive, AuthClient]));
|
|
1569
|
+
}
|
|
1570
|
+
|
|
1571
|
+
// Usage:
|
|
1572
|
+
const program = Effect.gen(function*() {
|
|
1573
|
+
const client = yield* UsersClient;
|
|
1574
|
+
const user = yield* client.GetUser({ id: 'u1' });
|
|
1575
|
+
yield* Effect.logInfo('got', user);
|
|
1576
|
+
});
|
|
1577
|
+
|
|
1578
|
+
const usersByName = Effect.gen(function*() {
|
|
1579
|
+
const client = yield* UsersClient;
|
|
1580
|
+
return yield* client
|
|
1581
|
+
.StreamUsers({ since: yesterday })
|
|
1582
|
+
.pipe(Stream.runCollect);
|
|
1583
|
+
}).pipe(
|
|
1584
|
+
RpcClient.withHeaders({ 'x-tenant': 't1' })
|
|
1585
|
+
);
|
|
1586
|
+
```
|
|
1587
|
+
|
|
1588
|
+
## Anti-patterns
|
|
1589
|
+
|
|
1590
|
+
1. **Confusing `RpcGroup` and `Entity` handler signatures.** Group: `(payload, options)`. Entity: `(envelope)`. The compiler will catch most cases but not all (the second arg is optional in groups).
|
|
1591
|
+
2. **Using `clientId: number` in handler types.** It's `client: Rpc.ServerClient` (which exposes `client.id` and a mutable `annotations` context).
|
|
1592
|
+
3. **Treating `Rpc.fork` and `Rpc.uninterruptible` like options.** They are wrappers — apply with `.pipe(Rpc.fork)` on the handler's return value.
|
|
1593
|
+
4. **Forgetting `primaryKey` on entity rpcs that need dedup.** Without it, retried sends are *not* deduplicated; this is critical for clustered handlers that should be idempotent.
|
|
1594
|
+
5. **Using `JSON.parse`/`JSON.stringify` on rpc payloads.** Schemas already round-trip; if you need a JSON string boundary, use `Schema.fromJsonString(...)`.
|
|
1595
|
+
6. **Picking `layerJson` for a streaming or socket transport.** No framing → message corruption. Use `layerNdjson` or `layerMsgPack`.
|
|
1596
|
+
7. **Forgetting `Layer.provide(AuthClient)` on a `requiredForClient: true` middleware.** Compile error, but a confusing one if you don't know to look.
|
|
1597
|
+
8. **Using `WorkflowEngine.layerMemory` in production.** It is testing-only; use `ClusterWorkflowEngine.layer` plus a real cluster bundle.
|
|
1598
|
+
9. **Forgetting `ShardingConfig` when using `Entity.makeTestClient`.** `TestRunner.layer` provides one; `makeTestClient` does not.
|
|
1599
|
+
10. **Treating entity ids as application ids.** They're routing keys. Use composite ids when you need tenancy/multi-org isolation: `entityId = '${tenantId}:${userId}'` and a custom `ShardGroup` annotation that derives the group from the prefix.
|
|
1600
|
+
11. **Mounting an HTTP API directly on top of an `Entity` instead of using `EntityProxy`.** Reinvents the proxy/discard/error mapping the proxy gives you for free.
|
|
1601
|
+
12. **Reading `Date.now()` inside an entity or workflow handler.** Use `Clock` (and inside workflows, `DateTime.now` works because the engine wraps activities). For durable timers, use `DurableClock.sleep`.
|
|
1602
|
+
13. **`yield* fiber` / `yield* deferred` / `yield* ref`.** Removed in v4. Use `Fiber.join`, `Deferred.await`, `Ref.get` explicitly.
|
|
1603
|
+
14. **Treating `maxResidentEntities` like mailbox capacity.** It is a runner-wide resident-entity cap. Persisted messages wait in storage at the cap; volatile sends to new addresses fail with `MailboxFull`.
|
|
1604
|
+
15. **Implementing the old encoded storage driver.** `MessageStorage.Encoded` now requires bounded/address-filtered `unprocessedMessages` and batched `resetAddresses`.
|
|
1605
|
+
|
|
1606
|
+
## Rules
|
|
1607
|
+
|
|
1608
|
+
- Define rpcs in shared modules so server, client, entity, proxy, and AtomRpc all consume the same definitions.
|
|
1609
|
+
- Pick `class extends Rpc.make(...)` for nominal types you import widely; pick `const` for ad-hoc ones.
|
|
1610
|
+
- Use `Schema.Class` for non-trivial payloads/successes/errors; let `Rpc.make` build a struct only for tiny inline payloads.
|
|
1611
|
+
- Use `Schema.TaggedError` (or `Schema.Error` with a `Schema.tag` field) for every rpc/middleware error.
|
|
1612
|
+
- Set `defect: Schema.Defect({ includeStack: true })` on rpcs whose defects you want to debug across the wire.
|
|
1613
|
+
- Set `primaryKey` on every rpc that gets persisted or retried; cluster will dedupe based on it.
|
|
1614
|
+
- Annotate persistent entities with `ClusterSchema.Persisted` (via `entity.annotateRpcs`).
|
|
1615
|
+
- Use `Rpc.fork` for read-only handlers that should run concurrently; otherwise let the per-entity `concurrency: 1` default protect state.
|
|
1616
|
+
- Use `Entity.CurrentAddress` instead of threading `entityId` through payloads.
|
|
1617
|
+
- Use `Entity.keepAlive(true)` to pin entities while they own long-running async work.
|
|
1618
|
+
- Use `EntityProxy.toRpcGroup` / `toHttpApiGroup` and `WorkflowProxy.toRpcGroup` / `toHttpApiGroup` to expose cluster protocols externally — never hand-roll the dispatch.
|
|
1619
|
+
- Use `RpcTest.makeClient` for handler tests and `Entity.makeTestClient` for entity tests; reach for `TestRunner.layer` for full-cluster integration tests.
|
|
1620
|
+
- For production cluster, use `NodeClusterSocket.layer` / `NodeClusterHttp.layer` (or the Bun equivalents) unless you specifically need to assemble layers manually.
|
|
1621
|
+
- Size `maxResidentEntities` and `unprocessedMessageBatchSize` deliberately for the runner's memory and storage throughput.
|
|
1622
|
+
- Match transport ↔ serialization: HTTP → `layerJson`; sockets/websocket/streaming → `layerNdjson` or `layerMsgPack`.
|
|
1623
|
+
- Pattern-match on `client.GetUser(...).pipe(Effect.catchTag('UserNotFound', ...), Effect.catchFilter(...))` for typed recovery; reserve broad `Effect.catchAll` for the runtime boundary.
|