effect-start 0.17.2 → 0.19.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 (80) hide show
  1. package/dist/Development.d.ts +7 -2
  2. package/dist/Development.js +12 -6
  3. package/dist/PlatformRuntime.d.ts +4 -0
  4. package/dist/PlatformRuntime.js +9 -0
  5. package/dist/Route.d.ts +6 -2
  6. package/dist/Route.js +22 -0
  7. package/dist/RouteHttp.d.ts +1 -1
  8. package/dist/RouteHttp.js +12 -19
  9. package/dist/RouteMount.d.ts +2 -1
  10. package/dist/Start.d.ts +1 -5
  11. package/dist/Start.js +1 -8
  12. package/dist/Unique.d.ts +50 -0
  13. package/dist/Unique.js +187 -0
  14. package/dist/bun/BunHttpServer.js +5 -6
  15. package/dist/bun/BunRoute.d.ts +1 -1
  16. package/dist/bun/BunRoute.js +2 -2
  17. package/dist/index.d.ts +1 -0
  18. package/dist/index.js +1 -0
  19. package/dist/node/Effectify.d.ts +209 -0
  20. package/dist/node/Effectify.js +19 -0
  21. package/dist/node/FileSystem.d.ts +3 -5
  22. package/dist/node/FileSystem.js +42 -62
  23. package/dist/node/PlatformError.d.ts +46 -0
  24. package/dist/node/PlatformError.js +43 -0
  25. package/dist/testing/TestLogger.js +1 -1
  26. package/package.json +10 -5
  27. package/src/Development.ts +13 -18
  28. package/src/PlatformRuntime.ts +11 -0
  29. package/src/Route.ts +31 -2
  30. package/src/RouteHttp.ts +15 -31
  31. package/src/RouteMount.ts +1 -1
  32. package/src/Start.ts +1 -15
  33. package/src/Unique.ts +232 -0
  34. package/src/bun/BunHttpServer.ts +6 -9
  35. package/src/bun/BunRoute.ts +3 -3
  36. package/src/index.ts +1 -0
  37. package/src/node/Effectify.ts +262 -0
  38. package/src/node/FileSystem.ts +59 -97
  39. package/src/node/PlatformError.ts +102 -0
  40. package/src/testing/TestLogger.ts +1 -1
  41. package/dist/Random.d.ts +0 -5
  42. package/dist/Random.js +0 -49
  43. package/src/Commander.test.ts +0 -1639
  44. package/src/ContentNegotiation.test.ts +0 -603
  45. package/src/Development.test.ts +0 -119
  46. package/src/Entity.test.ts +0 -592
  47. package/src/FileRouterPattern.test.ts +0 -147
  48. package/src/FileRouter_files.test.ts +0 -64
  49. package/src/FileRouter_path.test.ts +0 -145
  50. package/src/FileRouter_tree.test.ts +0 -132
  51. package/src/Http.test.ts +0 -319
  52. package/src/HttpAppExtra.test.ts +0 -103
  53. package/src/HttpUtils.test.ts +0 -85
  54. package/src/PathPattern.test.ts +0 -648
  55. package/src/Random.ts +0 -59
  56. package/src/RouteBody.test.ts +0 -232
  57. package/src/RouteHook.test.ts +0 -40
  58. package/src/RouteHttp.test.ts +0 -2909
  59. package/src/RouteMount.test.ts +0 -481
  60. package/src/RouteSchema.test.ts +0 -427
  61. package/src/RouteSse.test.ts +0 -249
  62. package/src/RouteTree.test.ts +0 -494
  63. package/src/RouteTrie.test.ts +0 -322
  64. package/src/RouterPattern.test.ts +0 -676
  65. package/src/Values.test.ts +0 -263
  66. package/src/bun/BunBundle.test.ts +0 -268
  67. package/src/bun/BunBundle_imports.test.ts +0 -48
  68. package/src/bun/BunHttpServer.test.ts +0 -251
  69. package/src/bun/BunImportTrackerPlugin.test.ts +0 -77
  70. package/src/bun/BunRoute.test.ts +0 -162
  71. package/src/bundler/BundleHttp.test.ts +0 -132
  72. package/src/effect/HttpRouter.test.ts +0 -548
  73. package/src/experimental/EncryptedCookies.test.ts +0 -488
  74. package/src/hyper/HyperHtml.test.ts +0 -209
  75. package/src/hyper/HyperRoute.test.tsx +0 -197
  76. package/src/middlewares/BasicAuthMiddleware.test.ts +0 -84
  77. package/src/testing/TestHttpClient.test.ts +0 -83
  78. package/src/testing/TestLogger.test.ts +0 -51
  79. package/src/x/datastar/Datastar.test.ts +0 -266
  80. package/src/x/tailwind/TailwindPlugin.test.ts +0 -333
