effect-start 0.14.0 → 0.15.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 (81) hide show
  1. package/package.json +8 -9
  2. package/src/Commander.test.ts +507 -245
  3. package/src/ContentNegotiation.test.ts +500 -0
  4. package/src/ContentNegotiation.ts +535 -0
  5. package/src/FileRouter.ts +16 -12
  6. package/src/{FileRouterCodegen.test.ts → FileRouterCodegen.todo.ts} +384 -219
  7. package/src/FileRouterCodegen.ts +6 -6
  8. package/src/FileRouterPattern.test.ts +93 -62
  9. package/src/FileRouter_files.test.ts +5 -5
  10. package/src/FileRouter_path.test.ts +121 -69
  11. package/src/FileRouter_tree.test.ts +62 -56
  12. package/src/FileSystemExtra.test.ts +46 -30
  13. package/src/Http.test.ts +24 -0
  14. package/src/Http.ts +25 -0
  15. package/src/HttpAppExtra.test.ts +39 -20
  16. package/src/HttpAppExtra.ts +0 -1
  17. package/src/HttpUtils.test.ts +35 -18
  18. package/src/HttpUtils.ts +2 -0
  19. package/src/PathPattern.test.ts +648 -0
  20. package/src/PathPattern.ts +483 -0
  21. package/src/Route.ts +258 -1073
  22. package/src/RouteBody.test.ts +182 -0
  23. package/src/RouteBody.ts +106 -0
  24. package/src/RouteHook.test.ts +40 -0
  25. package/src/RouteHook.ts +105 -0
  26. package/src/RouteHttp.test.ts +443 -0
  27. package/src/RouteHttp.ts +219 -0
  28. package/src/RouteMount.test.ts +468 -0
  29. package/src/RouteMount.ts +313 -0
  30. package/src/RouteSchema.test.ts +81 -0
  31. package/src/RouteSchema.ts +44 -0
  32. package/src/RouteTree.test.ts +346 -0
  33. package/src/RouteTree.ts +165 -0
  34. package/src/RouteTrie.test.ts +322 -0
  35. package/src/RouteTrie.ts +224 -0
  36. package/src/RouterPattern.test.ts +569 -548
  37. package/src/RouterPattern.ts +7 -7
  38. package/src/Start.ts +3 -3
  39. package/src/TuplePathPattern.ts +64 -0
  40. package/src/Values.ts +16 -0
  41. package/src/bun/BunBundle.test.ts +36 -42
  42. package/src/bun/BunBundle.ts +2 -2
  43. package/src/bun/BunBundle_imports.test.ts +4 -6
  44. package/src/bun/BunHttpServer.test.ts +183 -6
  45. package/src/bun/BunHttpServer.ts +56 -32
  46. package/src/bun/BunHttpServer_web.ts +18 -6
  47. package/src/bun/BunImportTrackerPlugin.test.ts +3 -3
  48. package/src/bun/BunRoute.ts +29 -210
  49. package/src/{BundleHttp.test.ts → bundler/BundleHttp.test.ts} +34 -60
  50. package/src/{BundleHttp.ts → bundler/BundleHttp.ts} +1 -2
  51. package/src/client/index.ts +1 -1
  52. package/src/{Effect_HttpRouter.test.ts → effect/HttpRouter.test.ts} +69 -90
  53. package/src/experimental/EncryptedCookies.test.ts +125 -64
  54. package/src/experimental/SseHttpResponse.ts +0 -1
  55. package/src/hyper/Hyper.ts +89 -0
  56. package/src/{HyperHtml.test.ts → hyper/HyperHtml.test.ts} +13 -13
  57. package/src/{HyperHtml.ts → hyper/HyperHtml.ts} +2 -2
  58. package/src/{jsx.d.ts → hyper/jsx.d.ts} +1 -1
  59. package/src/index.ts +2 -4
  60. package/src/middlewares/BasicAuthMiddleware.test.ts +29 -19
  61. package/src/{NodeFileSystem.ts → node/FileSystem.ts} +6 -2
  62. package/src/testing/TestHttpClient.test.ts +26 -26
  63. package/src/testing/TestLogger.test.ts +27 -11
  64. package/src/x/datastar/Datastar.test.ts +47 -48
  65. package/src/x/datastar/Datastar.ts +1 -1
  66. package/src/x/tailwind/TailwindPlugin.test.ts +56 -58
  67. package/src/x/tailwind/plugin.ts +1 -1
  68. package/src/FileHttpRouter.test.ts +0 -239
  69. package/src/FileHttpRouter.ts +0 -194
  70. package/src/Hyper.ts +0 -194
  71. package/src/Route.test.ts +0 -1370
  72. package/src/RouteRender.ts +0 -40
  73. package/src/Router.test.ts +0 -375
  74. package/src/Router.ts +0 -255
  75. package/src/bun/BunRoute.test.ts +0 -480
  76. package/src/bun/BunRoute_bundles.test.ts +0 -219
  77. /package/src/{Bundle.ts → bundler/Bundle.ts} +0 -0
  78. /package/src/{BundleFiles.ts → bundler/BundleFiles.ts} +0 -0
  79. /package/src/{HyperNode.ts → hyper/HyperNode.ts} +0 -0
  80. /package/src/{jsx-runtime.ts → hyper/jsx-runtime.ts} +0 -0
  81. /package/src/{NodeUtils.ts → node/Utils.ts} +0 -0
