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.
- package/package.json +8 -9
- package/src/Commander.test.ts +507 -245
- package/src/ContentNegotiation.test.ts +500 -0
- package/src/ContentNegotiation.ts +535 -0
- package/src/FileRouter.ts +16 -12
- package/src/{FileRouterCodegen.test.ts → FileRouterCodegen.todo.ts} +384 -219
- package/src/FileRouterCodegen.ts +6 -6
- package/src/FileRouterPattern.test.ts +93 -62
- package/src/FileRouter_files.test.ts +5 -5
- package/src/FileRouter_path.test.ts +121 -69
- package/src/FileRouter_tree.test.ts +62 -56
- package/src/FileSystemExtra.test.ts +46 -30
- package/src/Http.test.ts +24 -0
- package/src/Http.ts +25 -0
- package/src/HttpAppExtra.test.ts +39 -20
- package/src/HttpAppExtra.ts +0 -1
- package/src/HttpUtils.test.ts +35 -18
- package/src/HttpUtils.ts +2 -0
- package/src/PathPattern.test.ts +648 -0
- package/src/PathPattern.ts +483 -0
- package/src/Route.ts +258 -1073
- package/src/RouteBody.test.ts +182 -0
- package/src/RouteBody.ts +106 -0
- package/src/RouteHook.test.ts +40 -0
- package/src/RouteHook.ts +105 -0
- package/src/RouteHttp.test.ts +443 -0
- package/src/RouteHttp.ts +219 -0
- package/src/RouteMount.test.ts +468 -0
- package/src/RouteMount.ts +313 -0
- package/src/RouteSchema.test.ts +81 -0
- package/src/RouteSchema.ts +44 -0
- package/src/RouteTree.test.ts +346 -0
- package/src/RouteTree.ts +165 -0
- package/src/RouteTrie.test.ts +322 -0
- package/src/RouteTrie.ts +224 -0
- package/src/RouterPattern.test.ts +569 -548
- package/src/RouterPattern.ts +7 -7
- package/src/Start.ts +3 -3
- package/src/TuplePathPattern.ts +64 -0
- package/src/Values.ts +16 -0
- package/src/bun/BunBundle.test.ts +36 -42
- package/src/bun/BunBundle.ts +2 -2
- package/src/bun/BunBundle_imports.test.ts +4 -6
- package/src/bun/BunHttpServer.test.ts +183 -6
- package/src/bun/BunHttpServer.ts +56 -32
- package/src/bun/BunHttpServer_web.ts +18 -6
- package/src/bun/BunImportTrackerPlugin.test.ts +3 -3
- package/src/bun/BunRoute.ts +29 -210
- package/src/{BundleHttp.test.ts → bundler/BundleHttp.test.ts} +34 -60
- package/src/{BundleHttp.ts → bundler/BundleHttp.ts} +1 -2
- package/src/client/index.ts +1 -1
- package/src/{Effect_HttpRouter.test.ts → effect/HttpRouter.test.ts} +69 -90
- package/src/experimental/EncryptedCookies.test.ts +125 -64
- package/src/experimental/SseHttpResponse.ts +0 -1
- package/src/hyper/Hyper.ts +89 -0
- package/src/{HyperHtml.test.ts → hyper/HyperHtml.test.ts} +13 -13
- package/src/{HyperHtml.ts → hyper/HyperHtml.ts} +2 -2
- package/src/{jsx.d.ts → hyper/jsx.d.ts} +1 -1
- package/src/index.ts +2 -4
- package/src/middlewares/BasicAuthMiddleware.test.ts +29 -19
- package/src/{NodeFileSystem.ts → node/FileSystem.ts} +6 -2
- package/src/testing/TestHttpClient.test.ts +26 -26
- package/src/testing/TestLogger.test.ts +27 -11
- package/src/x/datastar/Datastar.test.ts +47 -48
- package/src/x/datastar/Datastar.ts +1 -1
- package/src/x/tailwind/TailwindPlugin.test.ts +56 -58
- package/src/x/tailwind/plugin.ts +1 -1
- package/src/FileHttpRouter.test.ts +0 -239
- package/src/FileHttpRouter.ts +0 -194
- package/src/Hyper.ts +0 -194
- package/src/Route.test.ts +0 -1370
- package/src/RouteRender.ts +0 -40
- package/src/Router.test.ts +0 -375
- package/src/Router.ts +0 -255
- package/src/bun/BunRoute.test.ts +0 -480
- package/src/bun/BunRoute_bundles.test.ts +0 -219
- /package/src/{Bundle.ts → bundler/Bundle.ts} +0 -0
- /package/src/{BundleFiles.ts → bundler/BundleFiles.ts} +0 -0
- /package/src/{HyperNode.ts → hyper/HyperNode.ts} +0 -0
- /package/src/{jsx-runtime.ts → hyper/jsx-runtime.ts} +0 -0
- /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
|
+
})
|
package/src/RouteHttp.ts
ADDED
|
@@ -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
|
+
}
|