@@ -1,2909 +0,0 @@
1
- import * as test from "bun:test"
2
- import * as Effect from "effect/Effect"
3
- import * as Option from "effect/Option"
4
- import * as Ref from "effect/Ref"
5
- import * as Schedule from "effect/Schedule"
6
- import * as Schema from "effect/Schema"
7
- import * as Stream from "effect/Stream"
8
- import * as Tracer from "effect/Tracer"
9
- import * as Entity from "./Entity.ts"
10
- import * as Http from "./Http.ts"
11
- import * as Route from "./Route.ts"
12
- import * as RouteHttp from "./RouteHttp.ts"
13
- import * as RouteSchema from "./RouteSchema.ts"
14
- import * as RouteTree from "./RouteTree.ts"
15
- import * as TestLogger from "./testing/TestLogger.ts"
16
-
17
- test.it("converts string to text/plain for Route.text", async () => {
18
- const handler = RouteHttp.toWebHandler(
19
- Route.get(
20
- Route.text("Hello World"),
21
- ),
22
- )
23
- const response = await Http.fetch(handler, { path: "/text" })
24
-
25
- test
26
- .expect(response.status)
27
- .toBe(200)
28
- test
29
- .expect(response.headers.get("Content-Type"))
30
- .toBe("text/plain; charset=utf-8")
31
- test
32
- .expect(await response.text())
33
- .toBe("Hello World")
34
- })
35
-
36
- test.it("converts string to text/html for Route.html", async () => {
37
- const handler = RouteHttp.toWebHandler(
38
- Route.get(Route.html("<h1>Hello</h1>")),
39
- )
40
- const response = await Http.fetch(handler, { path: "/html" })
41
-
42
- test
43
- .expect(response.status)
44
- .toBe(200)
45
- test
46
- .expect(response.headers.get("Content-Type"))
47
- .toBe("text/html; charset=utf-8")
48
- test
49
- .expect(await response.text())
50
- .toBe("<h1>Hello</h1>")
51
- })
52
-
53
- test.it("converts object to JSON for Route.json", async () => {
54
- const handler = RouteHttp.toWebHandler(
55
- Route.get(
56
- Route.json({ message: "hello", count: 42 }),
57
- ),
58
- )
59
- const response = await Http.fetch(handler, { path: "/json" })
60
-
61
- test
62
- .expect(response.status)
63
- .toBe(200)
64
- test
65
- .expect(response.headers.get("Content-Type"))
66
- .toBe("application/json")
67
- test
68
- .expect(await response.json())
69
- .toEqual({ message: "hello", count: 42 })
70
- })
71
-
72
- test.it("converts array to JSON for Route.json", async () => {
73
- const handler = RouteHttp.toWebHandler(
74
- Route.get(
75
- Route.json([1, 2, 3]),
76
- ),
77
- )
78
- const response = await Http.fetch(handler, { path: "/array" })
79
-
80
- test
81
- .expect(response.status)
82
- .toBe(200)
83
- test
84
- .expect(response.headers.get("Content-Type"))
85
- .toBe("application/json")
86
- test
87
- .expect(await response.json())
88
- .toEqual([1, 2, 3])
89
- })
90
-
91
- test.it("handles method-specific routes", async () => {
92
- const handler = RouteHttp.toWebHandler(
93
- Route
94
- .get(Route.text("get resource"))
95
- .post(Route.text("post resource")),
96
- )
97
-
98
- const getResponse = await Http.fetch(handler, {
99
- path: "/resource",
100
- method: "GET",
101
- })
102
- test
103
- .expect(await getResponse.text())
104
- .toBe("get resource")
105
-
106
- const postResponse = await Http.fetch(handler, {
107
- path: "/resource",
108
- method: "POST",
109
- })
110
- test
111
- .expect(await postResponse.text())
112
- .toBe("post resource")
113
- })
114
-
115
- test.it("handles errors by returning 500 response", () =>
116
- Effect
117
- .gen(function*() {
118
- const runtime = yield* Effect.runtime<TestLogger.TestLogger>()
119
- const handler = RouteHttp.toWebHandlerRuntime(runtime)(
120
- Route.get(
121
- Route.text(function*(): Generator<any, string, any> {
122
- return yield* Effect.fail(new Error("Something went wrong"))
123
- }),
124
- ),
125
- )
126
- const response = yield* Effect.promise(() =>
127
- Http.fetch(handler, { path: "/error" })
128
- )
129
-
130
- test
131
- .expect(response.status)
132
- .toBe(500)
133
-
134
- const text = yield* Effect.promise(() => response.text())
135
- test
136
- .expect(text)
137
- .toContain("Something went wrong")
138
-
139
- const messages = yield* TestLogger.messages
140
- test
141
- .expect(messages.some((m) => m.includes("Something went wrong")))
142
- .toBe(true)
143
- })
144
- .pipe(Effect.provide(TestLogger.layer()), Effect.runPromise))
145
-
146
- test.it("handles defects by returning 500 response", () =>
147
- Effect
148
- .gen(function*() {
149
- const runtime = yield* Effect.runtime<TestLogger.TestLogger>()
150
- const handler = RouteHttp.toWebHandlerRuntime(runtime)(
151
- Route.get(
152
- Route.text(function*() {
153
- return yield* Effect.die("Unexpected error")
154
-
155
- return "Hello"
156
- }),
157
- ),
158
- )
159
- const response = yield* Effect.promise(() =>
160
- Http.fetch(handler, { path: "/defect" })
161
- )
162
-
163
- test
164
- .expect(response.status)
165
- .toBe(500)
166
-
167
- const messages = yield* TestLogger.messages
168
- test
169
- .expect(messages.some((m) => m.includes("Unexpected error")))
170
- .toBe(true)
171
- })
172
- .pipe(Effect.provide(TestLogger.layer()), Effect.runPromise))
173
-
174
- test.it("includes descriptor properties in handler context", async () => {
175
- let capturedMethod: string | undefined
176
-
177
- const handler = RouteHttp.toWebHandler(
178
- Route.get(
179
- Route.text(function*(ctx) {
180
- capturedMethod = ctx.method
181
- return "ok"
182
- }),
183
- ),
184
- )
185
- await Http.fetch(handler, { path: "/test" })
186
-
187
- test
188
- .expect(capturedMethod)
189
- .toBe("GET")
190
- })
191
-
192
- test.it("includes request in handler context", async () => {
193
- let capturedRequest: Request | undefined
194
-
195
- const handler = RouteHttp.toWebHandler(
196
- Route.get(
197
- Route.text(function*(ctx) {
198
- test
199
- .expectTypeOf(ctx.request)
200
- .toEqualTypeOf<Request>()
201
- capturedRequest = ctx.request
202
- return "ok"
203
- }),
204
- ),
205
- )
206
- await Http.fetch(handler, {
207
- path: "/test",
208
- headers: { "x-custom": "value" },
209
- })
210
-
211
- test
212
- .expect(capturedRequest)
213
- .toBeInstanceOf(Request)
214
- test
215
- .expect(capturedRequest?.headers.get("x-custom"))
216
- .toBe("value")
217
- })
218
-
219
- test.it("returns 405 for wrong method", async () => {
220
- const handler = RouteHttp.toWebHandler(
221
- Route.get(Route.text("users")),
222
- )
223
- const response = await Http.fetch(handler, {
224
- path: "/users",
225
- method: "POST",
226
- })
227
-
228
- test
229
- .expect(response.status)
230
- .toBe(405)
231
- })
232
-
233
- test.it("supports POST method", async () => {
234
- const handler = RouteHttp.toWebHandler(
235
- Route.post(Route.text("created")),
236
- )
237
- const response = await Http.fetch(handler, {
238
- path: "/users",
239
- method: "POST",
240
- })
241
-
242
- test
243
- .expect(response.status)
244
- .toBe(200)
245
- test
246
- .expect(await response.text())
247
- .toBe("created")
248
- })
249
-
250
- test.it("selects json when Accept prefers application/json", async () => {
251
- const handler = RouteHttp.toWebHandler(
252
- Route
253
- .get(Route.json({ type: "json" }))
254
- .get(Route.html("<div>html</div>")),
255
- )
256
- const response = await Http.fetch(handler, {
257
- path: "/data",
258
- headers: { Accept: "application/json" },
259
- })
260
-
261
- test
262
- .expect(response.headers.get("Content-Type"))
263
- .toBe("application/json")
264
- test
265
- .expect(await response.json())
266
- .toEqual({ type: "json" })
267
- })
268
-
269
- test.it("selects html when Accept prefers text/html", async () => {
270
- const handler = RouteHttp.toWebHandler(
271
- Route
272
- .get(Route.json({ type: "json" }))
273
- .get(Route.html("<div>html</div>")),
274
- )
275
- const response = await Http.fetch(handler, {
276
- path: "/data",
277
- headers: { Accept: "text/html" },
278
- })
279
-
280
- test
281
- .expect(response.headers.get("Content-Type"))
282
- .toBe("text/html; charset=utf-8")
283
- test
284
- .expect(await response.text())
285
- .toBe("<div>html</div>")
286
- })
287
-
288
- test.it("selects text/plain when Accept prefers it", async () => {
289
- const handler = RouteHttp.toWebHandler(
290
- Route
291
- .get(Route.text("plain text"))
292
- .get(Route.json({ type: "json" })),
293
- )
294
- const response = await Http.fetch(handler, {
295
- path: "/data",
296
- headers: { Accept: "text/plain" },
297
- })
298
-
299
- test
300
- .expect(response.headers.get("Content-Type"))
301
- .toBe("text/plain; charset=utf-8")
302
- test
303
- .expect(await response.text())
304
- .toBe("plain text")
305
- })
306
-
307
- test.it("returns first candidate when no Accept header", async () => {
308
- const handler = RouteHttp.toWebHandler(
309
- Route
310
- .get(Route.json({ type: "json" }))
311
- .get(Route.html("<div>html</div>")),
312
- )
313
- const response = await Http.fetch(handler, { path: "/data" })
314
-
315
- test
316
- .expect(response.headers.get("Content-Type"))
317
- .toBe("application/json")
318
- })
319
-
320
- test.it("handles Accept with quality values", async () => {
321
- const handler = RouteHttp.toWebHandler(
322
- Route
323
- .get(Route.json({ type: "json" }))
324
- .get(Route.html("<div>html</div>")),
325
- )
326
- const response = await Http.fetch(handler, {
327
- path: "/data",
328
- headers: { Accept: "text/html;q=0.9, application/json;q=1.0" },
329
- })
330
-
331
- test
332
- .expect(response.headers.get("Content-Type"))
333
- .toBe("application/json")
334
- })
335
-
336
- test.it("handles Accept: */*", async () => {
337
- const handler = RouteHttp.toWebHandler(
338
- Route
339
- .get(Route.json({ type: "json" }))
340
- .get(Route.html("<div>html</div>")),
341
- )
342
- const response = await Http.fetch(handler, {
343
- path: "/data",
344
- headers: { Accept: "*/*" },
345
- })
346
-
347
- test
348
- .expect(response.headers.get("Content-Type"))
349
- .toBe("application/json")
350
- })
351
-
352
- test.it("returns 406 when Accept doesn't match available formats", async () => {
353
- const handler = RouteHttp.toWebHandler(
354
- Route.get(Route.json({ type: "json" })),
355
- )
356
- const response = await Http.fetch(handler, {
357
- path: "/data",
358
- headers: { Accept: "text/html" },
359
- })
360
-
361
- test
362
- .expect(response.status)
363
- .toBe(406)
364
- test
365
- .expect(await response.json())
366
- .toEqual({ status: 406, message: "not acceptable" })
367
- })
368
-
369
- test.it("returns 406 when Accept doesn't match any of multiple formats", async () => {
370
- const handler = RouteHttp.toWebHandler(
371
- Route
372
- .get(Route.json({ type: "json" }))
373
- .get(Route.html("<div>html</div>")),
374
- )
375
- const response = await Http.fetch(handler, {
376
- path: "/data",
377
- headers: { Accept: "image/png" },
378
- })
379
-
380
- test
381
- .expect(response.status)
382
- .toBe(406)
383
- })
384
-
385
- test.it("definition order determines priority when no Accept header", async () => {
386
- const handler = RouteHttp.toWebHandler(
387
- Route
388
- .get(Route.text("plain"))
389
- .get(Route.html("<div>html</div>")),
390
- )
391
- const response = await Http.fetch(handler, { path: "/data" })
392
-
393
- test
394
- .expect(response.headers.get("Content-Type"))
395
- .toBe("text/plain; charset=utf-8")
396
- })
397
-
398
- test.it("falls back to html when no Accept header and no json or text", async () => {
399
- const handler = RouteHttp.toWebHandler(
400
- Route.get(Route.html("<div>html</div>")),
401
- )
402
- const response = await Http.fetch(handler, { path: "/data" })
403
-
404
- test
405
- .expect(response.headers.get("Content-Type"))
406
- .toBe("text/html; charset=utf-8")
407
- })
408
-
409
- test.it("Route.text matches any text/* Accept header", async () => {
410
- const handler = RouteHttp.toWebHandler(
411
- Route.get(
412
- Route.text(function*() {
413
- return Entity.make("event: message\ndata: hello\n\n", {
414
- headers: { "content-type": "text/event-stream" },
415
- })
416
- }),
417
- ),
418
- )
419
-
420
- const response = await Http.fetch(handler, {
421
- path: "/events",
422
- headers: { Accept: "text/event-stream" },
423
- })
424
-
425
- test
426
- .expect(response.status)
427
- .toBe(200)
428
- test
429
- .expect(response.headers.get("Content-Type"))
430
- .toBe("text/event-stream")
431
- test
432
- .expect(await response.text())
433
- .toBe("event: message\ndata: hello\n\n")
434
- })
435
-
436
- test.it("Route.text matches text/markdown Accept header", async () => {
437
- const handler = RouteHttp.toWebHandler(
438
- Route.get(
439
- Route.text(function*() {
440
- return Entity.make("# Hello", {
441
- headers: { "content-type": "text/markdown" },
442
- })
443
- }),
444
- ),
445
- )
446
-
447
- const response = await Http.fetch(handler, {
448
- path: "/doc",
449
- headers: { Accept: "text/markdown" },
450
- })
451
-
452
- test
453
- .expect(response.status)
454
- .toBe(200)
455
- test
456
- .expect(response.headers.get("Content-Type"))
457
- .toBe("text/markdown")
458
- })
459
-
460
- test.describe("walkHandles", () => {
461
- test.it("yields handlers for static routes", () => {
462
- const tree = RouteTree.make({
463
- "/users": Route.get(Route.text("users list")),
464
- "/admin": Route.get(Route.text("admin")),
465
- })
466
-
467
- const handles = Object.fromEntries(RouteHttp.walkHandles(tree))
468
-
469
- test
470
- .expect("/users" in handles)
471
- .toBe(true)
472
- test
473
- .expect("/admin" in handles)
474
- .toBe(true)
475
- })
476
-
477
- test.it("yields handlers for parameterized routes", () => {
478
- const tree = RouteTree.make({
479
- "/users/:id": Route.get(Route.text("user detail")),
480
- })
481
-
482
- const handles = Object.fromEntries(RouteHttp.walkHandles(tree))
483
-
484
- test
485
- .expect("/users/:id" in handles)
486
- .toBe(true)
487
- })
488
-
489
- test.it("preserves optional param syntax", () => {
490
- const tree = RouteTree.make({
491
- "/files/:name?": Route.get(Route.text("files")),
492
- })
493
-
494
- const handles = Object.fromEntries(RouteHttp.walkHandles(tree))
495
-
496
- test
497
- .expect("/files/:name?" in handles)
498
- .toBe(true)
499
- })
500
-
501
- test.it("preserves wildcard param syntax", () => {
502
- const tree = RouteTree.make({
503
- "/docs/:path*": Route.get(Route.text("docs")),
504
- })
505
-
506
- const handles = Object.fromEntries(RouteHttp.walkHandles(tree))
507
-
508
- test
509
- .expect("/docs/:path*" in handles)
510
- .toBe(true)
511
- })
512
- })
513
-
514
- test.describe("middleware chain", () => {
515
- test.it("passes enriched context to handler", async () => {
516
- const handler = RouteHttp.toWebHandler(
517
- Route
518
- .use(Route.filter({ context: { answer: 42 } }))
519
- .get(Route.text(function*(ctx) {
520
- return `The answer is ${ctx.answer}`
521
- })),
522
- )
523
- const response = await Http.fetch(handler, { path: "/test" })
524
-
525
- test
526
- .expect(response.status)
527
- .toBe(200)
528
- test
529
- .expect(await response.text())
530
- .toBe("The answer is 42")
531
- })
532
-
533
- test.it("composes multiple middlewares with cumulative context", async () => {
534
- const handler = RouteHttp.toWebHandler(
535
- Route
536
- .use(Route.filter({ context: { a: 1 } }))
537
- .use(Route.filter({ context: { b: 2 } }))
538
- .get(Route.text(function*(ctx) {
539
- return `a=${ctx.a},b=${ctx.b}`
540
- })),
541
- )
542
- const response = await Http.fetch(handler, { path: "/test" })
543
-
544
- test
545
- .expect(await response.text())
546
- .toBe("a=1,b=2")
547
- })
548
-
549
- test.it("later middleware can access earlier context", async () => {
550
- const handler = RouteHttp.toWebHandler(
551
- Route
552
- .use(Route.filter({ context: { x: 10 } }))
553
- .use(Route.filter(function*(ctx) {
554
- return { context: { doubled: ctx.x * 2 } }
555
- }))
556
- .get(Route.text(function*(ctx) {
557
- return `doubled=${ctx.doubled}`
558
- })),
559
- )
560
- const response = await Http.fetch(handler, { path: "/test" })
561
-
562
- test
563
- .expect(await response.text())
564
- .toBe("doubled=20")
565
- })
566
-
567
- test.it("middleware error short-circuits chain", () =>
568
- Effect
569
- .gen(function*() {
570
- const runtime = yield* Effect.runtime<TestLogger.TestLogger>()
571
- const handler = RouteHttp.toWebHandlerRuntime(runtime)(
572
- Route
573
- .use(Route.filter(function*() {
574
- return yield* Effect.fail(new Error("middleware failed"))
575
- }))
576
- .get(Route.text("should not reach")),
577
- )
578
- const response = yield* Effect.promise(() =>
579
- Http.fetch(handler, { path: "/test" })
580
- )
581
-
582
- test
583
- .expect(response.status)
584
- .toBe(500)
585
- test
586
- .expect(yield* Effect.promise(() => response.text()))
587
- .toContain("middleware failed")
588
-
589
- const messages = yield* TestLogger.messages
590
- test
591
- .expect(messages.some((m) => m.includes("middleware failed")))
592
- .toBe(true)
593
- })
594
- .pipe(Effect.provide(TestLogger.layer()), Effect.runPromise))
595
-
596
- test.it("applies middleware to all methods", async () => {
597
- const handler = RouteHttp.toWebHandler(
598
- Route
599
- .use(Route.filter({ context: { shared: true } }))
600
- .get(Route.text(function*(ctx) {
601
- return `GET:${ctx.shared}`
602
- }))
603
- .post(Route.text(function*(ctx) {
604
- return `POST:${ctx.shared}`
605
- })),
606
- )
607
-
608
- const getResponse = await Http.fetch(handler, {
609
- path: "/test",
610
- method: "GET",
611
- })
612
- test
613
- .expect(await getResponse.text())
614
- .toBe("GET:true")
615
-
616
- const postResponse = await Http.fetch(handler, {
617
- path: "/test",
618
- method: "POST",
619
- })
620
- test
621
- .expect(await postResponse.text())
622
- .toBe("POST:true")
623
- })
624
-
625
- test.it("method-specific middleware enriches context for that method", async () => {
626
- const handler = RouteHttp.toWebHandler(
627
- Route.get(
628
- Route.filter({ context: { methodSpecific: true } }),
629
- Route.text(function*(ctx) {
630
- return `methodSpecific=${ctx.methodSpecific}`
631
- }),
632
- ),
633
- )
634
- const response = await Http.fetch(handler, { path: "/test" })
635
-
636
- test
637
- .expect(await response.text())
638
- .toBe("methodSpecific=true")
639
- })
640
-
641
- test.it("wildcard and method-specific middlewares compose in order", async () => {
642
- const handler = RouteHttp.toWebHandler(
643
- Route
644
- .use(Route.filter({ context: { a: 1 } }))
645
- .get(
646
- Route.filter({ context: { b: 2 } }),
647
- Route.text(function*(ctx) {
648
- return `a=${ctx.a},b=${ctx.b}`
649
- }),
650
- ),
651
- )
652
- const response = await Http.fetch(handler, { path: "/test" })
653
-
654
- test
655
- .expect(await response.text())
656
- .toBe("a=1,b=2")
657
- })
658
-
659
- test.it("method-specific middleware only affects its method", async () => {
660
- const handler = RouteHttp.toWebHandler(
661
- Route
662
- .get(
663
- Route.filter({ context: { getOnly: true } }),
664
- Route.text(function*(ctx) {
665
- return `GET:${ctx.getOnly}`
666
- }),
667
- )
668
- .post(Route.text(function*(ctx) {
669
- return `POST:${(ctx as any).getOnly}`
670
- })),
671
- )
672
-
673
- const getResponse = await Http.fetch(handler, {
674
- path: "/test",
675
- method: "GET",
676
- })
677
- test
678
- .expect(await getResponse.text())
679
- .toBe("GET:true")
680
-
681
- const postResponse = await Http.fetch(handler, {
682
- path: "/test",
683
- method: "POST",
684
- })
685
- test
686
- .expect(await postResponse.text())
687
- .toBe("POST:undefined")
688
- })
689
-
690
- test.it("json middleware wraps json response content", async () => {
691
- const handler = RouteHttp.toWebHandler(
692
- Route
693
- .use(
694
- Route.json(function*(_ctx, next) {
695
- const value = yield* next().json
696
- return { data: value }
697
- }),
698
- )
699
- .get(
700
- Route.json({ message: "hello", count: 42 }),
701
- ),
702
- )
703
- const response = await Http.fetch(handler, { path: "/test" })
704
-
705
- test
706
- .expect(response.status)
707
- .toBe(200)
708
- test
709
- .expect(response.headers.get("Content-Type"))
710
- .toBe("application/json")
711
- test
712
- .expect(await response.json())
713
- .toEqual({ data: { message: "hello", count: 42 } })
714
- })
715
-
716
- test.it("multiple json middlewares compose in order", async () => {
717
- const handler = RouteHttp.toWebHandler(
718
- Route
719
- .use(
720
- Route.json(function*(_ctx, next) {
721
- const value = yield* next().json
722
- return { outer: value }
723
- }),
724
- )
725
- .use(
726
- Route.json(function*(_ctx, next) {
727
- const value = yield* next().json
728
- return { inner: value }
729
- }),
730
- )
731
- .get(
732
- Route.json({ original: true }),
733
- ),
734
- )
735
- const response = await Http.fetch(handler, { path: "/test" })
736
-
737
- test
738
- .expect(await response.json())
739
- .toEqual({ outer: { inner: { original: true } } })
740
- })
741
-
742
- test.it("json middleware passes through non-json responses", async () => {
743
- const handler = RouteHttp.toWebHandler(
744
- Route
745
- .use(
746
- Route.json(function*(_ctx, next) {
747
- const value = yield* next().json
748
- return { wrapped: value }
749
- }),
750
- )
751
- .get(Route.json({ type: "json" }))
752
- .get(Route.text("plain text")),
753
- )
754
-
755
- const textResponse = await Http.fetch(handler, {
756
- path: "/test",
757
- headers: { Accept: "text/plain" },
758
- })
759
- test
760
- .expect(textResponse.headers.get("Content-Type"))
761
- .toBe("text/plain; charset=utf-8")
762
- test
763
- .expect(await textResponse.text())
764
- .toBe("plain text")
765
-
766
- const jsonResponse = await Http.fetch(handler, {
767
- path: "/test",
768
- headers: { Accept: "application/json" },
769
- })
770
- test
771
- .expect(await jsonResponse.json())
772
- .toEqual({ wrapped: { type: "json" } })
773
- })
774
-
775
- test.it("text middleware wraps text response content", async () => {
776
- const handler = RouteHttp.toWebHandler(
777
- Route
778
- .use(
779
- Route.text(function*(_ctx, next) {
780
- const value = yield* next().text
781
- return `wrapped: ${value}`
782
- }),
783
- )
784
- .get(Route.text("hello")),
785
- )
786
- const response = await Http.fetch(handler, { path: "/test" })
787
-
788
- test
789
- .expect(response.headers.get("Content-Type"))
790
- .toBe("text/plain; charset=utf-8")
791
- test
792
- .expect(await response.text())
793
- .toBe("wrapped: hello")
794
- })
795
-
796
- test.it("html middleware wraps html response content", async () => {
797
- const handler = RouteHttp.toWebHandler(
798
- Route
799
- .use(
800
- Route.html(function*(_ctx, next) {
801
- const value = yield* next().text
802
- return `<div>${value}</div>`
803
- }),
804
- )
805
- .get(Route.html("<span>content</span>")),
806
- )
807
- const response = await Http.fetch(handler, { path: "/test" })
808
-
809
- test
810
- .expect(response.headers.get("Content-Type"))
811
- .toBe("text/html; charset=utf-8")
812
- test
813
- .expect(await response.text())
814
- .toBe("<div><span>content</span></div>")
815
- })
816
-
817
- test.it("bytes middleware wraps bytes response content", async () => {
818
- const encoder = new TextEncoder()
819
- const decoder = new TextDecoder()
820
-
821
- const handler = RouteHttp.toWebHandler(
822
- Route
823
- .use(
824
- Route.bytes(function*(_ctx, next) {
825
- const value = yield* next().bytes
826
- const text = decoder.decode(value)
827
- return encoder.encode(`wrapped:${text}`)
828
- }),
829
- )
830
- .get(Route.bytes(encoder.encode("data"))),
831
- )
832
- const response = await Http.fetch(handler, { path: "/test" })
833
-
834
- test
835
- .expect(response.headers.get("Content-Type"))
836
- .toBe("application/octet-stream")
837
- test
838
- .expect(await response.text())
839
- .toBe("wrapped:data")
840
- })
841
-
842
- test.it("chains middlewares in order", async () => {
843
- const calls: string[] = []
844
-
845
- const handler = RouteHttp.toWebHandler(
846
- Route
847
- .use(
848
- // always called
849
- Route.filter({
850
- context: {
851
- name: "Johnny",
852
- },
853
- }),
854
- // called 1st
855
- // next is related handler with same format (here format="text" descriptor)
856
- Route.text(function*(_ctx, next) {
857
- calls.push("wildcard text 1")
858
- return "1st layout: " + (yield* next().text)
859
- }),
860
- // never called because it's unrelated (different format descriptor)
861
- Route.json(function*(_ctx, next) {
862
- calls.push("wildcard json")
863
- return { data: yield* next().json }
864
- }),
865
- // called 2nd
866
- // no other related handler in the same method,
867
- // continue traversing RouteHttp middleware chain
868
- Route.text(function*(_ctx, next) {
869
- calls.push("wildcard text 2")
870
- return "2nd layout: " + (yield* next().text)
871
- }),
872
- )
873
- .get(
874
- // never called because doesn't pass content negotiation check in RouteHttp middleware
875
- Route.json(function*(_ctx) {
876
- calls.push("method json")
877
- return { ok: true }
878
- }),
879
- // called 3rd
880
- Route.text(function*(_ctx, next) {
881
- calls.push("method text 1")
882
- return "Prefix: " + (yield* next().text)
883
- }),
884
- // called 4th - terminal, no next() call
885
- Route.text(function*(ctx) {
886
- calls.push("method text 2")
887
- return `Hello, ${ctx.name}`
888
- }),
889
- ),
890
- )
891
-
892
- const response = await Http.fetch(handler, {
893
- path: "/test",
894
- headers: { Accept: "text/plain" },
895
- })
896
-
897
- test
898
- .expect(calls)
899
- .toEqual([
900
- "wildcard text 1",
901
- "wildcard text 2",
902
- "method text 1",
903
- "method text 2",
904
- ])
905
-
906
- test
907
- .expect(response.status)
908
- .toBe(200)
909
- test
910
- .expect(response.headers.get("Content-Type"))
911
- .toBe("text/plain; charset=utf-8")
912
- test
913
- .expect(await response.text())
914
- .toBe("1st layout: 2nd layout: Prefix: Hello, Johnny")
915
- })
916
-
917
- test.it("schema headers parsing works with HttpServerRequest service", async () => {
918
- const handler = RouteHttp.toWebHandler(
919
- Route.get(
920
- RouteSchema.schemaHeaders(
921
- Schema.Struct({
922
- "x-test": Schema.String,
923
- }),
924
- ),
925
- Route.text(function*(ctx) {
926
- return `header=${ctx.headers["x-test"]}`
927
- }),
928
- ),
929
- )
930
- const response = await Http.fetch(handler, {
931
- path: "/test",
932
- headers: { "x-test": "test-value" },
933
- })
934
-
935
- test
936
- .expect(response.status)
937
- .toBe(200)
938
- test
939
- .expect(await response.text())
940
- .toBe("header=test-value")
941
- })
942
-
943
- test.it("merges headers", async () => {
944
- const handler = RouteHttp.toWebHandler(
945
- Route
946
- .use(RouteSchema.schemaHeaders(
947
- Schema.Struct({
948
- "x-shared": Schema.String,
949
- }),
950
- ))
951
- .get(
952
- RouteSchema.schemaHeaders(
953
- Schema.Struct({
954
- "x-get-only": Schema.String,
955
- }),
956
- ),
957
- Route.text(function*(ctx) {
958
- return `shared=${ctx.headers["x-shared"]},getOnly=${
959
- ctx.headers["x-get-only"]
960
- }`
961
- }),
962
- ),
963
- )
964
- const response = await Http.fetch(handler, {
965
- path: "/test",
966
- headers: {
967
- "x-shared": "shared-value",
968
- "x-get-only": "get-value",
969
- },
970
- })
971
-
972
- test
973
- .expect(response.status)
974
- .toBe(200)
975
- test
976
- .expect(await response.text())
977
- .toBe("shared=shared-value,getOnly=get-value")
978
- })
979
- })
980
-
981
- test.describe("toWebHandler type constraints", () => {
982
- test.it("accepts routes with method", () => {
983
- RouteHttp.toWebHandler(Route.get(Route.text("hello")))
984
- })
985
-
986
- test.it("accepts multiple routes with methods", () => {
987
- RouteHttp.toWebHandler(
988
- Route.get(Route.text("hello")).post(Route.text("world")),
989
- )
990
- })
991
-
992
- test.it("rejects routes without method", () => {
993
- const noMethod = Route.empty.pipe(Route.text("hello"))
994
- // @ts-expect-error
995
- RouteHttp.toWebHandler(noMethod)
996
- })
997
-
998
- test.it("rejects mixed routes where one has method and one doesn't", () => {
999
- const withMethod = Route.get(Route.text("hello"))
1000
- const withoutMethod = Route.empty.pipe(Route.text("hello"))
1001
- const mixed = [...withMethod, ...withoutMethod] as const
1002
- // @ts-expect-error
1003
- RouteHttp.toWebHandler(mixed)
1004
- })
1005
- })
1006
-
1007
- test.describe("streaming responses", () => {
1008
- test.it("streams text response", async () => {
1009
- const handler = RouteHttp.toWebHandler(
1010
- Route.get(
1011
- Route.text(function*() {
1012
- return Stream.make("Hello", " ", "World")
1013
- }),
1014
- ),
1015
- )
1016
- const response = await Http.fetch(handler, { path: "/stream" })
1017
-
1018
- test
1019
- .expect(response.headers.get("Content-Type"))
1020
- .toBe("text/plain; charset=utf-8")
1021
- test
1022
- .expect(await response.text())
1023
- .toBe("Hello World")
1024
- })
1025
-
1026
- test.it("streams html response", async () => {
1027
- const handler = RouteHttp.toWebHandler(
1028
- Route.get(
1029
- Route.html(function*() {
1030
- return Stream.make("<div>", "content", "</div>")
1031
- }),
1032
- ),
1033
- )
1034
- const response = await Http.fetch(handler, { path: "/stream" })
1035
-
1036
- test
1037
- .expect(response.headers.get("Content-Type"))
1038
- .toBe("text/html; charset=utf-8")
1039
- test
1040
- .expect(await response.text())
1041
- .toBe("<div>content</div>")
1042
- })
1043
-
1044
- test.it("streams bytes response", async () => {
1045
- const encoder = new TextEncoder()
1046
- const handler = RouteHttp.toWebHandler(
1047
- Route.get(
1048
- Route.bytes(function*() {
1049
- return Stream.make(
1050
- encoder.encode("chunk1"),
1051
- encoder.encode("chunk2"),
1052
- )
1053
- }),
1054
- ),
1055
- )
1056
- const response = await Http.fetch(handler, { path: "/stream" })
1057
-
1058
- test
1059
- .expect(response.headers.get("Content-Type"))
1060
- .toBe("application/octet-stream")
1061
- test
1062
- .expect(await response.text())
1063
- .toBe("chunk1chunk2")
1064
- })
1065
-
1066
- test.it("handles stream errors gracefully", async () => {
1067
- const handler = RouteHttp.toWebHandler(
1068
- Route.get(
1069
- Route.text(function*() {
1070
- return Stream.make("start").pipe(
1071
- Stream.concat(Stream.fail(new Error("stream error"))),
1072
- )
1073
- }),
1074
- ),
1075
- )
1076
- const response = await Http.fetch(handler, { path: "/error" })
1077
-
1078
- test
1079
- .expect(response.status)
1080
- .toBe(200)
1081
-
1082
- await test
1083
- .expect(response.text())
1084
- .rejects
1085
- .toThrow("stream error")
1086
- })
1087
- })
1088
-
1089
- test.describe("schema handlers", () => {
1090
- test.it("parses headers, cookies, and search params together", async () => {
1091
- const handler = RouteHttp.toWebHandler(
1092
- Route.get(
1093
- RouteSchema.schemaHeaders(
1094
- Schema.Struct({
1095
- "x-api-key": Schema.String,
1096
- }),
1097
- ),
1098
- RouteSchema.schemaCookies(
1099
- Schema.Struct({
1100
- session: Schema.String,
1101
- }),
1102
- ),
1103
- RouteSchema.schemaSearchParams(
1104
- Schema.Struct({
1105
- page: Schema.NumberFromString,
1106
- limit: Schema.optional(Schema.NumberFromString),
1107
- }),
1108
- ),
1109
- Route.json(function*(ctx) {
1110
- return {
1111
- apiKey: ctx.headers["x-api-key"],
1112
- session: ctx.cookies.session,
1113
- page: ctx.searchParams.page,
1114
- limit: ctx.searchParams.limit,
1115
- }
1116
- }),
1117
- ),
1118
- )
1119
-
1120
- const response = await Http.fetch(handler, {
1121
- path: "/test?page=2&limit=10",
1122
- headers: {
1123
- "x-api-key": "secret-key",
1124
- cookie: "session=abc123",
1125
- },
1126
- })
1127
-
1128
- test
1129
- .expect(response.status)
1130
- .toBe(200)
1131
- test
1132
- .expect(await response.json())
1133
- .toEqual({
1134
- apiKey: "secret-key",
1135
- session: "abc123",
1136
- page: 2,
1137
- limit: 10,
1138
- })
1139
- })
1140
-
1141
- test.it("parses JSON body with headers", async () => {
1142
- const handler = RouteHttp.toWebHandler(
1143
- Route.post(
1144
- RouteSchema.schemaHeaders(
1145
- Schema.Struct({
1146
- "content-type": Schema.String,
1147
- }),
1148
- ),
1149
- RouteSchema.schemaBodyJson(
1150
- Schema.Struct({
1151
- name: Schema.String,
1152
- age: Schema.Number,
1153
- }),
1154
- ),
1155
- Route.json(function*(ctx) {
1156
- return {
1157
- contentType: ctx.headers["content-type"],
1158
- name: ctx.body.name,
1159
- age: ctx.body.age,
1160
- }
1161
- }),
1162
- ),
1163
- )
1164
-
1165
- const response = await Http.fetch(handler, {
1166
- path: "/users",
1167
- method: "POST",
1168
- headers: { "Content-Type": "application/json" },
1169
- body: JSON.stringify({ name: "Alice", age: 30 }),
1170
- })
1171
-
1172
- test
1173
- .expect(response.status)
1174
- .toBe(200)
1175
- test
1176
- .expect(await response.json())
1177
- .toEqual({
1178
- contentType: "application/json",
1179
- name: "Alice",
1180
- age: 30,
1181
- })
1182
- })
1183
-
1184
- test.it("parses URL-encoded body", async () => {
1185
- const handler = RouteHttp.toWebHandler(
1186
- Route.post(
1187
- RouteSchema.schemaBodyUrlParams(
1188
- Schema.Struct({
1189
- username: Schema.String,
1190
- password: Schema.String,
1191
- }),
1192
- ),
1193
- Route.json(function*(ctx) {
1194
- return {
1195
- username: ctx.body.username,
1196
- hasPassword: ctx.body.password.length > 0,
1197
- }
1198
- }),
1199
- ),
1200
- )
1201
-
1202
- const response = await Http.fetch(handler, {
1203
- path: "/login",
1204
- method: "POST",
1205
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
1206
- body: "username=alice&password=secret",
1207
- })
1208
-
1209
- test
1210
- .expect(response.status)
1211
- .toBe(200)
1212
- test
1213
- .expect(await response.json())
1214
- .toEqual({
1215
- username: "alice",
1216
- hasPassword: true,
1217
- })
1218
- })
1219
-
1220
- test.it("returns 400 on schema validation failure", () =>
1221
- Effect
1222
- .gen(function*() {
1223
- const runtime = yield* Effect.runtime<TestLogger.TestLogger>()
1224
- const handler = RouteHttp.toWebHandlerRuntime(runtime)(
1225
- Route.get(
1226
- RouteSchema.schemaSearchParams(
1227
- Schema.Struct({
1228
- count: Schema.NumberFromString,
1229
- }),
1230
- ),
1231
- Route.text("ok"),
1232
- ),
1233
- )
1234
-
1235
- const response = yield* Effect.promise(() =>
1236
- Http.fetch(handler, { path: "/test?count=not-a-number" })
1237
- )
1238
-
1239
- test
1240
- .expect(response.status)
1241
- .toBe(400)
1242
-
1243
- const messages = yield* TestLogger.messages
1244
- test
1245
- .expect(messages.some((m) => m.includes("ParseError")))
1246
- .toBe(true)
1247
- })
1248
- .pipe(Effect.provide(TestLogger.layer()), Effect.runPromise))
1249
-
1250
- test.it("handles missing required fields", () =>
1251
- Effect
1252
- .gen(function*() {
1253
- const runtime = yield* Effect.runtime<TestLogger.TestLogger>()
1254
- const handler = RouteHttp.toWebHandlerRuntime(runtime)(
1255
- Route.get(
1256
- RouteSchema.schemaHeaders(
1257
- Schema.Struct({
1258
- "x-required": Schema.String,
1259
- }),
1260
- ),
1261
- Route.text("ok"),
1262
- ),
1263
- )
1264
-
1265
- const response = yield* Effect.promise(() =>
1266
- Http.fetch(handler, { path: "/test" })
1267
- )
1268
-
1269
- test
1270
- .expect(response.status)
1271
- .toBe(400)
1272
-
1273
- const messages = yield* TestLogger.messages
1274
- test
1275
- .expect(messages.some((m) => m.includes("x-required")))
1276
- .toBe(true)
1277
- })
1278
- .pipe(Effect.provide(TestLogger.layer()), Effect.runPromise))
1279
-
1280
- test.it("parses multipart form data with file", async () => {
1281
- const handler = RouteHttp.toWebHandler(
1282
- Route.post(
1283
- RouteSchema.schemaBodyMultipart(
1284
- Schema.Struct({
1285
- title: Schema.String,
1286
- file: Schema.Array(RouteSchema.File),
1287
- }),
1288
- ),
1289
- Route.json(function*(ctx) {
1290
- const file = ctx.body.file[0]
1291
- return {
1292
- title: ctx.body.title,
1293
- fileName: file.name,
1294
- contentType: file.contentType,
1295
- size: file.content.length,
1296
- }
1297
- }),
1298
- ),
1299
- )
1300
-
1301
- const formData = new FormData()
1302
- formData.append("title", "My Upload")
1303
- formData.append(
1304
- "file",
1305
- new Blob(["hello world"], { type: "text/plain" }),
1306
- "test.txt",
1307
- )
1308
-
1309
- const response = await Http.fetch(handler, {
1310
- path: "/upload",
1311
- method: "POST",
1312
- body: formData,
1313
- })
1314
-
1315
- test
1316
- .expect(response.status)
1317
- .toBe(200)
1318
-
1319
- const json = await response.json()
1320
- test
1321
- .expect(json.title)
1322
- .toBe("My Upload")
1323
- test
1324
- .expect(json.fileName)
1325
- .toBe("test.txt")
1326
- test
1327
- .expect(json.contentType)
1328
- .toContain("text/plain")
1329
- test
1330
- .expect(json.size)
1331
- .toBe(11)
1332
- })
1333
-
1334
- test.it("handles multiple files with same field name", async () => {
1335
- const handler = RouteHttp.toWebHandler(
1336
- Route.post(
1337
- RouteSchema.schemaBodyMultipart(
1338
- Schema.Struct({
1339
- documents: Schema.Array(RouteSchema.File),
1340
- }),
1341
- ),
1342
- Route.json(function*(ctx) {
1343
- return {
1344
- count: ctx.body.documents.length,
1345
- names: ctx.body.documents.map((f) => f.name),
1346
- sizes: ctx.body.documents.map((f) => f.content.length),
1347
- }
1348
- }),
1349
- ),
1350
- )
1351
-
1352
- const formData = new FormData()
1353
- formData.append(
1354
- "documents",
1355
- new Blob(["first file content"], { type: "text/plain" }),
1356
- "doc1.txt",
1357
- )
1358
- formData.append(
1359
- "documents",
1360
- new Blob(["second file content"], { type: "text/plain" }),
1361
- "doc2.txt",
1362
- )
1363
- formData.append(
1364
- "documents",
1365
- new Blob(["third file content"], { type: "text/plain" }),
1366
- "doc3.txt",
1367
- )
1368
-
1369
- const response = await Http.fetch(handler, {
1370
- path: "/upload",
1371
- method: "POST",
1372
- body: formData,
1373
- })
1374
-
1375
- test
1376
- .expect(response.status)
1377
- .toBe(200)
1378
-
1379
- const json = await response.json()
1380
- test
1381
- .expect(json.count)
1382
- .toBe(3)
1383
- test
1384
- .expect(json.names)
1385
- .toEqual(["doc1.txt", "doc2.txt", "doc3.txt"])
1386
- test
1387
- .expect(json.sizes)
1388
- .toEqual([18, 19, 18])
1389
- })
1390
-
1391
- test.it("handles single file upload", async () => {
1392
- const handler = RouteHttp.toWebHandler(
1393
- Route.post(
1394
- RouteSchema.schemaBodyMultipart(
1395
- Schema.Struct({
1396
- image: Schema.Array(RouteSchema.File),
1397
- }),
1398
- ),
1399
- Route.json(function*(ctx) {
1400
- const image = ctx.body.image[0]
1401
- return {
1402
- name: image.name,
1403
- type: image.contentType,
1404
- size: image.content.length,
1405
- }
1406
- }),
1407
- ),
1408
- )
1409
-
1410
- const formData = new FormData()
1411
- formData.append(
1412
- "image",
1413
- new Blob(["fake image data"], { type: "image/png" }),
1414
- "avatar.png",
1415
- )
1416
-
1417
- const response = await Http.fetch(handler, {
1418
- path: "/upload",
1419
- method: "POST",
1420
- body: formData,
1421
- })
1422
-
1423
- test
1424
- .expect(response.status)
1425
- .toBe(200)
1426
-
1427
- const json = await response.json()
1428
- test
1429
- .expect(json.name)
1430
- .toBe("avatar.png")
1431
- test
1432
- .expect(json.type)
1433
- .toContain("image/png")
1434
- test
1435
- .expect(json.size)
1436
- .toBe(15)
1437
- })
1438
-
1439
- test.it("handles multiple string values for same field", async () => {
1440
- const handler = RouteHttp.toWebHandler(
1441
- Route.post(
1442
- RouteSchema.schemaBodyMultipart(
1443
- Schema.Struct({
1444
- tags: Schema.Array(Schema.String),
1445
- title: Schema.String,
1446
- }),
1447
- ),
1448
- Route.json(function*(ctx) {
1449
- return {
1450
- title: ctx.body.title,
1451
- // Schema returns readonly array, but Json type expects mutable array
1452
- tags: [...ctx.body.tags],
1453
- }
1454
- }),
1455
- ),
1456
- )
1457
-
1458
- const formData = new FormData()
1459
- formData.append("title", "My Post")
1460
- formData.append("tags", "javascript")
1461
- formData.append("tags", "typescript")
1462
- formData.append("tags", "effect")
1463
-
1464
- const response = await Http.fetch(handler, {
1465
- path: "/upload",
1466
- method: "POST",
1467
- body: formData,
1468
- })
1469
-
1470
- test
1471
- .expect(response.status)
1472
- .toBe(200)
1473
-
1474
- const json = await response.json()
1475
- test
1476
- .expect(json.title)
1477
- .toBe("My Post")
1478
- test
1479
- .expect(json.tags)
1480
- .toEqual(["javascript", "typescript", "effect"])
1481
- })
1482
-
1483
- test.it("schema validation: single value with Schema.String succeeds", async () => {
1484
- const handler = RouteHttp.toWebHandler(
1485
- Route.post(
1486
- RouteSchema.schemaBodyMultipart(
1487
- Schema.Struct({
1488
- name: Schema.String,
1489
- }),
1490
- ),
1491
- Route.json(function*(ctx) {
1492
- return { name: ctx.body.name }
1493
- }),
1494
- ),
1495
- )
1496
-
1497
- const formData = new FormData()
1498
- formData.append("name", "John")
1499
-
1500
- const response = await Http.fetch(handler, {
1501
- path: "/test",
1502
- method: "POST",
1503
- body: formData,
1504
- })
1505
-
1506
- test
1507
- .expect(response.status)
1508
- .toBe(200)
1509
-
1510
- const json = await response.json()
1511
- test
1512
- .expect(json.name)
1513
- .toBe("John")
1514
- })
1515
-
1516
- test.it("schema validation: multiple values with Schema.String fails with detailed error", () =>
1517
- Effect
1518
- .gen(function*() {
1519
- const runtime = yield* Effect.runtime<TestLogger.TestLogger>()
1520
- const handler = RouteHttp.toWebHandlerRuntime(runtime)(
1521
- Route.post(
1522
- RouteSchema.schemaBodyMultipart(
1523
- Schema.Struct({
1524
- name: Schema.String,
1525
- }),
1526
- ),
1527
- Route.json(function*(ctx) {
1528
- return { name: ctx.body.name }
1529
- }),
1530
- ),
1531
- )
1532
-
1533
- const formData = new FormData()
1534
- formData.append("name", "John")
1535
- formData.append("name", "Jane")
1536
-
1537
- const response = yield* Effect.promise(() =>
1538
- Http.fetch(handler, {
1539
- path: "/test",
1540
- method: "POST",
1541
- body: formData,
1542
- })
1543
- )
1544
-
1545
- test
1546
- .expect(response.status)
1547
- .toBe(400)
1548
-
1549
- const body = yield* Effect.promise(() => response.json())
1550
-
1551
- test
1552
- .expect(body.message)
1553
- .toContain("ParseError")
1554
- test
1555
- .expect(body.message)
1556
- .toContain("Expected string, actual [\"John\",\"Jane\"]")
1557
-
1558
- const messages = yield* TestLogger.messages
1559
- test
1560
- .expect(messages.some((m) => m.includes("ParseError")))
1561
- .toBe(true)
1562
- })
1563
- .pipe(Effect.provide(TestLogger.layer()), Effect.runPromise))
1564
-
1565
- test.it("logs validation errors to console", () =>
1566
- Effect
1567
- .gen(function*() {
1568
- const testLogger = yield* TestLogger.TestLogger
1569
- const runtime = yield* Effect.runtime<TestLogger.TestLogger>()
1570
-
1571
- const handler = RouteHttp.toWebHandlerRuntime(runtime)(
1572
- Route.post(
1573
- RouteSchema.schemaBodyMultipart(
1574
- Schema.Struct({
1575
- name: Schema.String,
1576
- }),
1577
- ),
1578
- Route.json(function*(ctx) {
1579
- return { name: ctx.body.name }
1580
- }),
1581
- ),
1582
- )
1583
-
1584
- const formData = new FormData()
1585
- formData.append("name", "John")
1586
- formData.append("name", "Jane")
1587
-
1588
- yield* Effect.promise(() =>
1589
- Http.fetch(handler, {
1590
- path: "/test",
1591
- method: "POST",
1592
- body: formData,
1593
- })
1594
- )
1595
-
1596
- const messages = yield* Ref.get(testLogger.messages)
1597
- const errorLogs = messages.filter((msg) => msg.includes("[Error]"))
1598
-
1599
- test
1600
- .expect(errorLogs.length)
1601
- .toBeGreaterThan(0)
1602
-
1603
- test
1604
- .expect(errorLogs[0])
1605
- .toContain("ParseError")
1606
- test
1607
- .expect(errorLogs[0])
1608
- .toContain("Expected string, actual [\"John\",\"Jane\"]")
1609
- })
1610
- .pipe(Effect.provide(TestLogger.layer()), Effect.runPromise))
1611
-
1612
- test.it("composes shared middleware with method-specific schema", async () => {
1613
- const handler = RouteHttp.toWebHandler(
1614
- Route
1615
- .use(RouteSchema.schemaHeaders(
1616
- Schema.Struct({
1617
- "x-api-version": Schema.String,
1618
- }),
1619
- ))
1620
- .post(
1621
- RouteSchema.schemaBodyJson(
1622
- Schema.Struct({
1623
- action: Schema.String,
1624
- }),
1625
- ),
1626
- Route.json(function*(ctx) {
1627
- return {
1628
- version: ctx.headers["x-api-version"],
1629
- action: ctx.body.action,
1630
- }
1631
- }),
1632
- )
1633
- .get(
1634
- RouteSchema.schemaSearchParams(
1635
- Schema.Struct({
1636
- id: Schema.String,
1637
- }),
1638
- ),
1639
- Route.json(function*(ctx) {
1640
- return {
1641
- version: ctx.headers["x-api-version"],
1642
- id: ctx.searchParams.id,
1643
- }
1644
- }),
1645
- ),
1646
- )
1647
-
1648
- const postResponse = await Http.fetch(handler, {
1649
- path: "/api",
1650
- method: "POST",
1651
- headers: {
1652
- "x-api-version": "v2",
1653
- "Content-Type": "application/json",
1654
- },
1655
- body: JSON.stringify({ action: "create" }),
1656
- })
1657
-
1658
- test
1659
- .expect(await postResponse.json())
1660
- .toEqual({ version: "v2", action: "create" })
1661
-
1662
- const getResponse = await Http.fetch(handler, {
1663
- path: "/api?id=123",
1664
- method: "GET",
1665
- headers: { "x-api-version": "v2" },
1666
- })
1667
-
1668
- test
1669
- .expect(await getResponse.json())
1670
- .toEqual({ version: "v2", id: "123" })
1671
- })
1672
-
1673
- test.it("handles cookies with equals sign in value", async () => {
1674
- const handler = RouteHttp.toWebHandler(
1675
- Route.get(
1676
- RouteSchema.schemaCookies(
1677
- Schema.Struct({
1678
- token: Schema.String,
1679
- }),
1680
- ),
1681
- Route.json(function*(ctx) {
1682
- return { token: ctx.cookies.token }
1683
- }),
1684
- ),
1685
- )
1686
-
1687
- const response = await Http.fetch(handler, {
1688
- path: "/test",
1689
- headers: { cookie: "token=abc=123==" },
1690
- })
1691
-
1692
- test
1693
- .expect(response.status)
1694
- .toBe(200)
1695
- test
1696
- .expect(await response.json())
1697
- .toEqual({ token: "abc=123==" })
1698
- })
1699
-
1700
- test.it("handles multiple search params with same key", async () => {
1701
- const handler = RouteHttp.toWebHandler(
1702
- Route.get(
1703
- RouteSchema.schemaSearchParams(
1704
- Schema.Struct({
1705
- tags: Schema.Array(Schema.String),
1706
- }),
1707
- ),
1708
- Route.json(function*(ctx) {
1709
- return { tags: [...ctx.searchParams.tags] }
1710
- }),
1711
- ),
1712
- )
1713
-
1714
- const response = await Http.fetch(handler, {
1715
- path: "/test?tags=one&tags=two&tags=three",
1716
- })
1717
-
1718
- test
1719
- .expect(response.status)
1720
- .toBe(200)
1721
- test
1722
- .expect(await response.json())
1723
- .toEqual({ tags: ["one", "two", "three"] })
1724
- })
1725
-
1726
- test.it("parses path params from RouteTree", async () => {
1727
- const tree = RouteTree.make({
1728
- "/folders/:folderId/files/:fileId": Route.get(
1729
- RouteSchema.schemaPathParams(
1730
- Schema.Struct({
1731
- folderId: Schema.String,
1732
- fileId: Schema.NumberFromString,
1733
- }),
1734
- ),
1735
- Route.json(function*(ctx) {
1736
- return {
1737
- folderId: ctx.pathParams.folderId,
1738
- fileId: ctx.pathParams.fileId,
1739
- }
1740
- }),
1741
- ),
1742
- })
1743
-
1744
- const handles = Object.fromEntries(RouteHttp.walkHandles(tree))
1745
- const handler = handles["/folders/:folderId/files/:fileId"]
1746
-
1747
- const response = await Http.fetch(handler, {
1748
- path: "/folders/abc123/files/42",
1749
- })
1750
-
1751
- test
1752
- .expect(response.status)
1753
- .toBe(200)
1754
- test
1755
- .expect(await response.json())
1756
- .toEqual({
1757
- folderId: "abc123",
1758
- fileId: 42,
1759
- })
1760
- })
1761
-
1762
- test.it("path params validation fails on invalid input", () =>
1763
- Effect
1764
- .gen(function*() {
1765
- const runtime = yield* Effect.runtime<TestLogger.TestLogger>()
1766
- const handler = RouteHttp.toWebHandlerRuntime(runtime)(
1767
- Route.get(
1768
- RouteSchema.schemaPathParams(
1769
- Schema.Struct({
1770
- userId: Schema.NumberFromString,
1771
- }),
1772
- ),
1773
- Route.text("ok"),
1774
- ),
1775
- )
1776
-
1777
- const response = yield* Effect.promise(() =>
1778
- Http.fetch(handler, { path: "/users/not-a-number" })
1779
- )
1780
-
1781
- test
1782
- .expect(response.status)
1783
- .toBe(400)
1784
-
1785
- const messages = yield* TestLogger.messages
1786
- test
1787
- .expect(messages.some((m) => m.includes("ParseError")))
1788
- .toBe(true)
1789
- })
1790
- .pipe(Effect.provide(TestLogger.layer()), Effect.runPromise))
1791
-
1792
- test.it("combines path params with headers and body", async () => {
1793
- const tree = RouteTree.make({
1794
- "/projects/:projectId/tasks": Route.post(
1795
- RouteSchema.schemaPathParams(
1796
- Schema.Struct({
1797
- projectId: Schema.String,
1798
- }),
1799
- ),
1800
- RouteSchema.schemaHeaders(
1801
- Schema.Struct({
1802
- "x-api-key": Schema.String,
1803
- }),
1804
- ),
1805
- RouteSchema.schemaBodyJson(
1806
- Schema.Struct({
1807
- title: Schema.String,
1808
- }),
1809
- ),
1810
- Route.json(function*(ctx) {
1811
- return {
1812
- projectId: ctx.pathParams.projectId,
1813
- apiKey: ctx.headers["x-api-key"],
1814
- title: ctx.body.title,
1815
- }
1816
- }),
1817
- ),
1818
- })
1819
-
1820
- const handles = Object.fromEntries(RouteHttp.walkHandles(tree))
1821
- const handler = handles["/projects/:projectId/tasks"]
1822
-
1823
- const response = await Http.fetch(handler, {
1824
- path: "/projects/proj-999/tasks",
1825
- method: "POST",
1826
- headers: { "x-api-key": "secret" },
1827
- body: { title: "New Task" },
1828
- })
1829
-
1830
- test
1831
- .expect(response.status)
1832
- .toBe(200)
1833
- test
1834
- .expect(await response.json())
1835
- .toEqual({
1836
- projectId: "proj-999",
1837
- apiKey: "secret",
1838
- title: "New Task",
1839
- })
1840
- })
1841
- })
1842
-
1843
- test.describe("request abort handling", () => {
1844
- test.it("returns 499 and runs finalizers when request is aborted", async () => {
1845
- let finalizerRan = false
1846
-
1847
- const handler = RouteHttp.toWebHandler(
1848
- Route.get(
1849
- Route.text(function*() {
1850
- yield* Effect.addFinalizer(() =>
1851
- Effect.sync(() => {
1852
- finalizerRan = true
1853
- })
1854
- )
1855
- yield* Effect.sleep("10 seconds")
1856
- return "should not reach"
1857
- }),
1858
- ),
1859
- )
1860
-
1861
- const { request, abort } = Http.createAbortableRequest({ path: "/abort" })
1862
-
1863
- const responsePromise = handler(request)
1864
-
1865
- await Effect.runPromise(Effect.sleep("10 millis"))
1866
- abort()
1867
-
1868
- const response = await responsePromise
1869
-
1870
- test
1871
- .expect(response.status)
1872
- .toBe(499)
1873
- test
1874
- .expect(finalizerRan)
1875
- .toBe(true)
1876
- })
1877
-
1878
- test.it("uses clientAbortFiberId to identify client disconnects", async () => {
1879
- let interruptedBy: string | undefined
1880
-
1881
- const handler = RouteHttp.toWebHandler(
1882
- Route.get(
1883
- Route.text(
1884
- Effect
1885
- .gen(function*() {
1886
- yield* Effect.sleep("10 seconds")
1887
- return "should not reach"
1888
- })
1889
- .pipe(
1890
- Effect.onInterrupt((interruptors) =>
1891
- Effect.sync(() => {
1892
- for (const id of interruptors) {
1893
- interruptedBy = String(id)
1894
- }
1895
- })
1896
- ),
1897
- ),
1898
- ),
1899
- ),
1900
- )
1901
-
1902
- const { request, abort } = Http.createAbortableRequest({ path: "/abort" })
1903
-
1904
- const responsePromise = handler(request)
1905
-
1906
- await Effect.runPromise(Effect.sleep("10 millis"))
1907
- abort()
1908
-
1909
- await responsePromise
1910
-
1911
- test
1912
- .expect(interruptedBy)
1913
- .toContain("-499")
1914
- })
1915
-
1916
- test.it("interrupts streaming response when request is aborted", async () => {
1917
- let finalizerRan = false
1918
-
1919
- const handler = RouteHttp.toWebHandler(
1920
- Route.get(
1921
- Route.text(function*() {
1922
- yield* Effect.addFinalizer(() =>
1923
- Effect.sync(() => {
1924
- finalizerRan = true
1925
- })
1926
- )
1927
- return Stream.fromSchedule(Schedule.spaced("100 millis")).pipe(
1928
- Stream.map((n) => `event ${n}\n`),
1929
- Stream.take(100),
1930
- )
1931
- }),
1932
- ),
1933
- )
1934
-
1935
- const { request, abort } = Http.createAbortableRequest({ path: "/stream" })
1936
-
1937
- const response = await handler(request)
1938
-
1939
- test
1940
- .expect(response.status)
1941
- .toBe(200)
1942
-
1943
- const reader = response.body!.getReader()
1944
- const firstChunk = await reader.read()
1945
-
1946
- test
1947
- .expect(firstChunk.done)
1948
- .toBe(false)
1949
-
1950
- abort()
1951
-
1952
- await Effect.runPromise(Effect.sleep("50 millis"))
1953
-
1954
- test
1955
- .expect(finalizerRan)
1956
- .toBe(true)
1957
- })
1958
- })
1959
-
1960
- test.describe("tracing", () => {
1961
- test.it("creates span with correct name and kind", async () => {
1962
- let capturedSpan: Tracer.Span | undefined
1963
-
1964
- const handler = RouteHttp.toWebHandler(
1965
- Route.get(
1966
- Route.text(function*() {
1967
- const span = yield* Effect.currentSpan
1968
- capturedSpan = span
1969
- return "ok"
1970
- }),
1971
- ),
1972
- )
1973
-
1974
- await Http.fetch(handler, { path: "/test" })
1975
-
1976
- test
1977
- .expect(capturedSpan)
1978
- .toBeDefined()
1979
- test
1980
- .expect(capturedSpan?.name)
1981
- .toBe("http.server GET")
1982
- test
1983
- .expect(capturedSpan?.kind)
1984
- .toBe("server")
1985
- })
1986
-
1987
- test.it("adds request attributes to span", async () => {
1988
- let capturedSpan: Tracer.Span | undefined
1989
-
1990
- const handler = RouteHttp.toWebHandler(
1991
- Route.get(
1992
- Route.text(function*() {
1993
- const span = yield* Effect.currentSpan
1994
- capturedSpan = span
1995
- return "ok"
1996
- }),
1997
- ),
1998
- )
1999
-
2000
- await Http.fetch(handler, {
2001
- path: "/users?page=1&limit=10",
2002
- headers: { "user-agent": "test-agent" },
2003
- })
2004
-
2005
- test
2006
- .expect(capturedSpan?.attributes.get("http.request.method"))
2007
- .toBe("GET")
2008
- test
2009
- .expect(capturedSpan?.attributes.get("url.path"))
2010
- .toBe("/users")
2011
- test
2012
- .expect(capturedSpan?.attributes.get("url.query"))
2013
- .toBe("page=1&limit=10")
2014
- test
2015
- .expect(capturedSpan?.attributes.get("url.scheme"))
2016
- .toBe("http")
2017
- test
2018
- .expect(capturedSpan?.attributes.get("user_agent.original"))
2019
- .toBe("test-agent")
2020
- })
2021
-
2022
- test.it("adds response status code to span", async () => {
2023
- let capturedSpan: Tracer.Span | undefined
2024
-
2025
- const handler = RouteHttp.toWebHandler(
2026
- Route.get(
2027
- Route.text(function*() {
2028
- const span = yield* Effect.currentSpan
2029
- capturedSpan = span
2030
- return "ok"
2031
- }),
2032
- ),
2033
- )
2034
-
2035
- const response = await Http.fetch(handler, { path: "/test" })
2036
-
2037
- test
2038
- .expect(response.status)
2039
- .toBe(200)
2040
-
2041
- await Effect.runPromise(Effect.sleep("10 millis"))
2042
-
2043
- test
2044
- .expect(capturedSpan?.attributes.get("http.response.status_code"))
2045
- .toBe(200)
2046
- })
2047
-
2048
- test.it("parses W3C traceparent header for parent span", async () => {
2049
- let capturedSpan: Tracer.Span | undefined
2050
-
2051
- const handler = RouteHttp.toWebHandler(
2052
- Route.get(
2053
- Route.text(function*() {
2054
- const span = yield* Effect.currentSpan
2055
- capturedSpan = span
2056
- return "ok"
2057
- }),
2058
- ),
2059
- )
2060
-
2061
- await Http.fetch(handler, {
2062
- path: "/test",
2063
- headers: {
2064
- traceparent: "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01",
2065
- },
2066
- })
2067
-
2068
- test
2069
- .expect(capturedSpan?.parent)
2070
- .toBeDefined()
2071
-
2072
- const parent = Option.getOrUndefined(
2073
- capturedSpan?.parent ?? Option.none(),
2074
- ) as Tracer.AnySpan | undefined
2075
- test
2076
- .expect(parent?.traceId)
2077
- .toBe("0af7651916cd43dd8448eb211c80319c")
2078
- test
2079
- .expect(parent?.spanId)
2080
- .toBe("b7ad6b7169203331")
2081
- })
2082
-
2083
- test.it("parses B3 single header for parent span", async () => {
2084
- let capturedSpan: Tracer.Span | undefined
2085
-
2086
- const handler = RouteHttp.toWebHandler(
2087
- Route.get(
2088
- Route.text(function*() {
2089
- const span = yield* Effect.currentSpan
2090
- capturedSpan = span
2091
- return "ok"
2092
- }),
2093
- ),
2094
- )
2095
-
2096
- await Http.fetch(handler, {
2097
- path: "/test",
2098
- headers: {
2099
- b3: "80f198ee56343ba864fe8b2a57d3eff7-e457b5a2e4d86bd1-1",
2100
- },
2101
- })
2102
-
2103
- test
2104
- .expect(capturedSpan?.parent)
2105
- .toBeDefined()
2106
-
2107
- const parent = Option.getOrUndefined(
2108
- capturedSpan?.parent ?? Option.none(),
2109
- ) as Tracer.AnySpan | undefined
2110
- test
2111
- .expect(parent?.traceId)
2112
- .toBe("80f198ee56343ba864fe8b2a57d3eff7")
2113
- test
2114
- .expect(parent?.spanId)
2115
- .toBe("e457b5a2e4d86bd1")
2116
- })
2117
-
2118
- test.it("parses X-B3 multi headers for parent span", async () => {
2119
- let capturedSpan: Tracer.Span | undefined
2120
-
2121
- const handler = RouteHttp.toWebHandler(
2122
- Route.get(
2123
- Route.text(function*() {
2124
- const span = yield* Effect.currentSpan
2125
- capturedSpan = span
2126
- return "ok"
2127
- }),
2128
- ),
2129
- )
2130
-
2131
- await Http.fetch(handler, {
2132
- path: "/test",
2133
- headers: {
2134
- "x-b3-traceid": "463ac35c9f6413ad48485a3953bb6124",
2135
- "x-b3-spanid": "0020000000000001",
2136
- "x-b3-sampled": "1",
2137
- },
2138
- })
2139
-
2140
- test
2141
- .expect(capturedSpan?.parent)
2142
- .toBeDefined()
2143
-
2144
- const parent = Option.getOrUndefined(
2145
- capturedSpan?.parent ?? Option.none(),
2146
- ) as Tracer.AnySpan | undefined
2147
- test
2148
- .expect(parent?.traceId)
2149
- .toBe("463ac35c9f6413ad48485a3953bb6124")
2150
- test
2151
- .expect(parent?.spanId)
2152
- .toBe("0020000000000001")
2153
- })
2154
-
2155
- test.it("withTracerDisabledWhen disables tracing for matching requests", () =>
2156
- Effect
2157
- .gen(function*() {
2158
- let spanCapturedOnHealth = false
2159
- let spanCapturedOnUsers = false
2160
-
2161
- const runtime = yield* RouteHttp.withTracerDisabledWhen(
2162
- Effect.runtime<never>(),
2163
- (req) => new URL(req.url).pathname === "/health",
2164
- )
2165
- const handler = RouteHttp.toWebHandlerRuntime(runtime)(
2166
- Route.get(
2167
- Route.text(function*() {
2168
- const spanResult = yield* Effect.option(Effect.currentSpan)
2169
- if (Option.isSome(spanResult)) {
2170
- const path = spanResult.value.attributes.get("url.path")
2171
- if (path === "/health") spanCapturedOnHealth = true
2172
- if (path === "/users") spanCapturedOnUsers = true
2173
- }
2174
- return "ok"
2175
- }),
2176
- ),
2177
- )
2178
-
2179
- yield* Effect.promise(() => Http.fetch(handler, { path: "/health" }))
2180
- yield* Effect.promise(() => Http.fetch(handler, { path: "/users" }))
2181
-
2182
- test
2183
- .expect(spanCapturedOnHealth)
2184
- .toBe(false)
2185
- test
2186
- .expect(spanCapturedOnUsers)
2187
- .toBe(true)
2188
- })
2189
- .pipe(Effect.runPromise))
2190
-
2191
- test.it("withSpanNameGenerator customizes span name", () =>
2192
- Effect
2193
- .gen(function*() {
2194
- let capturedSpan: Tracer.Span | undefined
2195
-
2196
- const runtime = yield* RouteHttp.withSpanNameGenerator(
2197
- Effect.runtime<never>(),
2198
- (req) => {
2199
- const url = new URL(req.url)
2200
- return `${req.method} ${url.pathname}`
2201
- },
2202
- )
2203
- const handler = RouteHttp.toWebHandlerRuntime(runtime)(
2204
- Route.get(
2205
- Route.text(function*() {
2206
- const span = yield* Effect.currentSpan
2207
- capturedSpan = span
2208
- return "ok"
2209
- }),
2210
- ),
2211
- )
2212
-
2213
- yield* Effect.promise(() => Http.fetch(handler, { path: "/users" }))
2214
-
2215
- test
2216
- .expect(capturedSpan?.name)
2217
- .toBe("GET /users")
2218
- })
2219
- .pipe(Effect.runPromise))
2220
-
2221
- test.it("adds http.route attribute when route has path", async () => {
2222
- let capturedSpan: Tracer.Span | undefined
2223
-
2224
- const tree = RouteTree.make({
2225
- "/users/:id": Route.get(
2226
- Route.text(function*() {
2227
- const span = yield* Effect.currentSpan
2228
- capturedSpan = span
2229
- return "ok"
2230
- }),
2231
- ),
2232
- })
2233
-
2234
- const handles = Object.fromEntries(RouteHttp.walkHandles(tree))
2235
- const handler = handles["/users/:id"]
2236
-
2237
- await Http.fetch(handler, { path: "/users/123" })
2238
-
2239
- test
2240
- .expect(capturedSpan?.attributes.get("http.route"))
2241
- .toBe("/users/:id")
2242
- })
2243
- })
2244
-
2245
- test.describe("RouteTree layer routes", () => {
2246
- test.it("layer routes execute in order before path routes", async () => {
2247
- const calls: string[] = []
2248
-
2249
- const tree = RouteTree.make({
2250
- "*": Route
2251
- .use(Route.filter(function*() {
2252
- calls.push("layer1")
2253
- return { context: {} }
2254
- }))
2255
- .use(Route.filter(function*() {
2256
- calls.push("layer2")
2257
- return { context: {} }
2258
- })),
2259
- "/test": Route.get(Route.text(function*() {
2260
- calls.push("handler")
2261
- return "ok"
2262
- })),
2263
- })
2264
-
2265
- const handles = Object.fromEntries(RouteHttp.walkHandles(tree))
2266
- const response = await Http.fetch(handles["/test"], { path: "/test" })
2267
-
2268
- test
2269
- .expect(response.status)
2270
- .toBe(200)
2271
- test
2272
- .expect(calls)
2273
- .toEqual(["layer1", "layer2", "handler"])
2274
- })
2275
-
2276
- test.it("layer routes apply to all paths in the tree", async () => {
2277
- const calls: string[] = []
2278
-
2279
- const tree = RouteTree.make({
2280
- "*": Route.use(Route.filter(function*() {
2281
- calls.push("layer")
2282
- return { context: {} }
2283
- })),
2284
- "/users": Route.get(Route.text(function*() {
2285
- calls.push("users")
2286
- return "users"
2287
- })),
2288
- "/admin": Route.get(Route.text(function*() {
2289
- calls.push("admin")
2290
- return "admin"
2291
- })),
2292
- })
2293
-
2294
- const handles = Object.fromEntries(RouteHttp.walkHandles(tree))
2295
-
2296
- calls.length = 0
2297
- await Http.fetch(handles["/users"], { path: "/users" })
2298
- test
2299
- .expect(calls)
2300
- .toEqual(["layer", "users"])
2301
-
2302
- calls.length = 0
2303
- await Http.fetch(handles["/admin"], { path: "/admin" })
2304
- test
2305
- .expect(calls)
2306
- .toEqual(["layer", "admin"])
2307
- })
2308
-
2309
- test.it("layer execution does not leak between requests", async () => {
2310
- let layerCallCount = 0
2311
-
2312
- const tree = RouteTree.make({
2313
- "*": Route.use(Route.filter(function*() {
2314
- layerCallCount++
2315
- return { context: {} }
2316
- })),
2317
- "/test": Route.get(Route.text("ok")),
2318
- })
2319
-
2320
- const handles = Object.fromEntries(RouteHttp.walkHandles(tree))
2321
-
2322
- layerCallCount = 0
2323
- await Http.fetch(handles["/test"], { path: "/test" })
2324
- test
2325
- .expect(layerCallCount)
2326
- .toBe(1)
2327
-
2328
- await Http.fetch(handles["/test"], { path: "/test" })
2329
- test
2330
- .expect(layerCallCount)
2331
- .toBe(2)
2332
- })
2333
-
2334
- test.it("nested tree inherits parent layer routes", async () => {
2335
- const calls: string[] = []
2336
-
2337
- const apiTree = RouteTree.make({
2338
- "/users": Route.get(Route.text(function*() {
2339
- calls.push("users")
2340
- return "users"
2341
- })),
2342
- })
2343
-
2344
- const tree = RouteTree.make({
2345
- "*": Route.use(Route.filter(function*() {
2346
- calls.push("root-layer")
2347
- return { context: {} }
2348
- })),
2349
- "/api": apiTree,
2350
- })
2351
-
2352
- const handles = Object.fromEntries(RouteHttp.walkHandles(tree))
2353
- await Http.fetch(handles["/api/users"], { path: "/api/users" })
2354
-
2355
- test
2356
- .expect(calls)
2357
- .toEqual(["root-layer", "users"])
2358
- })
2359
-
2360
- test.it("layer routes can short-circuit with error", () =>
2361
- Effect
2362
- .gen(function*() {
2363
- const runtime = yield* Effect.runtime<TestLogger.TestLogger>()
2364
- let handlerExecuted = false
2365
-
2366
- const tree = RouteTree.make({
2367
- "*": Route.use(Route.filter(function*() {
2368
- return yield* Effect.fail(new Error("layer rejected"))
2369
- })),
2370
- "/test": Route.get(Route.text(function*() {
2371
- handlerExecuted = true
2372
- return "should not reach"
2373
- })),
2374
- })
2375
-
2376
- const handles = Object.fromEntries(RouteHttp.walkHandles(tree, runtime))
2377
-
2378
- const response = yield* Effect.promise(() =>
2379
- Http.fetch(handles["/test"], { path: "/test" })
2380
- )
2381
-
2382
- test
2383
- .expect(response.status)
2384
- .toBe(500)
2385
-
2386
- test
2387
- .expect(handlerExecuted)
2388
- .toBe(false)
2389
-
2390
- const text = yield* Effect.promise(() => response.text())
2391
- test
2392
- .expect(text)
2393
- .toContain("layer rejected")
2394
-
2395
- const messages = yield* TestLogger.messages
2396
- test
2397
- .expect(messages.some((m) => m.includes("layer rejected")))
2398
- .toBe(true)
2399
- })
2400
- .pipe(Effect.provide(TestLogger.layer()), Effect.runPromise))
2401
-
2402
- test.it("layer middleware wraps response content with json", async () => {
2403
- const tree = RouteTree.make({
2404
- "*": Route.use(
2405
- Route.json(function*(_ctx, next) {
2406
- const value = yield* next().json
2407
- return { wrapped: value }
2408
- }),
2409
- ),
2410
- "/data": Route.get(Route.json({ original: true })),
2411
- })
2412
-
2413
- const handles = Object.fromEntries(RouteHttp.walkHandles(tree))
2414
- const response = await Http.fetch(handles["/data"], { path: "/data" })
2415
-
2416
- test
2417
- .expect(await response.json())
2418
- .toEqual({ wrapped: { original: true } })
2419
- })
2420
-
2421
- test.it("layer middleware wraps response content with text", async () => {
2422
- const tree = RouteTree.make({
2423
- "*": Route.use(
2424
- Route.text(function*(_ctx, next) {
2425
- const value = yield* next().text
2426
- return `Layout: ${value}`
2427
- }),
2428
- ),
2429
- "/page": Route.get(Route.text("Page Content")),
2430
- })
2431
-
2432
- const handles = Object.fromEntries(RouteHttp.walkHandles(tree))
2433
- const response = await Http.fetch(handles["/page"], { path: "/page" })
2434
-
2435
- test
2436
- .expect(await response.text())
2437
- .toBe("Layout: Page Content")
2438
- })
2439
-
2440
- test.it("multiple layers execute in definition order", async () => {
2441
- const calls: string[] = []
2442
-
2443
- const tree = RouteTree.make({
2444
- "*": Route
2445
- .use(Route.filter(function*() {
2446
- calls.push("layer1")
2447
- return { context: {} }
2448
- }))
2449
- .use(Route.filter(function*() {
2450
- calls.push("layer2")
2451
- return { context: {} }
2452
- })),
2453
- "/test": Route.get(Route.text(function*() {
2454
- calls.push("handler")
2455
- return "ok"
2456
- })),
2457
- })
2458
-
2459
- const handles = Object.fromEntries(RouteHttp.walkHandles(tree))
2460
- await Http.fetch(handles["/test"], { path: "/test" })
2461
-
2462
- test
2463
- .expect(calls)
2464
- .toEqual(["layer1", "layer2", "handler"])
2465
- })
2466
-
2467
- test.it("format negotiation excludes middleware formats", async () => {
2468
- const tree = RouteTree.make({
2469
- "*": Route.use(
2470
- Route.json(function*(_ctx, next) {
2471
- const value = yield* next().json
2472
- return { wrapped: value }
2473
- }),
2474
- ),
2475
- "/": Route.get(Route.html("<h1>Hello</h1>")),
2476
- })
2477
-
2478
- const handles = Object.fromEntries(RouteHttp.walkHandles(tree))
2479
- const response = await Http.fetch(handles["/"], { path: "/" })
2480
-
2481
- test
2482
- .expect(response.status)
2483
- .toBe(200)
2484
- test
2485
- .expect(response.headers.get("Content-Type"))
2486
- .toBe("text/html; charset=utf-8")
2487
- test
2488
- .expect(await response.text())
2489
- .toBe("<h1>Hello</h1>")
2490
- })
2491
- })
2492
-
2493
- test.describe("Route.render (format=*)", () => {
2494
- test.it("accepts any Accept header", async () => {
2495
- const handler = RouteHttp.toWebHandler(
2496
- Route.get(
2497
- Route.render(function*() {
2498
- return Stream.make("event: message\ndata: hello\n\n")
2499
- }),
2500
- ),
2501
- )
2502
-
2503
- const response = await Http.fetch(handler, {
2504
- path: "/events",
2505
- headers: { Accept: "text/event-stream" },
2506
- })
2507
-
2508
- test
2509
- .expect(response.status)
2510
- .toBe(200)
2511
- test
2512
- .expect(await response.text())
2513
- .toBe("event: message\ndata: hello\n\n")
2514
- })
2515
-
2516
- test.it("works without Accept header", async () => {
2517
- const handler = RouteHttp.toWebHandler(
2518
- Route.get(
2519
- Route.render(function*() {
2520
- return "raw response"
2521
- }),
2522
- ),
2523
- )
2524
-
2525
- const response = await Http.fetch(handler, { path: "/raw" })
2526
-
2527
- test
2528
- .expect(response.status)
2529
- .toBe(200)
2530
- test
2531
- .expect(await response.text())
2532
- .toBe("raw response")
2533
- })
2534
-
2535
- test.it("does not participate in content negotiation", async () => {
2536
- const handler = RouteHttp.toWebHandler(
2537
- Route
2538
- .get(Route.json({ type: "json" }))
2539
- .get(Route.render(function*() {
2540
- return "fallback"
2541
- })),
2542
- )
2543
-
2544
- const jsonResponse = await Http.fetch(handler, {
2545
- path: "/data",
2546
- headers: { Accept: "application/json" },
2547
- })
2548
- test
2549
- .expect(await jsonResponse.json())
2550
- .toEqual({ type: "json" })
2551
-
2552
- const eventStreamResponse = await Http.fetch(handler, {
2553
- path: "/data",
2554
- headers: { Accept: "text/event-stream" },
2555
- })
2556
- test
2557
- .expect(eventStreamResponse.status)
2558
- .toBe(200)
2559
- test
2560
- .expect(await eventStreamResponse.text())
2561
- .toBe("fallback")
2562
- })
2563
-
2564
- test.it("is always called regardless of Accept header when only handle routes exist", async () => {
2565
- const handler = RouteHttp.toWebHandler(
2566
- Route.get(
2567
- Route.render(function*() {
2568
- return "any format"
2569
- }),
2570
- ),
2571
- )
2572
-
2573
- const responses = await Promise.all([
2574
- Http.fetch(handler, {
2575
- path: "/",
2576
- headers: { Accept: "text/event-stream" },
2577
- }),
2578
- Http.fetch(handler, { path: "/", headers: { Accept: "image/png" } }),
2579
- Http.fetch(handler, { path: "/", headers: { Accept: "*/*" } }),
2580
- Http.fetch(handler, { path: "/" }),
2581
- ])
2582
-
2583
- for (const response of responses) {
2584
- test
2585
- .expect(response.status)
2586
- .toBe(200)
2587
- test
2588
- .expect(await response.text())
2589
- .toBe("any format")
2590
- }
2591
- })
2592
-
2593
- test.it("can return Entity with custom headers", async () => {
2594
- const handler = RouteHttp.toWebHandler(
2595
- Route.get(
2596
- Route.render(function*() {
2597
- return Entity.make(Stream.make("data: hello\n\n"), {
2598
- headers: {
2599
- "content-type": "text/event-stream",
2600
- "cache-control": "no-cache",
2601
- },
2602
- })
2603
- }),
2604
- ),
2605
- )
2606
-
2607
- const response = await Http.fetch(handler, {
2608
- path: "/events",
2609
- headers: { Accept: "text/event-stream" },
2610
- })
2611
-
2612
- test
2613
- .expect(response.status)
2614
- .toBe(200)
2615
- test
2616
- .expect(response.headers.get("content-type"))
2617
- .toBe("text/event-stream")
2618
- test
2619
- .expect(response.headers.get("cache-control"))
2620
- .toBe("no-cache")
2621
- test
2622
- .expect(await response.text())
2623
- .toBe("data: hello\n\n")
2624
- })
2625
-
2626
- test.it("handle middleware wraps handle handler", async () => {
2627
- const handler = RouteHttp.toWebHandler(
2628
- Route
2629
- .use(
2630
- Route.render(function*(_ctx, next) {
2631
- const value = yield* next().text
2632
- return `wrapped: ${value}`
2633
- }),
2634
- )
2635
- .get(
2636
- Route.render(function*() {
2637
- return "inner"
2638
- }),
2639
- ),
2640
- )
2641
-
2642
- const response = await Http.fetch(handler, {
2643
- path: "/",
2644
- headers: { Accept: "text/event-stream" },
2645
- })
2646
-
2647
- test
2648
- .expect(response.status)
2649
- .toBe(200)
2650
- test
2651
- .expect(await response.text())
2652
- .toBe("wrapped: inner")
2653
- })
2654
-
2655
- test.it("render middleware always runs even when specific format is selected", async () => {
2656
- const calls: string[] = []
2657
-
2658
- const handler = RouteHttp.toWebHandler(
2659
- Route
2660
- .use(
2661
- Route.render(function*(_ctx, next) {
2662
- calls.push("render middleware")
2663
- return next().stream
2664
- }),
2665
- )
2666
- .get(
2667
- Route.json(function*() {
2668
- calls.push("json handler")
2669
- return { type: "json" }
2670
- }),
2671
- ),
2672
- )
2673
-
2674
- const response = await Http.fetch(handler, {
2675
- path: "/",
2676
- headers: { Accept: "application/json" },
2677
- })
2678
-
2679
- test
2680
- .expect(response.status)
2681
- .toBe(200)
2682
- test
2683
- .expect(calls)
2684
- .toEqual(["render middleware", "json handler"])
2685
- test
2686
- .expect(await response.json())
2687
- .toEqual({ type: "json" })
2688
- })
2689
-
2690
- test.it("next() from render matches both render and selected format routes", async () => {
2691
- const calls: string[] = []
2692
-
2693
- const handler = RouteHttp.toWebHandler(
2694
- Route
2695
- .use(
2696
- Route.render(function*(_ctx, next) {
2697
- calls.push("render middleware 1")
2698
- return next().stream
2699
- }),
2700
- Route.render(function*(_ctx, next) {
2701
- calls.push("render middleware 2")
2702
- return next().stream
2703
- }),
2704
- Route.json(function*(_ctx, next) {
2705
- calls.push("json middleware")
2706
- return yield* next().json
2707
- }),
2708
- )
2709
- .get(
2710
- Route.json(function*() {
2711
- calls.push("json handler")
2712
- return { type: "json" }
2713
- }),
2714
- ),
2715
- )
2716
-
2717
- const response = await Http.fetch(handler, {
2718
- path: "/",
2719
- headers: { Accept: "application/json" },
2720
- })
2721
-
2722
- test
2723
- .expect(response.status)
2724
- .toBe(200)
2725
- test
2726
- .expect(calls)
2727
- .toEqual([
2728
- "render middleware 1",
2729
- "render middleware 2",
2730
- "json middleware",
2731
- "json handler",
2732
- ])
2733
- })
2734
-
2735
- test.it("render handler runs when no specific format matches", async () => {
2736
- const calls: string[] = []
2737
-
2738
- const handler = RouteHttp.toWebHandler(
2739
- Route
2740
- .get(
2741
- Route.json(function*() {
2742
- calls.push("json")
2743
- return { type: "json" }
2744
- }),
2745
- Route.render(function*() {
2746
- calls.push("render")
2747
- return "render output"
2748
- }),
2749
- ),
2750
- )
2751
-
2752
- const eventStreamResponse = await Http.fetch(handler, {
2753
- path: "/",
2754
- headers: { Accept: "text/event-stream" },
2755
- })
2756
-
2757
- test
2758
- .expect(eventStreamResponse.status)
2759
- .toBe(200)
2760
- test
2761
- .expect(calls)
2762
- .toEqual(["render"])
2763
- test
2764
- .expect(await eventStreamResponse.text())
2765
- .toBe("render output")
2766
- })
2767
-
2768
- test.it("render used as fallback when Accept doesn't match other formats", async () => {
2769
- const handler = RouteHttp.toWebHandler(
2770
- Route
2771
- .get(
2772
- Route.json({ type: "json" }),
2773
- Route.html("<h1>html</h1>"),
2774
- Route.render(function*() {
2775
- return "fallback for unknown accept"
2776
- }),
2777
- ),
2778
- )
2779
-
2780
- const eventStreamResponse = await Http.fetch(handler, {
2781
- path: "/",
2782
- headers: { Accept: "text/event-stream" },
2783
- })
2784
-
2785
- test
2786
- .expect(eventStreamResponse.status)
2787
- .toBe(200)
2788
- test
2789
- .expect(await eventStreamResponse.text())
2790
- .toBe("fallback for unknown accept")
2791
- })
2792
-
2793
- test.it("handler context includes format=*", () => {
2794
- Route.get(
2795
- Route.render(function*(ctx) {
2796
- test
2797
- .expectTypeOf(ctx.format)
2798
- .toEqualTypeOf<"*">()
2799
- return "ok"
2800
- }),
2801
- )
2802
- })
2803
-
2804
- test.it("streams work correctly with render", async () => {
2805
- const handler = RouteHttp.toWebHandler(
2806
- Route.get(
2807
- Route.render(function*() {
2808
- return Stream.make("chunk1", "chunk2", "chunk3")
2809
- }),
2810
- ),
2811
- )
2812
-
2813
- const response = await Http.fetch(handler, {
2814
- path: "/stream",
2815
- headers: { Accept: "text/event-stream" },
2816
- })
2817
-
2818
- test
2819
- .expect(response.status)
2820
- .toBe(200)
2821
- test
2822
- .expect(await response.text())
2823
- .toBe("chunk1chunk2chunk3")
2824
- })
2825
-
2826
- test.it("multiple render middlewares chain correctly", async () => {
2827
- const handler = RouteHttp.toWebHandler(
2828
- Route
2829
- .use(
2830
- Route.render(function*(_ctx, next) {
2831
- const value = yield* next().text
2832
- return `outer(${value})`
2833
- }),
2834
- Route.render(function*(_ctx, next) {
2835
- const value = yield* next().text
2836
- return `inner(${value})`
2837
- }),
2838
- )
2839
- .get(
2840
- Route.render(function*() {
2841
- return "content"
2842
- }),
2843
- ),
2844
- )
2845
-
2846
- const response = await Http.fetch(handler, { path: "/" })
2847
-
2848
- test
2849
- .expect(response.status)
2850
- .toBe(200)
2851
- test
2852
- .expect(await response.text())
2853
- .toBe("outer(inner(content))")
2854
- })
2855
-
2856
- test.it("render middleware can wrap text handler", async () => {
2857
- const handler = RouteHttp.toWebHandler(
2858
- Route
2859
- .use(
2860
- Route.render(function*(_ctx, next) {
2861
- const value = yield* next().text
2862
- return `[${value}]`
2863
- }),
2864
- )
2865
- .get(
2866
- Route.text("hello"),
2867
- ),
2868
- )
2869
-
2870
- const response = await Http.fetch(handler, {
2871
- path: "/",
2872
- headers: { Accept: "text/plain" },
2873
- })
2874
-
2875
- test
2876
- .expect(response.status)
2877
- .toBe(200)
2878
- test
2879
- .expect(await response.text())
2880
- .toBe("[hello]")
2881
- })
2882
-
2883
- test.it("render middleware can wrap html handler", async () => {
2884
- const handler = RouteHttp.toWebHandler(
2885
- Route
2886
- .use(
2887
- Route.render(function*(_ctx, next) {
2888
- const value = yield* next().text
2889
- return `<!DOCTYPE html>${value}`
2890
- }),
2891
- )
2892
- .get(
2893
- Route.html("<body>content</body>"),
2894
- ),
2895
- )
2896
-
2897
- const response = await Http.fetch(handler, {
2898
- path: "/",
2899
- headers: { Accept: "text/html" },
2900
- })
2901
-
2902
- test
2903
- .expect(response.status)
2904
- .toBe(200)
2905
- test
2906
- .expect(await response.text())
2907
- .toBe("<!DOCTYPE html><body>content</body>")
2908
- })
2909
- })