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,920 @@
1
+ ---
2
+ name: effect-http-server
3
+ description: Build HTTP servers with effect/unstable/http — HttpRouter routes and middleware, HttpServerRequest schema decoding, HttpServerResponse constructors, multipart uploads, websocket upgrades, static files, NodeHttpServer/BunHttpServer layers, and in-memory web handlers. Use when serving raw HTTP routes, reading request bodies/cookies/uploads, writing server middleware, streaming responses, or testing handlers without a real port.
4
+ ---
5
+
6
+ You are an Effect TypeScript expert specializing in HTTP servers built with `effect/unstable/http` — `HttpRouter`, `HttpServer`, `HttpServerRequest`, `HttpServerResponse`, `HttpMiddleware`, and the platform server layers.
7
+
8
+ This skill covers the imperative HTTP server primitives. For the declarative, schema-first `HttpApi`/OpenAPI layer see the `effect-http-api` skill; for HTTP clients see `effect-http-client`; for raw TCP/WebSocket sockets see `effect-socket`.
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 — these modules are unstable and change between betas.
13
+
14
+ Key files:
15
+
16
+ - `packages/effect/src/unstable/http/HttpRouter.ts` — router service, `add`/`addAll`/`route`/`use`, `serve`, `toWebHandler`, schema decoders, `middleware`, `cors`, `provideRequest`, `RouterConfig`
17
+ - `packages/effect/src/unstable/http/HttpServer.ts` — `HttpServer` service, `serve`/`serveEffect`, address helpers, `layerTestClient`, `layerServices`
18
+ - `packages/effect/src/unstable/http/HttpServerRequest.ts` — request model, body accessors, `schemaBodyJson`/`schemaBodyForm`/etc., `ParsedSearchParams`, `upgrade`, `MaxBodySize`
19
+ - `packages/effect/src/unstable/http/HttpServerResponse.ts` — every response constructor and combinator, `toWeb`/`fromWeb`
20
+ - `packages/effect/src/unstable/http/HttpMiddleware.ts` — `logger`, `tracer`, `cors`, `xForwardedHeaders`, `searchParamsParser`, tracing config references
21
+ - `packages/effect/src/unstable/http/HttpEffect.ts` — `toWebHandler*`, `fromWebHandler`, `toHandled`, pre-response handlers, request scope management
22
+ - `packages/effect/src/unstable/http/HttpServerError.ts` — `HttpServerError` + reasons, `causeResponse`, `ClientAbort`
23
+ - `packages/effect/src/unstable/http/HttpServerRespondable.ts` — the error-to-response protocol
24
+ - `packages/effect/src/unstable/http/HttpBody.ts` — body variants (`Empty`/`Raw`/`Uint8Array`/`FormData`/`Stream`) and constructors
25
+ - `packages/effect/src/unstable/http/Headers.ts`, `Cookies.ts`, `Multipart.ts` — header/cookie/multipart models and limits
26
+ - `packages/effect/src/unstable/http/HttpStaticServer.ts` — static file serving
27
+ - `packages/platform-node/src/NodeHttpServer.ts` — Node server adapter, `layer`, `layerTest`, graceful shutdown
28
+ - `packages/platform-bun/src/BunHttpServer.ts` — Bun equivalent
29
+ - `packages/platform-node/test/NodeHttpServer.test.ts` — the best end-to-end reference for real route/middleware/multipart wiring
30
+
31
+ ## Core Model
32
+
33
+ An HTTP handler is just an Effect:
34
+
35
+ ```
36
+ Effect<HttpServerResponse, E, HttpServerRequest | Scope | ...>
37
+ ```
38
+
39
+ The current request is a **service** (`HttpServerRequest.HttpServerRequest`) in the handler's context, and each request runs in its own `Scope` that closes after the response is sent. The v4 `HttpRouter` is also a **service**: routes and middleware register themselves against it from Layers, and `HttpRouter.serve(appLayer)` builds the router, wraps it with logging/tracing, and runs it on the `HttpServer` provided by a platform layer. There is no immutable `HttpRouter.empty.pipe(HttpRouter.get(...))` value-style router in v4.
40
+
41
+ ```ts
42
+ import { Effect, Layer, Schema, Stream } from 'effect';
43
+ import {
44
+ Cookies,
45
+ Headers,
46
+ HttpBody,
47
+ HttpEffect,
48
+ HttpMiddleware,
49
+ HttpRouter,
50
+ HttpServer,
51
+ HttpServerError,
52
+ HttpServerRequest,
53
+ HttpServerRespondable,
54
+ HttpServerResponse,
55
+ HttpStatus,
56
+ HttpStaticServer,
57
+ Multipart
58
+ } from 'effect/unstable/http';
59
+ import { NodeHttpServer, NodeRuntime } from '@effect/platform-node';
60
+ import { createServer } from 'node:http';
61
+ ```
62
+
63
+ Minimal server:
64
+
65
+ ```ts
66
+ const HelloRoute = HttpRouter.add(
67
+ 'GET',
68
+ '/hello',
69
+ Effect.succeed(HttpServerResponse.text('Hello, World!'))
70
+ );
71
+
72
+ const Main = HttpRouter.serve(HelloRoute).pipe(
73
+ Layer.provide(NodeHttpServer.layer(createServer, { port: 3000 }))
74
+ );
75
+
76
+ Layer.launch(Main).pipe(NodeRuntime.runMain);
77
+ ```
78
+
79
+ ---
80
+
81
+ ## 1. Registering Routes
82
+
83
+ ### `HttpRouter.add` — one route as a Layer
84
+
85
+ ```ts
86
+ HttpRouter.add(method, path, handler, options?): Layer<never, never, HttpRouter | ...>
87
+ ```
88
+
89
+ - `method`: `'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'OPTIONS' | '*'` (`'*'` matches all methods; `HEAD` requests automatically fall back to the matching `GET` route with the body stripped)
90
+ - `path`: `PathInput` — must start with `/`, or be `*`. `:name` captures a path param; a trailing `/*` is a wildcard (and also matches the bare prefix: `'/files/*'` matches `/files` too)
91
+ - `options`: `{ uninterruptible?: boolean }` — handlers are interruptible by default (client disconnect interrupts the fiber); set `true` for must-complete handlers
92
+
93
+ The handler can take three forms:
94
+
95
+ ```ts
96
+ // 1. A static HttpServerResponse value
97
+ HttpRouter.add('GET', '/ping', HttpServerResponse.text('pong'));
98
+
99
+ // 2. An Effect producing a response
100
+ HttpRouter.add('GET', '/me', Effect.gen(function* () {
101
+ const request = yield* HttpServerRequest.HttpServerRequest;
102
+ return HttpServerResponse.text(request.headers['user-agent'] ?? 'unknown');
103
+ }));
104
+
105
+ // 3. A function from the request
106
+ HttpRouter.add('GET', '/echo', (request) =>
107
+ Effect.succeed(HttpServerResponse.text(request.url))
108
+ );
109
+ ```
110
+
111
+ ### `HttpRouter.route` + `HttpRouter.addAll` — many routes at once
112
+
113
+ ```ts
114
+ const Routes = HttpRouter.addAll([
115
+ HttpRouter.route('GET', '/home', HttpServerResponse.html('<html />')),
116
+ HttpRouter.route('GET', '/health', HttpServerResponse.text('ok'))
117
+ ], { prefix: '/api' }); // optional mount prefix
118
+ ```
119
+
120
+ ### `HttpRouter.use` — imperative access to the router service
121
+
122
+ For grouped/prefixed registration in one place:
123
+
124
+ ```ts
125
+ const TodoRoutes = HttpRouter.use(Effect.fnUntraced(function* (router_) {
126
+ const router = router_.prefixed('/todos');
127
+ yield* router.add('GET', '/:id', Effect.flatMap(
128
+ HttpRouter.schemaParams(IdParams),
129
+ ({ id }) => todoResponse({ id, title: 'test' })
130
+ ));
131
+ yield* router.addAll([
132
+ HttpRouter.route('GET', '/', Effect.succeed(HttpServerResponse.text('root')))
133
+ ]);
134
+ }));
135
+ ```
136
+
137
+ `router.prefixed(prefix)` returns a prefixed view; when a prefixed route matches, the prefix is **stripped from `request.url`** seen by the handler (`request.originalUrl` keeps the full path). `HttpRouter.prefixPath` / `prefixRoute` are the underlying helpers.
138
+
139
+ ### Mounting a sub-app (v3 `mountApp`)
140
+
141
+ Wildcard method + wildcard path under a prefix is the v4 replacement for v3's `HttpRouter.mountApp` — works for any HTTP effect, including `HttpEffect.fromWebHandler` adapters:
142
+
143
+ ```ts
144
+ const Mounted = HttpRouter.use((router) => router.prefixed('/child').add('*', '*', childHttpEffect));
145
+ // the sub-app sees prefix-stripped request.url: /child/1 → '/1', /child?foo=bar → '?foo=bar'
146
+ ```
147
+
148
+ ### Path parameters
149
+
150
+ ```ts
151
+ // Raw access: Effect<ReadonlyRecord<string, string | undefined>, never, RouteContext>
152
+ const params = yield* HttpRouter.params;
153
+
154
+ // Schema-decoded (preferred)
155
+ const IdParams = Schema.Struct({ id: Schema.FiniteFromString });
156
+ const { id } = yield* HttpRouter.schemaParams(IdParams); // path params + search params merged; path wins
157
+ const { id: pathOnly } = yield* HttpRouter.schemaPathParams(IdParams); // path params only
158
+ ```
159
+
160
+ ### Router configuration
161
+
162
+ The matcher is `find-my-way-ts`. Configure via the `RouterConfig` reference or the `routerConfig` option of `serve`/`toWebHandler`:
163
+
164
+ ```ts
165
+ Layer.succeed(HttpRouter.RouterConfig)({
166
+ ignoreTrailingSlash: true,
167
+ ignoreDuplicateSlashes: true,
168
+ caseSensitive: false,
169
+ maxParamLength: 100
170
+ });
171
+ ```
172
+
173
+ ---
174
+
175
+ ## 2. Reading the Request
176
+
177
+ `HttpServerRequest.HttpServerRequest` is the service for the in-flight request:
178
+
179
+ ```ts
180
+ const request = yield* HttpServerRequest.HttpServerRequest;
181
+
182
+ request.method; // 'GET' | 'POST' | ... (uppercased)
183
+ request.url; // path (+search), prefix-stripped if route was prefixed
184
+ request.originalUrl; // as received
185
+ request.headers; // Headers — a lowercase-keyed string record
186
+ request.cookies; // ReadonlyRecord<string, string> (parsed, cached)
187
+ request.remoteAddress; // Option<string>
188
+ HttpServerRequest.toURL(request); // Option<URL> — absolute URL from request.url + the host header; use for absolute redirects
189
+
190
+ // Body accessors — each is cached, so reading twice is safe:
191
+ const text = yield* request.text; // Effect<string, HttpServerError>
192
+ const json = yield* request.json; // Effect<Schema.Json, HttpServerError>
193
+ const params = yield* request.urlParamsBody; // Effect<UrlParams, HttpServerError>
194
+ const buffer = yield* request.arrayBuffer; // Effect<ArrayBuffer, HttpServerError>
195
+ const byteStream = request.stream; // Stream<Uint8Array, HttpServerError> (single consumption)
196
+ ```
197
+
198
+ Cap accepted body sizes with the `MaxBodySize` reference (re-exported from `HttpIncomingMessage`, default `undefined` = unlimited):
199
+
200
+ ```ts
201
+ import { FileSystem } from 'effect';
202
+
203
+ someEffect.pipe(Effect.provideService(HttpServerRequest.MaxBodySize, FileSystem.Size(1024 * 1024)));
204
+ ```
205
+
206
+ ### Schema-validated decoding
207
+
208
+ From `HttpServerRequest` (no route context needed):
209
+
210
+ ```ts
211
+ // JSON body
212
+ const body = yield* HttpServerRequest.schemaBodyJson(CreateTodo);
213
+ // Effect<A, HttpServerError | Schema.SchemaError, HttpServerRequest | RD>
214
+
215
+ // application/x-www-form-urlencoded body
216
+ const form = yield* HttpServerRequest.schemaBodyUrlParams(Schema.Struct({
217
+ id: Schema.FiniteFromString,
218
+ title: Schema.String
219
+ }));
220
+
221
+ // Works for BOTH multipart and url-encoded form posts
222
+ const data = yield* HttpServerRequest.schemaBodyForm(UploadSchema);
223
+
224
+ // Headers / cookies / search params
225
+ const auth = yield* HttpServerRequest.schemaHeaders(Schema.Struct({ authorization: Schema.String }));
226
+ const session = yield* HttpServerRequest.schemaCookies(Schema.Struct({ sid: Schema.String }));
227
+ const query = yield* HttpServerRequest.schemaSearchParams(Schema.Struct({ q: Schema.String }));
228
+
229
+ // A JSON value embedded in one form field (multipart or url-encoded)
230
+ const payload = yield* HttpServerRequest.schemaBodyFormJson(Schema.Struct({
231
+ test: Schema.String
232
+ }))('json'); // note: curried — (schema)(fieldName)
233
+ ```
234
+
235
+ From `HttpRouter` (uses route context — only inside matched routes):
236
+
237
+ ```ts
238
+ // Whole-request decode WITHOUT body: { method, url, headers, cookies, pathParams, searchParams }
239
+ const meta = yield* HttpRouter.schemaNoBody(MySchema);
240
+
241
+ // Whole-request decode WITH parsed JSON body added as `body`
242
+ const all = yield* HttpRouter.schemaJson(MySchema);
243
+ ```
244
+
245
+ `HttpServerRequest.schemaBodyJson`, `schemaBodyFormJson`, and `HttpRouter.schemaJson` accept schema parse options plus `{ reviver }`. The reviver is passed to `JSON.parse`; the supplied schema then validates the revived value.
246
+
247
+ ```ts
248
+ const body = yield* HttpServerRequest.schemaBodyJson(CreateTodo, {
249
+ reviver: (key, value) => key === 'title' ? 'revived title' : value
250
+ });
251
+ ```
252
+
253
+ Search params are parsed by the router and provided as the `HttpServerRequest.ParsedSearchParams` service (repeated keys become arrays). Outside the router, `HttpMiddleware.searchParamsParser` provides it — but it parses `new URL(request.originalUrl)`, which must be absolute. That holds for web-handler adapters (`HttpServerRequest.fromWeb`); on the Node adapter `originalUrl` is path-only and the middleware defects (500). On a raw Node app parse yourself: `HttpServerRequest.searchParamsFromURL(new URL(request.url, 'http://localhost'))`.
254
+
255
+ ---
256
+
257
+ ## 3. Multipart Uploads
258
+
259
+ ```ts
260
+ const UploadRoute = HttpRouter.add('POST', '/upload', Effect.gen(function* () {
261
+ const request = yield* HttpServerRequest.HttpServerRequest;
262
+ // Persists file parts to temp files on disk.
263
+ // Requires Scope | FileSystem | Path (the request scope cleans the files up).
264
+ const persisted = yield* request.multipart;
265
+ const files = persisted.file; // ReadonlyArray<PersistedFile> | ReadonlyArray<string> | string
266
+ return yield* HttpServerResponse.json({ ok: 'file' in persisted });
267
+ }));
268
+ ```
269
+
270
+ Schema-validated variant with the multipart file schemas:
271
+
272
+ ```ts
273
+ const Upload = Schema.Struct({
274
+ file: Multipart.FilesSchema, // ReadonlyArray<PersistedFile>
275
+ // Multipart.SingleFileSchema — exactly one PersistedFile
276
+ // Multipart.PersistedFileSchema — the raw file schema
277
+ note: Schema.String // plain fields decode as strings
278
+ });
279
+ const { file, note } = yield* HttpServerRequest.schemaBodyMultipart(Upload);
280
+ // file[0].path / .name / .contentType / .key — read contents via FileSystem
281
+ ```
282
+
283
+ Streaming without persisting to disk: `request.multipartStream` is a `Stream<Multipart.Part, MultipartError>` where `Part = Field | File` (`file.content` is a byte stream, `file.contentEffect` collects it).
284
+
285
+ Limits are `Context.Reference`s, not options:
286
+
287
+ ```ts
288
+ serveLayerOrEffect.pipe(
289
+ Effect.provideService(Multipart.MaxFileSize, 10 * 1024 * 1024), // default: undefined (unlimited)
290
+ Effect.provideService(Multipart.MaxFieldSize, 1024 * 1024), // default: 10 MiB
291
+ Effect.provideService(Multipart.MaxParts, 20) // default: undefined
292
+ );
293
+ // Or build a Context with several at once: Multipart.limitsServices({ maxFileSize, maxTotalSize, ... })
294
+ ```
295
+
296
+ Limit violations surface as `MultipartError` with `error.reason._tag` of `'FileTooLarge' | 'FieldTooLarge' | 'TooManyParts' | 'BodyTooLarge' | 'Parse' | 'InternalError'` — catch and map to `413`:
297
+
298
+ ```ts
299
+ handler.pipe(
300
+ Effect.catchTag('MultipartError', (error) =>
301
+ error.reason._tag === 'FileTooLarge'
302
+ ? Effect.succeed(HttpServerResponse.empty({ status: 413 }))
303
+ : Effect.fail(error))
304
+ );
305
+ ```
306
+
307
+ The Effect v4 parser stops consuming input as soon as a part-count, part-size, or field-size limit is exceeded. Active file-part streams are terminated with the multipart failure when a limit is exceeded or the body ends unexpectedly, so consumers fail promptly instead of hanging. Keep consuming or supervising every exposed file stream so that failure is observed.
308
+
309
+ ---
310
+
311
+ ## 4. Building Responses
312
+
313
+ All constructors take an options object with `{ status?, statusText?, headers?, cookies?, contentType?, contentLength? }` (availability varies — body-typed constructors omit what the body determines).
314
+
315
+ ```ts
316
+ HttpServerResponse.text('hi'); // 200, text/plain
317
+ HttpServerResponse.text('hi', {
318
+ status: HttpStatus.fromLiteral('Created'),
319
+ headers: { 'x-request-id': 'abc' }
320
+ });
321
+ HttpServerResponse.empty(); // 204 — note the default is NOT 200
322
+ HttpServerResponse.empty({ status: HttpStatus.fromLiteral('NotFound') });
323
+ HttpServerResponse.redirect('/login'); // 302 + location header
324
+ HttpServerResponse.redirect(url, { status: HttpStatus.fromLiteral('MovedPermanently') });
325
+ HttpServerResponse.uint8Array(bytes, { contentType: 'application/octet-stream' });
326
+ HttpServerResponse.urlParams({ a: '1' }); // application/x-www-form-urlencoded
327
+ HttpServerResponse.formData(formData); // multipart response
328
+ HttpServerResponse.fromWeb(webResponse); // Web Response → response (body becomes a byte stream) — portable
329
+ HttpServerResponse.raw(webResponseOrReadableStream); // pass-through — web-handler adapters and Bun only; defects on the Node server adapter
330
+ ```
331
+
332
+ ### JSON
333
+
334
+ ```ts
335
+ // Effectful — JSON.stringify failures become HttpBodyError
336
+ const res = yield* HttpServerResponse.json({ ok: true });
337
+ // Effect<HttpServerResponse, HttpBodyError>
338
+
339
+ // Synchronous — throws on unserializable values
340
+ HttpServerResponse.jsonUnsafe({ ok: true }, { status: 400 });
341
+
342
+ // Schema-encoded — curried: build the encoder once, reuse per request
343
+ const todoResponse = HttpServerResponse.schemaJson(Todo);
344
+ yield* todoResponse({ id: 1, title: 'buy milk' }, { status: 201 });
345
+ // Effect<HttpServerResponse, HttpBodyError, RE>
346
+ ```
347
+
348
+ ### HTML
349
+
350
+ ```ts
351
+ HttpServerResponse.html('<html />'); // string form: plain HttpServerResponse
352
+ HttpServerResponse.html`<h1>${Effect.succeed('hi')}</h1>`; // template form: Effect (interpolations can be Effects)
353
+ HttpServerResponse.htmlStream`<ul>${Stream.make('<li>a</li>', '<li>b</li>')}</ul>`; // streaming template: Effect of a stream response
354
+ ```
355
+
356
+ ### Streaming
357
+
358
+ `stream` takes a byte stream — encode text first:
359
+
360
+ ```ts
361
+ HttpServerResponse.stream(
362
+ Stream.make('data: hello\n\n', 'data: world\n\n').pipe(Stream.encodeText),
363
+ { contentType: 'text/event-stream' }
364
+ );
365
+ ```
366
+
367
+ The request scope is kept open until a streaming body finishes (see section 10), so scoped resources used by the stream stay alive while it is being sent.
368
+
369
+ ### Files
370
+
371
+ ```ts
372
+ // From the file system — requires HttpPlatform (provided by NodeHttpServer.layer / BunHttpServer.layer).
373
+ // Sets content-type, content-length, etag, last-modified.
374
+ const res = yield* HttpServerResponse.file('./report.pdf', {
375
+ offset: 0,
376
+ bytesToRead: 1024, // optional byte range
377
+ headers: { 'cache-control': 'public, max-age=3600' }
378
+ });
379
+ // Effect<HttpServerResponse, PlatformError, HttpPlatform>
380
+
381
+ // From a Web File-like value (needs name, lastModified, size, stream, type — a plain Blob does not qualify)
382
+ const res2 = yield* HttpServerResponse.fileWeb(file);
383
+ ```
384
+
385
+ ### Combinators (all return new responses)
386
+
387
+ ```ts
388
+ response.pipe(
389
+ HttpServerResponse.setStatus(418),
390
+ HttpServerResponse.setHeader('x-one', '1'),
391
+ HttpServerResponse.setHeaders({ 'x-two': '2', 'x-three': '3' }),
392
+ HttpServerResponse.setBody(HttpBody.text('replaced'))
393
+ );
394
+ ```
395
+
396
+ ### Cookies
397
+
398
+ Cookie option keys: `domain`, `path`, `expires` (Date), `maxAge` (`Duration.Input`, e.g. `'5 minutes'`), `httpOnly`, `secure`, `sameSite` (`'lax' | 'strict' | 'none'`), `partitioned`, `priority`.
399
+
400
+ ```ts
401
+ // Unsafe variants are synchronous and throw on invalid cookies — fine for trusted values
402
+ HttpServerResponse.empty().pipe(
403
+ HttpServerResponse.setCookieUnsafe('session', token, {
404
+ httpOnly: true,
405
+ secure: true,
406
+ sameSite: 'lax',
407
+ path: '/',
408
+ maxAge: '30 days'
409
+ })
410
+ );
411
+
412
+ // Safe variants return Effects failing with CookiesError
413
+ const res = yield* HttpServerResponse.setCookie(response, 'session', token, { path: '/' });
414
+ const cleared = yield* HttpServerResponse.expireCookie(response, 'session', { path: '/' });
415
+ const multi = yield* HttpServerResponse.setCookies(response, [
416
+ ['a', '1'],
417
+ ['b', '2', { path: '/' }]
418
+ ]);
419
+ // Also: removeCookie, replaceCookies, mergeCookies, updateCookies, setCookiesUnsafe, expireCookieUnsafe
420
+ ```
421
+
422
+ Cookies live on `response.cookies` (a `Cookies.Cookies` collection) and are serialized to `set-cookie` headers only when the response is sent.
423
+
424
+ ---
425
+
426
+ ## 5. Error Handling
427
+
428
+ Route handlers may fail with **any** error type — you do not have to reduce `E` to `never`. At the server boundary (`HttpEffect.toHandled` → `HttpServerError.causeResponse`), an unhandled cause is converted to a response:
429
+
430
+ | Cause | Response |
431
+ |---|---|
432
+ | Error implementing `HttpServerRespondable` | whatever its protocol method returns |
433
+ | `Schema.SchemaError` | empty `400` |
434
+ | `Cause.NoSuchElementError` | empty `404` |
435
+ | `HttpServerError` with `RequestParseError` | empty `400` |
436
+ | `HttpServerError` with `RouteNotFound` (no route matched) | empty `404` |
437
+ | `HttpServerError` with `InternalError` / `ResponseError` | empty `500` |
438
+ | A defect that **is** an `HttpServerResponse` | that response, verbatim |
439
+ | Interrupt annotated `ClientAbort` (client disconnected) | `499` |
440
+ | Other interrupt (server shutdown) | `503` |
441
+ | Anything else (failure or defect) | empty `500`, cause reported to the error reporter |
442
+
443
+ ### Domain errors that know their own response
444
+
445
+ Implement `HttpServerRespondable.symbol` on the error class; throwing/failing with it anywhere in the handler produces the right response:
446
+
447
+ ```ts
448
+ class UserNotFound extends Schema.Error<UserNotFound>('UserNotFound')({
449
+ _tag: Schema.tag('UserNotFound'),
450
+ id: Schema.String
451
+ }) {
452
+ [HttpServerRespondable.symbol]() {
453
+ return HttpServerResponse.schemaJson(UserNotFound)(this, { status: 404 });
454
+ }
455
+ }
456
+
457
+ // Error classes are Effects in v4, so this is a valid route handler that always 404s:
458
+ HttpRouter.add('GET', '/missing', new UserNotFound({ id: 'x' }));
459
+ ```
460
+
461
+ ### Explicit mapping
462
+
463
+ ```ts
464
+ const GetUser = HttpRouter.add('GET', '/users/:id', Effect.gen(function* () {
465
+ const { id } = yield* HttpRouter.schemaPathParams(Schema.Struct({ id: Schema.String }));
466
+ const user = yield* Users.findById(id);
467
+ return yield* HttpServerResponse.json(user);
468
+ }).pipe(
469
+ Effect.catchTag('UserNotFound', (e) =>
470
+ Effect.succeed(HttpServerResponse.jsonUnsafe({ error: 'not_found', id: e.id }, { status: 404 })))
471
+ ));
472
+ ```
473
+
474
+ Typed route errors you leave unhandled appear as `HttpRouter.Request<'Error', E>` markers in the layer requirements; an error-handling middleware (section 6) can discharge them, otherwise `HttpRouter.serve` drops the markers and the table above applies at runtime.
475
+
476
+ `HttpServerError` is a single tagged error (`_tag: 'HttpServerError'`) wrapping a `reason` union — match on `error.reason._tag` (`'RequestParseError' | 'RouteNotFound' | 'InternalError' | 'ResponseError'`). `ServeError` is the separate startup failure of the platform layer (e.g. port in use).
477
+
478
+ ---
479
+
480
+ ## 6. Middleware
481
+
482
+ Two distinct mechanisms — pick the right one:
483
+
484
+ 1. **Router middleware** (`HttpRouter.middleware`) — wraps route handlers *before* the response is sent. Can modify responses, provide services, and handle typed route errors.
485
+ 2. **Server middleware** (the `middleware` option of `HttpRouter.serve`/`toWebHandler`, or `HttpServer.serve(effect, middleware)`) — wraps the entire chain *including response sending*. Response modifications here are **not** reflected in what the client receives. Use it only for observation (logging, metrics).
486
+
487
+ ### Built-ins (`HttpMiddleware`)
488
+
489
+ - `HttpMiddleware.logger` — logs each sent response with `http.method`/`http.url`/`http.status` annotations. **Added automatically** by `HttpRouter.serve` and `HttpRouter.toWebHandler` unless `{ disableLogger: true }`. Disable per route with `Layer.provide(HttpRouter.disableLogger)` or per effect with `HttpMiddleware.withLoggerDisabled`.
490
+ - `HttpMiddleware.tracer` — creates a `kind: 'server'` span per request (default name `http.server ${method}`; the router adds an `http.route` attribute). **Always applied** inside the server pipeline — never add it yourself. Configure via the references `HttpMiddleware.SpanNameGenerator` and `HttpMiddleware.TracerDisabledWhen` (or `HttpMiddleware.layerTracerDisabledForUrls(['/health'])`). Request/response headers recorded as span attributes are redacted per the `Headers.CurrentRedactedNames` reference (defaults: `authorization`, `cookie`, `set-cookie`, `x-api-key`; entries are strings or RegExps) — extend it with `Layer.succeed(Headers.CurrentRedactedNames)([...])`, and use `Headers.redact(request.headers, names)` when logging headers yourself.
491
+ - `HttpMiddleware.cors(options)` — handles OPTIONS preflight and appends CORS headers via a pre-response handler. Options: `{ allowedOrigins?: ReadonlyArray<string> | Predicate<string>, allowedMethods?, allowedHeaders?, exposedHeaders?, maxAge?, credentials? }`. Shortcut: `HttpRouter.cors(options)` is a ready-made global layer (merge it with your routes) — but it only accepts the `ReadonlyArray<string>` form of `allowedOrigins`; for a predicate wrap the middleware yourself: `HttpRouter.middleware(HttpMiddleware.cors({ allowedOrigins: pred }), { global: true })`.
492
+ - `HttpMiddleware.xForwardedHeaders` — trusts `x-forwarded-host`/`x-forwarded-for`, rewriting `host` and `remoteAddress`.
493
+ - `HttpMiddleware.searchParamsParser` — provides `ParsedSearchParams` outside the router. Requires an absolute `request.originalUrl` (web-handler adapters only — it defects on the Node adapter's path-only URLs; see section 2).
494
+
495
+ ### Route-scoped middleware with `HttpRouter.middleware`
496
+
497
+ A middleware is a function `(httpEffect) => httpEffect`. Type-parameterize what it `provides` (services injected into routes) and `handles` (typed route errors it absorbs):
498
+
499
+ ```ts
500
+ import { Context } from 'effect';
501
+
502
+ class CurrentSession extends Context.Service<CurrentSession, {
503
+ readonly token: string;
504
+ }>()('CurrentSession') {}
505
+
506
+ // Two-step call when configuring provides/handles: middleware<Config>()(fnOrEffect, options?)
507
+ const SessionMiddleware = HttpRouter.middleware<{ provides: CurrentSession }>()(
508
+ Effect.gen(function* () {
509
+ yield* Effect.log('SessionMiddleware initialized'); // runs once, at layer build
510
+ return (httpEffect) =>
511
+ Effect.flatMap(HttpServerRequest.HttpServerRequest, (request) =>
512
+ Effect.provideService(httpEffect, CurrentSession, {
513
+ token: request.headers.authorization ?? 'anonymous'
514
+ }));
515
+ })
516
+ );
517
+
518
+ // Error-handling middleware: may ONLY catch the errors listed in `handles`.
519
+ // Use the data-last (pipe) form — data-first Effect.catchTag drops Types.unhandled
520
+ // from the inferred error channel and trips the
521
+ // 'You must only handle the configured errors' guard.
522
+ const NotFoundHandler = HttpRouter.middleware<{ handles: UserNotFound }>()((httpEffect) =>
523
+ httpEffect.pipe(
524
+ Effect.catchTag('UserNotFound', (e) =>
525
+ Effect.succeed(HttpServerResponse.jsonUnsafe({ error: 'not_found', id: e.id }, { status: 404 })))
526
+ )
527
+ );
528
+
529
+ // Apply by providing `.layer` to the route layers it should affect:
530
+ const Routes = HttpRouter.add('GET', '/me', Effect.gen(function* () {
531
+ const session = yield* CurrentSession;
532
+ return HttpServerResponse.text(session.token);
533
+ })).pipe(
534
+ Layer.provide([SessionMiddleware.layer, NotFoundHandler.layer])
535
+ );
536
+ ```
537
+
538
+ Middleware can declare `requires` (services another middleware must provide); satisfy them with `combine` — in `a.combine(b)`, `b` runs outside `a` and its `provides` feed `a`:
539
+
540
+ ```ts
541
+ const Composed = NeedsSessionMiddleware.combine(SessionMiddleware);
542
+ Routes.pipe(Layer.provide(Composed.layer));
543
+ ```
544
+
545
+ ### Global middleware
546
+
547
+ Pass `{ global: true }` to get a Layer that applies to **all** routes (it requires `HttpRouter`, so merge it into the app layer passed to `serve`). Global middleware runs inside the response pipeline, so it *can* modify responses. The first-registered global middleware is outermost.
548
+
549
+ ```ts
550
+ const Timing = HttpRouter.middleware((httpEffect) =>
551
+ Effect.gen(function* () {
552
+ const start = yield* Effect.clockWith((clock) => clock.currentTimeMillis);
553
+ const response = yield* httpEffect;
554
+ const ms = (yield* Effect.clockWith((clock) => clock.currentTimeMillis)) - start;
555
+ return HttpServerResponse.setHeader(response, 'x-response-time', `${ms}ms`);
556
+ }), { global: true });
557
+
558
+ const AppRoutes = Layer.mergeAll(Routes, Timing, HttpRouter.cors());
559
+ ```
560
+
561
+ ### Providing plain services per request: `HttpRouter.provideRequest`
562
+
563
+ When routes just need services (no request/response wrapping), skip the middleware ceremony:
564
+
565
+ ```ts
566
+ const Routes = UserRoutes.pipe(HttpRouter.provideRequest(Database.layer));
567
+ // Database.layer is built ONCE; its services are provided to each request of these routes
568
+ ```
569
+
570
+ ### Pre-response handlers
571
+
572
+ Run a transform just before the response is sent (this is how `cors` appends headers):
573
+
574
+ ```ts
575
+ yield* HttpEffect.appendPreResponseHandler((request, response) =>
576
+ Effect.succeed(HttpServerResponse.setHeader(response, 'server', 'effect')));
577
+ // also: HttpEffect.withPreResponseHandler(effect, handler)
578
+ ```
579
+
580
+ ---
581
+
582
+ ## 7. Serving
583
+
584
+ ### Node
585
+
586
+ ```ts
587
+ const Main = HttpRouter.serve(AppRoutes, {
588
+ disableLogger: false, // default: logger middleware is added
589
+ disableListenLog: false, // default: logs `Listening on http://0.0.0.0:3000`
590
+ routerConfig: { ignoreTrailingSlash: true }
591
+ // middleware: (effect) => ... — observation-only; response changes are NOT sent (see section 6)
592
+ }).pipe(
593
+ Layer.provide(NodeHttpServer.layer(createServer, {
594
+ port: 3000,
595
+ // any net.ListenOptions key (host, path for unix sockets, ...) plus:
596
+ gracefulShutdownTimeout: '10 seconds', // Duration.Input; default 20 seconds
597
+ disablePreemptiveShutdown: false // true = wait indefinitely for in-flight requests
598
+ }))
599
+ );
600
+
601
+ Layer.launch(Main).pipe(NodeRuntime.runMain);
602
+ ```
603
+
604
+ `Layer.launch` keeps the layer alive forever; `NodeRuntime.runMain` wires `SIGINT`/`SIGTERM` to fiber interruption, which closes the layer scope — the Node adapter then stops accepting connections and closes the server, bounded by `gracefulShutdownTimeout`.
605
+
606
+ Variants: `NodeHttpServer.layerServer` (server only, no platform services), `layerHttpServices` (HttpPlatform + Etag + Node services only), `layerConfig(createServer, configWrappedOptions)` (read options from `Config`), `layerTest` (section 10). `makeHandler`/`makeUpgradeHandler` return raw Node `request`/`upgrade` listeners for mounting the app on a server you manage yourself. For TLS pass `() => https.createServer(tlsOptions)` as the server factory.
607
+
608
+ ### Bun
609
+
610
+ ```ts
611
+ import { BunHttpServer, BunRuntime } from '@effect/platform-bun';
612
+
613
+ const Main = HttpRouter.serve(AppRoutes).pipe(
614
+ Layer.provide(BunHttpServer.layer({ port: 3000 })) // Bun.serve options + the same shutdown options
615
+ );
616
+ Layer.launch(Main).pipe(BunRuntime.runMain);
617
+ ```
618
+
619
+ ### The HttpServer service
620
+
621
+ ```ts
622
+ const server = yield* HttpServer.HttpServer;
623
+ server.address; // { _tag: 'TcpAddress', hostname, port } | { _tag: 'UnixAddress', path }
624
+ HttpServer.formatAddress(server.address); // 'http://0.0.0.0:3000'
625
+ yield* HttpServer.logAddress; // log it
626
+ someServerLayer.pipe(HttpServer.withLogAddress); // log on startup
627
+ ```
628
+
629
+ Lower-level serving without the router — give `HttpServer.serve`/`serveEffect` any `Effect<HttpServerResponse, E, R | HttpServerRequest>`:
630
+
631
+ ```ts
632
+ // Layer form (dual: also usable as serve() / serve(middleware) in pipes)
633
+ const App = HttpServer.serve(Effect.succeed(HttpServerResponse.text('ok')));
634
+ // Effect form, runs in the current scope:
635
+ yield* HttpServer.serveEffect(myHttpEffect);
636
+ ```
637
+
638
+ ### Escape hatch: the router as a plain effect
639
+
640
+ `HttpRouter.toHttpEffect(appLayer)` builds the app layer and returns the router as an `Effect<HttpServerResponse, ..., HttpServerRequest | Scope>` — wrap it with outer middleware that *may* modify responses, serve it with `HttpServer.serveEffect`, or hand it to `HttpEffect.toWebHandlerWith`:
641
+
642
+ ```ts
643
+ const httpEffect = yield* HttpRouter.toHttpEffect(AppRoutes);
644
+ yield* HttpServer.serveEffect(httpEffect);
645
+ ```
646
+
647
+ (Inside `HttpRouter.use`, the service offers the same surface imperatively: `router.asHttpEffect()` and `router.addGlobalMiddleware`.)
648
+
649
+ ---
650
+
651
+ ## 8. WebSocket Upgrades
652
+
653
+ `request.upgrade` yields a `Socket` (from `effect/unstable/socket`) once the connection is upgraded. Both `NodeHttpServer` and `BunHttpServer` handle the platform `upgrade` events for you — just write a normal route:
654
+
655
+ ```ts
656
+ const WsRoute = HttpRouter.add('GET', '/ws', Effect.gen(function* () {
657
+ const request = yield* HttpServerRequest.HttpServerRequest;
658
+ const socket = yield* request.upgrade; // Effect<Socket.Socket, HttpServerError>
659
+ const write = yield* socket.writer; // scoped — the request Scope keeps it alive
660
+
661
+ // runs until the client disconnects; the handler receives each message
662
+ yield* socket.runString((message) => write(`echo: ${message}`));
663
+
664
+ return HttpServerResponse.empty(); // sent when the socket session ends
665
+ }));
666
+ ```
667
+
668
+ - `socket.run(handler)` for binary (`Uint8Array`) messages, `runString` for text, `runRaw` for both.
669
+ - `HttpServerRequest.upgradeChannel()` exposes the socket as a `Channel` for pipeline-style use.
670
+ - `request.upgrade` fails with `HttpServerError` (`RequestParseError` reason) when the request is not upgradeable — e.g. in plain web-handler adapters that lack upgrade support.
671
+ - For socket combinators, close events, and client sockets see the `effect-socket` skill.
672
+
673
+ ---
674
+
675
+ ## 9. Static Files (`HttpStaticServer`)
676
+
677
+ ```ts
678
+ const StaticFiles = HttpStaticServer.layer({
679
+ root: './public',
680
+ prefix: '/static', // optional mount prefix (registers GET <prefix>/*)
681
+ index: 'index.html', // default 'index.html'; pass `index: undefined` to disable directory index
682
+ spa: true, // serve the index for extensionless paths whose Accept includes text/html
683
+ cacheControl: 'public, max-age=3600',
684
+ mimeTypes: { custom: 'application/x-custom' } // merged over the built-in table
685
+ });
686
+ // Layer<never, PlatformError, HttpRouter | FileSystem | Path | HttpPlatform>
687
+
688
+ const AppRoutes = Layer.mergeAll(ApiRoutes, StaticFiles);
689
+ ```
690
+
691
+ It serves files with correct MIME types, `accept-ranges: bytes` + single-range requests (`206`/`416`), conditional requests (`if-none-match`/`if-modified-since` → `304`), and is path-traversal safe. Misses fail with `RouteNotFound` (→ `404`). `HttpStaticServer.make(options)` returns the bare handler effect if you want to mount it yourself.
692
+
693
+ ---
694
+
695
+ ## 10. Web Handlers, Request Scope, and Testing
696
+
697
+ ### Fetch-style handlers (serverless, in-memory testing)
698
+
699
+ ```ts
700
+ // Single HTTP effect → (Request) => Promise<Response> — no router, no port
701
+ const handler = HttpEffect.toWebHandler(
702
+ Effect.succeed(HttpServerResponse.text('ok'))
703
+ );
704
+ const response = await handler(new Request('http://localhost/'));
705
+
706
+ // With a base context / with a Layer for services.
707
+ // toWebHandlerWith declares R on the OUTER call with default `never` (it is not
708
+ // inferred from the effect) — supply it explicitly for handlers that use
709
+ // HttpServerRequest/Scope:
710
+ const handler2 = HttpEffect.toWebHandlerWith<never, HttpServerRequest.HttpServerRequest | Scope.Scope>(
711
+ MyRef.context(420)
712
+ )(httpApp);
713
+ const { handler: handler3, dispose } = HttpEffect.toWebHandlerLayer(httpApp, ServicesLayer);
714
+
715
+ // Whole router app → handler (this is the serverless entrypoint)
716
+ const { handler: appHandler, dispose: disposeApp } = HttpRouter.toWebHandler(
717
+ AppRoutes.pipe(Layer.provide(HttpServer.layerServices)),
718
+ { disableLogger: true } // also: middleware, memoMap, routerConfig
719
+ );
720
+ ```
721
+
722
+ - A second argument passes per-request context: `handler(request, Env.context({ foo: 'baz' }))`.
723
+ - `HttpServer.layerServices` provides `HttpPlatform` + `Path` + weak `Etag` + a **noop FileSystem** — enough for routers that never touch disk. For real `HttpServerResponse.file`/multipart persistence in a web handler, provide `NodeHttpServer.layerHttpServices` (or the platform FileSystem/Path layers) instead.
724
+ - `HttpEffect.fromWebHandler(handler)` adapts an existing `(Request) => Promise<Response>` into an HTTP effect.
725
+
726
+ ### Request scope semantics
727
+
728
+ Every request runs in a fresh `Scope` closed after the response is sent — `Effect.addFinalizer` in a handler runs post-response. Streaming bodies transfer the scope to the stream (`HttpEffect.scopeTransferToStream`, applied automatically by the adapters), so it closes when the body finishes streaming. If the client disconnects mid-request, the handler fiber is interrupted with the `ClientAbort` annotation (logged as `499`).
729
+
730
+ ### Integration tests on an ephemeral port
731
+
732
+ `NodeHttpServer.layerTest` starts a real server on port 0 and provides an `HttpClient` whose requests are rewritten to it:
733
+
734
+ ```ts
735
+ import { NodeHttpServer } from '@effect/platform-node';
736
+ import { describe, expect, it } from '@effect/vitest';
737
+ import { HttpClient, HttpClientResponse } from 'effect/unstable/http';
738
+
739
+ describe('todos', () => {
740
+ it.effect('GET /todos/:id', () =>
741
+ Effect.gen(function* () {
742
+ yield* HttpRouter.add(
743
+ 'GET',
744
+ '/todos/:id',
745
+ Effect.flatMap(HttpRouter.schemaParams(IdParams), ({ id }) =>
746
+ todoResponse({ id, title: 'test' }))
747
+ ).pipe(HttpRouter.serve, Layer.build); // build into the test scope
748
+
749
+ const todo = yield* HttpClient.get('/todos/1').pipe(
750
+ Effect.flatMap(HttpClientResponse.schemaBodyJson(Todo))
751
+ );
752
+ expect(todo).toEqual({ id: 1, title: 'test' });
753
+ }).pipe(Effect.provide(NodeHttpServer.layerTest)));
754
+ });
755
+ ```
756
+
757
+ (`BunHttpServer.layerTest` is the Bun equivalent. The pieces behind it: `HttpServer.makeTestClient` / `layerTestClient` build a client targeting the current server's address.)
758
+
759
+ ---
760
+
761
+ ## Key Patterns
762
+
763
+ ### Full JSON API with middleware, domain errors, static files
764
+
765
+ ```ts
766
+ import { Context, Effect, Layer, Schema } from 'effect';
767
+ import {
768
+ HttpRouter,
769
+ HttpServerRequest,
770
+ HttpServerRespondable,
771
+ HttpServerResponse,
772
+ HttpStaticServer
773
+ } from 'effect/unstable/http';
774
+ import { NodeHttpServer, NodeRuntime } from '@effect/platform-node';
775
+ import { createServer } from 'node:http';
776
+
777
+ class Todo extends Schema.Class<Todo>('Todo')({
778
+ id: Schema.Number,
779
+ title: Schema.String
780
+ }) {}
781
+
782
+ class TodoNotFound extends Schema.Error<TodoNotFound>('TodoNotFound')({
783
+ _tag: Schema.tag('TodoNotFound'),
784
+ id: Schema.Number
785
+ }) {
786
+ [HttpServerRespondable.symbol]() {
787
+ return HttpServerResponse.schemaJson(TodoNotFound)(this, { status: 404 });
788
+ }
789
+ }
790
+
791
+ class Todos extends Context.Service<Todos, {
792
+ readonly find: (id: number) => Effect.Effect<Todo, TodoNotFound>;
793
+ readonly create: (title: string) => Effect.Effect<Todo>;
794
+ }>()('app/Todos') {
795
+ static readonly layer = Layer.sync(Todos)(() => {
796
+ const todos = new Map<number, Todo>();
797
+ let nextId = 1;
798
+ return {
799
+ find: (id) =>
800
+ todos.has(id)
801
+ ? Effect.succeed(todos.get(id)!)
802
+ : Effect.fail(new TodoNotFound({ id })),
803
+ create: (title) =>
804
+ Effect.sync(() => {
805
+ const todo = new Todo({ id: nextId++, title });
806
+ todos.set(todo.id, todo);
807
+ return todo;
808
+ })
809
+ };
810
+ });
811
+ }
812
+
813
+ const todoResponse = HttpServerResponse.schemaJson(Todo);
814
+ const IdParams = Schema.Struct({ id: Schema.FiniteFromString });
815
+ const CreateTodo = Schema.Struct({ title: Schema.String });
816
+
817
+ const TodoRoutes = HttpRouter.use(Effect.fnUntraced(function* (router_) {
818
+ const router = router_.prefixed('/todos');
819
+
820
+ yield* router.add('GET', '/:id', Effect.gen(function* () {
821
+ const { id } = yield* HttpRouter.schemaParams(IdParams);
822
+ const todos = yield* Todos;
823
+ const todo = yield* todos.find(id); // TodoNotFound renders itself as 404 JSON
824
+ return yield* todoResponse(todo);
825
+ }));
826
+
827
+ yield* router.add('POST', '/', Effect.gen(function* () {
828
+ const { title } = yield* HttpServerRequest.schemaBodyJson(CreateTodo); // Schema.SchemaError → 400
829
+ const todos = yield* Todos;
830
+ const todo = yield* todos.create(title);
831
+ return yield* todoResponse(todo, { status: 201 });
832
+ }));
833
+ })).pipe(
834
+ HttpRouter.provideRequest(Todos.layer) // request-level service provision
835
+ );
836
+
837
+ const AppRoutes = Layer.mergeAll(
838
+ TodoRoutes,
839
+ HttpRouter.cors({ allowedOrigins: ['https://example.com'], credentials: true }),
840
+ HttpStaticServer.layer({ root: './public', spa: true })
841
+ );
842
+
843
+ const Main = HttpRouter.serve(AppRoutes).pipe(
844
+ Layer.provide(NodeHttpServer.layer(createServer, { port: 3000 }))
845
+ );
846
+
847
+ Layer.launch(Main).pipe(NodeRuntime.runMain);
848
+ ```
849
+
850
+ ### File upload with limits and cleanup-free persistence
851
+
852
+ ```ts
853
+ import { FileSystem } from 'effect';
854
+
855
+ const UploadRoute = HttpRouter.add('POST', '/upload', Effect.gen(function* () {
856
+ const { file } = yield* HttpServerRequest.schemaBodyMultipart(Schema.Struct({
857
+ file: Multipart.SingleFileSchema
858
+ }));
859
+ const fs = yield* FileSystem.FileSystem;
860
+ const contents = yield* fs.readFileString(file.path); // temp file; removed when the request scope closes
861
+ return yield* HttpServerResponse.json({ name: file.name, bytes: contents.length });
862
+ }).pipe(
863
+ Effect.catchTag('MultipartError', () =>
864
+ Effect.succeed(HttpServerResponse.empty({ status: 413 })))
865
+ ));
866
+
867
+ const Main = HttpRouter.serve(UploadRoute).pipe(
868
+ Layer.provide(NodeHttpServer.layer(createServer, { port: 3000 })),
869
+ Layer.provide(Layer.succeed(Multipart.MaxFileSize)(5 * 1024 * 1024))
870
+ );
871
+ ```
872
+
873
+ ### Serverless entrypoint
874
+
875
+ ```ts
876
+ // e.g. Cloudflare Workers / Deno / a Next.js route handler
877
+ export const { handler, dispose } = HttpRouter.toWebHandler(
878
+ AppRoutes.pipe(Layer.provide(HttpServer.layerServices))
879
+ );
880
+ // export default { fetch: handler }
881
+ ```
882
+
883
+ ### Handler unit test without a port
884
+
885
+ ```ts
886
+ import { describe, expect, it } from '@effect/vitest';
887
+
888
+ describe('health', () => {
889
+ it('responds', async () => {
890
+ const handler = HttpEffect.toWebHandler(
891
+ Effect.succeed(HttpServerResponse.jsonUnsafe({ ok: true }))
892
+ );
893
+ const response = await handler(new Request('http://localhost/health'));
894
+ expect(response.status).toBe(200);
895
+ expect(await response.json()).toEqual({ ok: true });
896
+ });
897
+ });
898
+ ```
899
+
900
+ ---
901
+
902
+ ## Common Mistakes
903
+
904
+ 1. **Using the v3 value-style router** — `HttpRouter.empty.pipe(HttpRouter.get('/x', app))` no longer exists. The v4 router is a service: register routes with `HttpRouter.add(...)` Layers (or `router.add` inside `HttpRouter.use`) and run with `HttpRouter.serve(appLayer)`.
905
+ 2. **Importing from `@effect/platform`** — gone in v4. Everything is `effect/unstable/http`; only the platform adapters live in `@effect/platform-node` / `@effect/platform-bun`.
906
+ 3. **Treating `HttpServerResponse.json` as synchronous** — it returns `Effect<HttpServerResponse, HttpBodyError>`. `yield*` it, or use `jsonUnsafe` for known-serializable values. Same for `schemaJson` (which is also curried: `schemaJson(schema)(value, options)`).
907
+ 4. **Expecting `HttpServerResponse.empty()` to be 200** — the default status is `204`. Pass `{ status: 200 }` if you need it.
908
+ 5. **Modifying the response in `HttpRouter.serve`'s `middleware` option** — that middleware wraps the chain *after* response sending; changes are silently dropped. Use `HttpRouter.middleware(..., { global: true })` or route-scoped `HttpRouter.middleware` instead.
909
+ 6. **Adding `HttpMiddleware.tracer` or `HttpMiddleware.logger` manually to `HttpRouter.serve`** — the tracer is always applied by the pipeline and the logger is added by default. Configure with `SpanNameGenerator`/`TracerDisabledWhen` references and the `disableLogger` option / `HttpRouter.disableLogger` layer.
910
+ 7. **Passing multipart limits as options** — `Multipart.MaxFileSize`, `MaxFieldSize` (default 10 MiB), `MaxParts`, and `HttpServerRequest.MaxBodySize` are `Context.Reference`s. Set them with `Layer.succeed(Multipart.MaxFileSize)(n)` or `Effect.provideService`.
911
+ 8. **Calling `request.multipart` without `FileSystem`/`Path`** — it persists parts to temp files and requires `Scope | FileSystem | Path`. The Node/Bun server layers provide them; `HttpServer.layerServices` only has a **noop** FileSystem (fine for routing tests, broken for real uploads and `HttpServerResponse.file`).
912
+ 9. **Using `HttpRouter.params`/`schemaParams`/`schemaPathParams` outside a matched route** — they need `RouteContext`, which only the router provides. In plain `HttpEffect` handlers read `request.url` yourself; `HttpMiddleware.searchParamsParser` provides `ParsedSearchParams` but only where `originalUrl` is absolute (web-handler adapters) — on Node use `HttpServerRequest.searchParamsFromURL(new URL(request.url, 'http://localhost'))`.
913
+ 10. **Forgetting cookie setters are effectful** — `setCookie`/`setCookies`/`expireCookie` return `Effect<_, CookiesError>`; the synchronous variants are `setCookieUnsafe`/`setCookiesUnsafe`/`expireCookieUnsafe` (and they throw on invalid input).
914
+ 11. **Hand-rolling 404/500 mapping for schema and missing-value errors** — `Schema.SchemaError` already becomes `400` and `NoSuchElementError` becomes `404` at the boundary; implement `HttpServerRespondable.symbol` on domain errors instead of try/catch pyramids.
915
+ 12. **Assuming handlers run to completion** — routes are interruptible; a client disconnect interrupts the fiber (`499`). Pass `{ uninterruptible: true }` to `HttpRouter.add` for side effects that must finish, and remember a plain server-shutdown interrupt maps to `503`.
916
+ 13. **Route paths without a leading `/`** — `PathInput` is `` `/${string}` `` or `'*'`; `'users/:id'` is a type error. Wildcards are a trailing `/*` (which also matches the bare prefix); `'*'` as the whole path matches everything.
917
+ 14. **Expecting `request.url` to keep the mount prefix** — prefixed routes (`router.prefixed`, `addAll({ prefix })`, `HttpStaticServer.layer({ prefix })`) strip the prefix from `request.url`; use `request.originalUrl` for the full path.
918
+ 15. **Reading the body twice via `request.stream`** — `text`/`json`/`arrayBuffer`/`urlParamsBody`/`multipart` are cached and re-readable, but `stream` consumes the raw body once. Pick one style per request.
919
+ 16. **Returning a Web `Response` directly** — wrap it. `HttpServerResponse.fromWeb(webResponse)` is the portable default (converts the body to a byte stream). `HttpServerResponse.raw(webResponse)` is a fast path for web-handler adapters and Bun only (there the Effect response's headers are merged into the Web `Response`); the Node server adapter passes non-Node-stream raw bodies straight to `nodeResponse.end()`, so a Web `Response`/`ReadableStream` is a runtime defect (500).
920
+ 17. **`Effect.die(response)` confusion** — a defect that is an `HttpServerResponse` is intentionally used as the response (an escape-hatch early return supported by `causeResponse`); do not be surprised when you see it in causes, and prefer plain success returns in your own code.