@@ -0,0 +1,443 @@
1
+ import * as test from "bun:test"
2
+ import * as Effect from "effect/Effect"
3
+ import * as Route from "./Route.ts"
4
+ import * as RouteHttp from "./RouteHttp.ts"
5
+ import * as RouteTree from "./RouteTree.ts"
6
+
7
+ test.it("converts string to text/plain for Route.text", async () => {
8
+ const handler = RouteHttp.toWebHandler(
9
+ Route.get(Route.text("Hello World")),
10
+ )
11
+ const response = await RouteHttp.fetch(handler, { path: "/text" })
12
+
13
+ test
14
+ .expect(response.status)
15
+ .toBe(200)
16
+ test
17
+ .expect(response.headers.get("Content-Type"))
18
+ .toBe("text/plain; charset=utf-8")
19
+ test
20
+ .expect(await response.text())
21
+ .toBe("Hello World")
22
+ })
23
+
24
+ test.it("converts string to text/html for Route.html", async () => {
25
+ const handler = RouteHttp.toWebHandler(
26
+ Route.get(Route.html("<h1>Hello</h1>")),
27
+ )
28
+ const response = await RouteHttp.fetch(handler, { path: "/html" })
29
+
30
+ test
31
+ .expect(response.status)
32
+ .toBe(200)
33
+ test
34
+ .expect(response.headers.get("Content-Type"))
35
+ .toBe("text/html; charset=utf-8")
36
+ test
37
+ .expect(await response.text())
38
+ .toBe("<h1>Hello</h1>")
39
+ })
40
+
41
+ test.it("converts object to JSON for Route.json", async () => {
42
+ const handler = RouteHttp.toWebHandler(
43
+ Route.get(Route.json({ message: "hello", count: 42 })),
44
+ )
45
+ const response = await RouteHttp.fetch(handler, { path: "/json" })
46
+
47
+ test
48
+ .expect(response.status)
49
+ .toBe(200)
50
+ test
51
+ .expect(response.headers.get("Content-Type"))
52
+ .toBe("application/json")
53
+ test
54
+ .expect(await response.json())
55
+ .toEqual({ message: "hello", count: 42 })
56
+ })
57
+
58
+ test.it("converts array to JSON for Route.json", async () => {
59
+ const handler = RouteHttp.toWebHandler(
60
+ Route.get(Route.json([1, 2, 3])),
61
+ )
62
+ const response = await RouteHttp.fetch(handler, { path: "/array" })
63
+
64
+ test
65
+ .expect(response.status)
66
+ .toBe(200)
67
+ test
68
+ .expect(response.headers.get("Content-Type"))
69
+ .toBe("application/json")
70
+ test
71
+ .expect(await response.json())
72
+ .toEqual([1, 2, 3])
73
+ })
74
+
75
+ test.it("provides request in context", async () => {
76
+ let capturedRequest: Request | undefined
77
+
78
+ const handler = RouteHttp.toWebHandler(
79
+ Route.get(Route.text(function*(ctx: any) {
80
+ capturedRequest = ctx.request
81
+ return "ok"
82
+ })),
83
+ )
84
+ await RouteHttp.fetch(handler, { path: "/test?foo=bar" })
85
+
86
+ test
87
+ .expect(capturedRequest)
88
+ .toBeInstanceOf(Request)
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 RouteHttp.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 RouteHttp.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", async () => {
116
+ const handler = RouteHttp.toWebHandler(
117
+ Route.get(Route.text(function*(): Generator<any, string, any> {
118
+ return yield* Effect.fail(new Error("Something went wrong"))
119
+ })),
120
+ )
121
+ const response = await RouteHttp.fetch(handler, { path: "/error" })
122
+
123
+ test
124
+ .expect(response.status)
125
+ .toBe(500)
126
+
127
+ const text = await response.text()
128
+ test
129
+ .expect(text)
130
+ .toContain("Something went wrong")
131
+ })
132
+
133
+ test.it("handles defects by returning 500 response", async () => {
134
+ const handler = RouteHttp.toWebHandler(
135
+ Route.get(Route.text(function*(): Generator<any, string, any> {
136
+ return yield* Effect.die("Unexpected error")
137
+ })),
138
+ )
139
+ const response = await RouteHttp.fetch(handler, { path: "/defect" })
140
+
141
+ test
142
+ .expect(response.status)
143
+ .toBe(500)
144
+ })
145
+
146
+ test.it("includes descriptor properties in handler context", async () => {
147
+ let capturedMethod: string | undefined
148
+
149
+ const handler = RouteHttp.toWebHandler(
150
+ Route.get(Route.text(function*(ctx: any) {
151
+ capturedMethod = ctx.method
152
+ return "ok"
153
+ })),
154
+ )
155
+ await RouteHttp.fetch(handler, { path: "/test" })
156
+
157
+ test
158
+ .expect(capturedMethod)
159
+ .toBe("GET")
160
+ })
161
+
162
+ test.it("returns 405 for wrong method", async () => {
163
+ const handler = RouteHttp.toWebHandler(
164
+ Route.get(Route.text("users")),
165
+ )
166
+ const response = await RouteHttp.fetch(handler, {
167
+ path: "/users",
168
+ method: "POST",
169
+ })
170
+
171
+ test
172
+ .expect(response.status)
173
+ .toBe(405)
174
+ })
175
+
176
+ test.it("supports POST method", async () => {
177
+ const handler = RouteHttp.toWebHandler(
178
+ Route.post(Route.text("created")),
179
+ )
180
+ const response = await RouteHttp.fetch(handler, {
181
+ path: "/users",
182
+ method: "POST",
183
+ })
184
+
185
+ test
186
+ .expect(response.status)
187
+ .toBe(200)
188
+ test
189
+ .expect(await response.text())
190
+ .toBe("created")
191
+ })
192
+
193
+ test.it("selects json when Accept prefers application/json", async () => {
194
+ const handler = RouteHttp.toWebHandler(
195
+ Route
196
+ .get(Route.json({ type: "json" }))
197
+ .get(Route.html("<div>html</div>")),
198
+ )
199
+ const response = await RouteHttp.fetch(handler, {
200
+ path: "/data",
201
+ headers: { Accept: "application/json" },
202
+ })
203
+
204
+ test
205
+ .expect(response.headers.get("Content-Type"))
206
+ .toBe("application/json")
207
+ test
208
+ .expect(await response.json())
209
+ .toEqual({ type: "json" })
210
+ })
211
+
212
+ test.it("selects html when Accept prefers text/html", async () => {
213
+ const handler = RouteHttp.toWebHandler(
214
+ Route
215
+ .get(Route.json({ type: "json" }))
216
+ .get(Route.html("<div>html</div>")),
217
+ )
218
+ const response = await RouteHttp.fetch(handler, {
219
+ path: "/data",
220
+ headers: { Accept: "text/html" },
221
+ })
222
+
223
+ test
224
+ .expect(response.headers.get("Content-Type"))
225
+ .toBe("text/html; charset=utf-8")
226
+ test
227
+ .expect(await response.text())
228
+ .toBe("<div>html</div>")
229
+ })
230
+
231
+ test.it("selects text/plain when Accept prefers it", async () => {
232
+ const handler = RouteHttp.toWebHandler(
233
+ Route
234
+ .get(Route.text("plain text"))
235
+ .get(Route.json({ type: "json" })),
236
+ )
237
+ const response = await RouteHttp.fetch(handler, {
238
+ path: "/data",
239
+ headers: { Accept: "text/plain" },
240
+ })
241
+
242
+ test
243
+ .expect(response.headers.get("Content-Type"))
244
+ .toBe("text/plain; charset=utf-8")
245
+ test
246
+ .expect(await response.text())
247
+ .toBe("plain text")
248
+ })
249
+
250
+ test.it("returns first candidate when no Accept header", 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 RouteHttp.fetch(handler, { path: "/data" })
257
+
258
+ test
259
+ .expect(response.headers.get("Content-Type"))
260
+ .toBe("application/json")
261
+ })
262
+
263
+ test.it("handles Accept with quality values", async () => {
264
+ const handler = RouteHttp.toWebHandler(
265
+ Route
266
+ .get(Route.json({ type: "json" }))
267
+ .get(Route.html("<div>html</div>")),
268
+ )
269
+ const response = await RouteHttp.fetch(handler, {
270
+ path: "/data",
271
+ headers: { Accept: "text/html;q=0.9, application/json;q=1.0" },
272
+ })
273
+
274
+ test
275
+ .expect(response.headers.get("Content-Type"))
276
+ .toBe("application/json")
277
+ })
278
+
279
+ test.it("handles Accept: */*", async () => {
280
+ const handler = RouteHttp.toWebHandler(
281
+ Route
282
+ .get(Route.json({ type: "json" }))
283
+ .get(Route.html("<div>html</div>")),
284
+ )
285
+ const response = await RouteHttp.fetch(handler, {
286
+ path: "/data",
287
+ headers: { Accept: "*/*" },
288
+ })
289
+
290
+ test
291
+ .expect(response.headers.get("Content-Type"))
292
+ .toBe("application/json")
293
+ })
294
+
295
+ test.it("returns 406 when Accept doesn't match available formats", async () => {
296
+ const handler = RouteHttp.toWebHandler(
297
+ Route.get(Route.json({ type: "json" })),
298
+ )
299
+ const response = await RouteHttp.fetch(handler, {
300
+ path: "/data",
301
+ headers: { Accept: "text/html" },
302
+ })
303
+
304
+ test
305
+ .expect(response.status)
306
+ .toBe(406)
307
+ test
308
+ .expect(await response.text())
309
+ .toBe("Not Acceptable")
310
+ })
311
+
312
+ test.it("returns 406 when Accept doesn't match any of multiple formats", async () => {
313
+ const handler = RouteHttp.toWebHandler(
314
+ Route
315
+ .get(Route.json({ type: "json" }))
316
+ .get(Route.html("<div>html</div>")),
317
+ )
318
+ const response = await RouteHttp.fetch(handler, {
319
+ path: "/data",
320
+ headers: { Accept: "image/png" },
321
+ })
322
+
323
+ test
324
+ .expect(response.status)
325
+ .toBe(406)
326
+ })
327
+
328
+ test.it("prefers json over text when no Accept header", async () => {
329
+ const handler = RouteHttp.toWebHandler(
330
+ Route
331
+ .get(Route.text("plain"))
332
+ .get(Route.json({ type: "json" })),
333
+ )
334
+ const response = await RouteHttp.fetch(handler, { path: "/data" })
335
+
336
+ test
337
+ .expect(response.headers.get("Content-Type"))
338
+ .toBe("application/json")
339
+ })
340
+
341
+ test.it("prefers text over html when no Accept header and no json", async () => {
342
+ const handler = RouteHttp.toWebHandler(
343
+ Route
344
+ .get(Route.html("<div>html</div>"))
345
+ .get(Route.text("plain")),
346
+ )
347
+ const response = await RouteHttp.fetch(handler, { path: "/data" })
348
+
349
+ test
350
+ .expect(response.headers.get("Content-Type"))
351
+ .toBe("text/plain; charset=utf-8")
352
+ })
353
+
354
+ test.it("falls back to html when no Accept header and no json or text", async () => {
355
+ const handler = RouteHttp.toWebHandler(
356
+ Route.get(Route.html("<div>html</div>")),
357
+ )
358
+ const response = await RouteHttp.fetch(handler, { path: "/data" })
359
+
360
+ test
361
+ .expect(response.headers.get("Content-Type"))
362
+ .toBe("text/html; charset=utf-8")
363
+ })
364
+
365
+ test.describe("walkHandles", () => {
366
+ test.it("yields handlers for static routes", () => {
367
+ const tree = RouteTree.make({
368
+ "/users": Route.get(Route.text("users list")),
369
+ "/admin": Route.get(Route.text("admin")),
370
+ })
371
+
372
+ const handles = Object.fromEntries(RouteHttp.walkHandles(tree))
373
+
374
+ test
375
+ .expect("/users" in handles)
376
+ .toBe(true)
377
+ test
378
+ .expect("/admin" in handles)
379
+ .toBe(true)
380
+ })
381
+
382
+ test.it("yields handlers for parameterized routes", () => {
383
+ const tree = RouteTree.make({
384
+ "/users/:id": Route.get(Route.text("user detail")),
385
+ })
386
+
387
+ const handles = Object.fromEntries(RouteHttp.walkHandles(tree))
388
+
389
+ test
390
+ .expect("/users/:id" in handles)
391
+ .toBe(true)
392
+ })
393
+
394
+ test.it("preserves optional param syntax", () => {
395
+ const tree = RouteTree.make({
396
+ "/files/:name?": Route.get(Route.text("files")),
397
+ })
398
+
399
+ const handles = Object.fromEntries(RouteHttp.walkHandles(tree))
400
+
401
+ test
402
+ .expect("/files/:name?" in handles)
403
+ .toBe(true)
404
+ })
405
+
406
+ test.it("preserves wildcard param syntax", () => {
407
+ const tree = RouteTree.make({
408
+ "/docs/:path*": Route.get(Route.text("docs")),
409
+ })
410
+
411
+ const handles = Object.fromEntries(RouteHttp.walkHandles(tree))
412
+
413
+ test
414
+ .expect("/docs/:path*" in handles)
415
+ .toBe(true)
416
+ })
417
+ })
418
+
419
+ test.describe("toWebHandler type constraints", () => {
420
+ test.it("accepts routes with method", () => {
421
+ RouteHttp.toWebHandler(Route.get(Route.text("hello")))
422
+ })
423
+
424
+ test.it("accepts multiple routes with methods", () => {
425
+ RouteHttp.toWebHandler(
426
+ Route.get(Route.text("hello")).post(Route.text("world")),
427
+ )
428
+ })
429
+
430
+ test.it("rejects routes without method", () => {
431
+ const noMethod = Route.empty.pipe(Route.text("hello"))
432
+ // @ts-expect-error
433
+ RouteHttp.toWebHandler(noMethod)
434
+ })
435
+
436
+ test.it("rejects mixed routes where one has method and one doesn't", () => {
437
+ const withMethod = Route.get(Route.text("hello"))
438
+ const withoutMethod = Route.empty.pipe(Route.text("hello"))
439
+ const mixed = [...withMethod, ...withoutMethod] as const
440
+ // @ts-expect-error
441
+ RouteHttp.toWebHandler(mixed)
442
+ })
443
+ })
@@ -0,0 +1,219 @@
1
+ import * as Array from "effect/Array"
2
+ import * as Cause from "effect/Cause"
3
+ import * as Effect from "effect/Effect"
4
+ import * as Runtime from "effect/Runtime"
5
+ import * as ContentNegotiation from "./ContentNegotiation.ts"
6
+ import * as Http from "./Http.ts"
7
+ import * as Route from "./Route.ts"
8
+ import * as RouteBody from "./RouteBody.ts"
9
+ import * as RouteMount from "./RouteMount.ts"
10
+ import * as RouteTree from "./RouteTree.ts"
11
+
12
+ type UnboundedRouteWithMethod = Route.Route.With<{
13
+ method: RouteMount.RouteMount.Method
14
+ format?: RouteBody.Format
15
+ }>
16
+
17
+ const formatToMediaType = {
18
+ text: "text/plain",
19
+ html: "text/html",
20
+ json: "application/json",
21
+ bytes: "application/octet-stream",
22
+ } as const
23
+
24
+ const formatToContentType = {
25
+ text: "text/plain; charset=utf-8",
26
+ html: "text/html; charset=utf-8",
27
+ json: "application/json",
28
+ bytes: "application/octet-stream",
29
+ } as const
30
+
31
+ function toResponse(result: unknown, format?: string): Response {
32
+ if (result instanceof Response) {
33
+ return result
34
+ }
35
+
36
+ const contentType = format && format in formatToContentType
37
+ ? formatToContentType[format]
38
+ : typeof result === "string"
39
+ ? "text/html; charset=utf-8"
40
+ : "application/json"
41
+
42
+ const body = contentType === "application/json"
43
+ ? JSON.stringify(result)
44
+ : result
45
+
46
+ return new Response(body as BodyInit, {
47
+ headers: { "Content-Type": contentType },
48
+ })
49
+ }
50
+
51
+ function getMediaType(format: string | undefined): string | undefined {
52
+ return format && format in formatToMediaType
53
+ ? formatToMediaType[format]
54
+ : undefined
55
+ }
56
+
57
+ const defaultFormatPriority = ["json", "text", "html", "bytes"] as const
58
+
59
+ function negotiateRoute(
60
+ routes: UnboundedRouteWithMethod[],
61
+ accept: string | null,
62
+ ): UnboundedRouteWithMethod | undefined {
63
+ if (routes.length === 1) {
64
+ if (!accept) return routes[0]
65
+ const format = Route.descriptor(routes[0]).format
66
+ const mediaType = getMediaType(format)
67
+ if (!mediaType) return routes[0]
68
+ const matched = ContentNegotiation.media(accept, [mediaType])
69
+ return matched.length > 0 ? routes[0] : undefined
70
+ }
71
+
72
+ const formatMap = new Map<string, UnboundedRouteWithMethod>()
73
+ const available: string[] = []
74
+ const mediaTypeMap = new Map<string, UnboundedRouteWithMethod>()
75
+
76
+ for (const route of routes) {
77
+ const format = Route.descriptor(route).format
78
+ if (format && !formatMap.has(format)) {
79
+ formatMap.set(format, route)
80
+ }
81
+ const mediaType = getMediaType(format)
82
+ if (format && mediaType && !mediaTypeMap.has(mediaType)) {
83
+ available.push(mediaType)
84
+ mediaTypeMap.set(mediaType, route)
85
+ }
86
+ }
87
+
88
+ if (!accept) {
89
+ for (const format of defaultFormatPriority) {
90
+ const route = formatMap.get(format)
91
+ if (route) return route
92
+ }
93
+ return routes[0]
94
+ }
95
+
96
+ if (available.length === 0) {
97
+ return routes[0]
98
+ }
99
+
100
+ const preferred = ContentNegotiation.media(accept, available)
101
+ if (preferred.length > 0) {
102
+ const best = mediaTypeMap.get(preferred[0])
103
+ if (best) {
104
+ return best
105
+ }
106
+ }
107
+
108
+ return undefined
109
+ }
110
+
111
+ export const toWebHandlerRuntime = <R>(
112
+ runtime: Runtime.Runtime<R>,
113
+ ) => {
114
+ const run = Runtime.runPromise(runtime)
115
+
116
+ return (
117
+ routes: Iterable<
118
+ UnboundedRouteWithMethod
119
+ >,
120
+ ): Http.WebHandler => {
121
+ const grouped = Array.groupBy(
122
+ Array.fromIterable(routes),
123
+ (route) => Route.descriptor(route).method?.toUpperCase() ?? "*",
124
+ )
125
+ const wildcards = grouped["*"] ?? []
126
+ const methodGroups: {
127
+ [method in Http.Method]?: UnboundedRouteWithMethod[]
128
+ } = {
129
+ GET: undefined,
130
+ POST: undefined,
131
+ PUT: undefined,
132
+ PATCH: undefined,
133
+ DELETE: undefined,
134
+ HEAD: undefined,
135
+ OPTIONS: undefined,
136
+ }
137
+
138
+ for (const method in grouped) {
139
+ if (method !== "*") {
140
+ methodGroups[method] = [...wildcards, ...grouped[method]]
141
+ }
142
+ }
143
+
144
+ return (request) => {
145
+ const method = request.method.toUpperCase()
146
+ const accept = request.headers.get("accept")
147
+ const group = methodGroups[method]
148
+
149
+ if (!group || group.length === 0) {
150
+ return Promise.resolve(
151
+ new Response("Method Not Allowed", { status: 405 }),
152
+ )
153
+ }
154
+
155
+ const route = negotiateRoute(group, accept)
156
+ if (!route) {
157
+ return Promise.resolve(
158
+ new Response("Not Acceptable", { status: 406 }),
159
+ )
160
+ }
161
+ const descriptor = Route.descriptor(route)
162
+
163
+ const context = {
164
+ ...descriptor,
165
+ request,
166
+ }
167
+
168
+ const effect = route.handler(
169
+ context as any,
170
+ () => Effect.succeed(undefined),
171
+ )
172
+
173
+ return run(
174
+ effect.pipe(
175
+ Effect.map((result) => toResponse(result, descriptor.format)),
176
+ Effect.catchAllCause((cause) =>
177
+ Effect.succeed(
178
+ new Response(Cause.pretty(cause), { status: 500 }),
179
+ )
180
+ ),
181
+ ),
182
+ )
183
+ }
184
+ }
185
+ }
186
+
187
+ export const toWebHandler: (
188
+ routes: Iterable<UnboundedRouteWithMethod>,
189
+ ) => Http.WebHandler = toWebHandlerRuntime(Runtime.defaultRuntime)
190
+
191
+ export function* walkHandles(
192
+ tree: RouteTree.RouteTree,
193
+ ): Generator<[path: string, handler: Http.WebHandler]> {
194
+ const pathGroups = new Map<
195
+ string,
196
+ Array<Route.Route.With<{ path: string; method: string }>>
197
+ >()
198
+
199
+ for (const route of RouteTree.walk(tree)) {
200
+ const descriptor = Route.descriptor(route)
201
+ const path = descriptor.path
202
+ const routes = pathGroups.get(path) ?? []
203
+ routes.push(route)
204
+ pathGroups.set(path, routes)
205
+ }
206
+
207
+ for (const [path, routes] of pathGroups) {
208
+ yield [path, toWebHandler(routes)]
209
+ }
210
+ }
211
+
212
+ export function fetch(
213
+ handle: Http.WebHandler,
214
+ init: RequestInit & ({ url: string } | { path: string }),
215
+ ): Promise<Response> {
216
+ const url = "path" in init ? `http://localhost${init.path}` : init.url
217
+ const request = new Request(url, init)
218
+ return Promise.resolve(handle(request))
219
+ }