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.
Files changed (118) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +278 -0
  3. package/guidance/effect-first-development.md +1247 -0
  4. package/guidance/post__effect-and-the-near-inexpressible-majesty-of-layers.md +490 -0
  5. package/guidance/post__parse-dont-validate.md +109 -0
  6. package/guidance/progressive-disclosure-guidance.md +38 -0
  7. package/package.json +63 -0
  8. package/patterns/avoid-any.md +37 -0
  9. package/patterns/avoid-data-tagged-error.md +34 -0
  10. package/patterns/avoid-direct-json.md +51 -0
  11. package/patterns/avoid-direct-tag-checks.md +54 -0
  12. package/patterns/avoid-expect-in-if.md +52 -0
  13. package/patterns/avoid-mutable-state.md +70 -0
  14. package/patterns/avoid-native-fetch.md +61 -0
  15. package/patterns/avoid-node-imports.md +86 -0
  16. package/patterns/avoid-non-null-assertion.md +44 -0
  17. package/patterns/avoid-object-type.md +46 -0
  18. package/patterns/avoid-option-getorthrow.md +39 -0
  19. package/patterns/avoid-platform-coupling.md +43 -0
  20. package/patterns/avoid-process-env.md +43 -0
  21. package/patterns/avoid-react-hooks.md +73 -0
  22. package/patterns/avoid-schema-suffix.md +45 -0
  23. package/patterns/avoid-sync-fs.md +68 -0
  24. package/patterns/avoid-try-catch.md +47 -0
  25. package/patterns/avoid-ts-ignore.md +38 -0
  26. package/patterns/avoid-untagged-errors.md +67 -0
  27. package/patterns/avoid-yield-ref.md +46 -0
  28. package/patterns/casting-awareness.md +46 -0
  29. package/patterns/context-tag-extends.md +84 -0
  30. package/patterns/effect-catchall-default.md +61 -0
  31. package/patterns/effect-promise-vs-trypromise.md +47 -0
  32. package/patterns/effect-run-in-body.md +58 -0
  33. package/patterns/imperative-loops.md +76 -0
  34. package/patterns/prefer-arr-sort.md +52 -0
  35. package/patterns/prefer-duration-values.md +56 -0
  36. package/patterns/prefer-effect-fn.md +161 -0
  37. package/patterns/prefer-match-over-switch.md +48 -0
  38. package/patterns/prefer-option-over-null.md +56 -0
  39. package/patterns/prefer-redacted-config.md +70 -0
  40. package/patterns/prefer-schema-class.md +54 -0
  41. package/patterns/require-effect-concurrency.md +83 -0
  42. package/patterns/stream-large-files.md +63 -0
  43. package/patterns/throw-in-effect-gen.md +62 -0
  44. package/patterns/use-clock-service.md +45 -0
  45. package/patterns/use-command-executor-service.md +54 -0
  46. package/patterns/use-console-service.md +54 -0
  47. package/patterns/use-filesystem-service.md +59 -0
  48. package/patterns/use-http-client-service.md +77 -0
  49. package/patterns/use-path-service.md +53 -0
  50. package/patterns/use-random-service.md +45 -0
  51. package/patterns/use-temp-file-scoped.md +66 -0
  52. package/patterns/vm-in-wrong-file.md +51 -0
  53. package/patterns/yield-in-for-loop.md +61 -0
  54. package/skills/effect-ai-chat/SKILL.md +472 -0
  55. package/skills/effect-ai-language-model/SKILL.md +652 -0
  56. package/skills/effect-ai-prompt/SKILL.md +752 -0
  57. package/skills/effect-ai-provider/SKILL.md +668 -0
  58. package/skills/effect-ai-streaming/SKILL.md +418 -0
  59. package/skills/effect-ai-tool/SKILL.md +1132 -0
  60. package/skills/effect-atom-rpc/SKILL.md +488 -0
  61. package/skills/effect-atom-state/SKILL.md +640 -0
  62. package/skills/effect-batching/SKILL.md +614 -0
  63. package/skills/effect-cache/SKILL.md +570 -0
  64. package/skills/effect-cli/SKILL.md +523 -0
  65. package/skills/effect-command-executor/SKILL.md +675 -0
  66. package/skills/effect-concurrency-testing/SKILL.md +612 -0
  67. package/skills/effect-config/SKILL.md +580 -0
  68. package/skills/effect-context-witness/SKILL.md +274 -0
  69. package/skills/effect-domain-modeling/SKILL.md +1212 -0
  70. package/skills/effect-domain-predicates/SKILL.md +867 -0
  71. package/skills/effect-error-handling/SKILL.md +1581 -0
  72. package/skills/effect-fiber/SKILL.md +731 -0
  73. package/skills/effect-filesystem/SKILL.md +624 -0
  74. package/skills/effect-graph/SKILL.md +571 -0
  75. package/skills/effect-http-api/SKILL.md +1760 -0
  76. package/skills/effect-http-client/SKILL.md +989 -0
  77. package/skills/effect-http-server/SKILL.md +920 -0
  78. package/skills/effect-incremental-migration/SKILL.md +362 -0
  79. package/skills/effect-layer-design/SKILL.md +642 -0
  80. package/skills/effect-managed-runtime/SKILL.md +395 -0
  81. package/skills/effect-mcp-server/SKILL.md +608 -0
  82. package/skills/effect-observability/SKILL.md +719 -0
  83. package/skills/effect-optics/SKILL.md +554 -0
  84. package/skills/effect-parallelization/SKILL.md +668 -0
  85. package/skills/effect-path/SKILL.md +296 -0
  86. package/skills/effect-pattern-matching/SKILL.md +914 -0
  87. package/skills/effect-platform-abstraction/SKILL.md +1175 -0
  88. package/skills/effect-platform-layers/SKILL.md +514 -0
  89. package/skills/effect-pubsub-event-bus/SKILL.md +384 -0
  90. package/skills/effect-react-composition/SKILL.md +986 -0
  91. package/skills/effect-react-vm/SKILL.md +675 -0
  92. package/skills/effect-rpc-api/SKILL.md +624 -0
  93. package/skills/effect-rpc-client/SKILL.md +666 -0
  94. package/skills/effect-rpc-cluster/SKILL.md +1623 -0
  95. package/skills/effect-rpc-server/SKILL.md +767 -0
  96. package/skills/effect-scheduling/SKILL.md +124 -0
  97. package/skills/effect-schema-composition/SKILL.md +975 -0
  98. package/skills/effect-schema-v4/SKILL.md +691 -0
  99. package/skills/effect-scope/SKILL.md +682 -0
  100. package/skills/effect-service-implementation/SKILL.md +656 -0
  101. package/skills/effect-socket/SKILL.md +703 -0
  102. package/skills/effect-sql/SKILL.md +781 -0
  103. package/skills/effect-stream/SKILL.md +765 -0
  104. package/skills/effect-testing/SKILL.md +1331 -0
  105. package/skills/effect-typeclass-design/SKILL.md +161 -0
  106. package/skills/effect-wide-events/Article.md +66 -0
  107. package/skills/effect-wide-events/SKILL.md +95 -0
  108. package/skills/effect-workflow/SKILL.md +810 -0
  109. package/src/agent-policy.ts +22 -0
  110. package/src/enforcer.ts +104 -0
  111. package/src/frontmatter.ts +34 -0
  112. package/src/guidance.ts +66 -0
  113. package/src/index.ts +38 -0
  114. package/src/pattern-catalog.ts +115 -0
  115. package/src/pattern-matcher.ts +178 -0
  116. package/src/pattern.ts +97 -0
  117. package/src/skills.ts +29 -0
  118. 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.