effect-start 0.14.0 → 0.16.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 (87) hide show
  1. package/package.json +8 -9
  2. package/src/Commander.test.ts +507 -245
  3. package/src/ContentNegotiation.test.ts +603 -0
  4. package/src/ContentNegotiation.ts +542 -0
  5. package/src/Entity.test.ts +592 -0
  6. package/src/Entity.ts +362 -0
  7. package/src/FileRouter.ts +16 -12
  8. package/src/{FileRouterCodegen.test.ts → FileRouterCodegen.todo.ts} +384 -219
  9. package/src/FileRouterCodegen.ts +6 -6
  10. package/src/FileRouterPattern.test.ts +93 -62
  11. package/src/FileRouter_files.test.ts +5 -5
  12. package/src/FileRouter_path.test.ts +121 -69
  13. package/src/FileRouter_tree.test.ts +62 -56
  14. package/src/FileSystemExtra.test.ts +46 -30
  15. package/src/Http.test.ts +319 -0
  16. package/src/Http.ts +167 -0
  17. package/src/HttpAppExtra.test.ts +39 -20
  18. package/src/HttpAppExtra.ts +0 -1
  19. package/src/HttpUtils.test.ts +35 -18
  20. package/src/HttpUtils.ts +2 -0
  21. package/src/PathPattern.test.ts +648 -0
  22. package/src/PathPattern.ts +485 -0
  23. package/src/Route.ts +266 -1069
  24. package/src/RouteBody.test.ts +234 -0
  25. package/src/RouteBody.ts +193 -0
  26. package/src/RouteHook.test.ts +40 -0
  27. package/src/RouteHook.ts +106 -0
  28. package/src/RouteHttp.test.ts +2906 -0
  29. package/src/RouteHttp.ts +427 -0
  30. package/src/RouteHttpTracer.ts +92 -0
  31. package/src/RouteMount.test.ts +481 -0
  32. package/src/RouteMount.ts +470 -0
  33. package/src/RouteSchema.test.ts +427 -0
  34. package/src/RouteSchema.ts +423 -0
  35. package/src/RouteTree.test.ts +494 -0
  36. package/src/RouteTree.ts +219 -0
  37. package/src/RouteTrie.test.ts +322 -0
  38. package/src/RouteTrie.ts +224 -0
  39. package/src/RouterPattern.test.ts +569 -548
  40. package/src/RouterPattern.ts +7 -7
  41. package/src/Start.ts +3 -3
  42. package/src/StreamExtra.ts +21 -1
  43. package/src/TuplePathPattern.ts +64 -0
  44. package/src/Values.test.ts +263 -0
  45. package/src/Values.ts +76 -0
  46. package/src/bun/BunBundle.test.ts +36 -42
  47. package/src/bun/BunBundle.ts +2 -2
  48. package/src/bun/BunBundle_imports.test.ts +4 -6
  49. package/src/bun/BunHttpServer.test.ts +183 -6
  50. package/src/bun/BunHttpServer.ts +72 -32
  51. package/src/bun/BunHttpServer_web.ts +18 -6
  52. package/src/bun/BunImportTrackerPlugin.test.ts +3 -3
  53. package/src/bun/BunRoute.test.ts +124 -442
  54. package/src/bun/BunRoute.ts +146 -286
  55. package/src/{BundleHttp.test.ts → bundler/BundleHttp.test.ts} +34 -60
  56. package/src/{BundleHttp.ts → bundler/BundleHttp.ts} +1 -2
  57. package/src/client/index.ts +1 -1
  58. package/src/{Effect_HttpRouter.test.ts → effect/HttpRouter.test.ts} +69 -90
  59. package/src/experimental/EncryptedCookies.test.ts +125 -64
  60. package/src/experimental/SseHttpResponse.ts +0 -1
  61. package/src/hyper/Hyper.ts +89 -0
  62. package/src/{HyperHtml.test.ts → hyper/HyperHtml.test.ts} +13 -13
  63. package/src/{HyperHtml.ts → hyper/HyperHtml.ts} +2 -2
  64. package/src/{jsx.d.ts → hyper/jsx.d.ts} +1 -1
  65. package/src/index.ts +3 -4
  66. package/src/middlewares/BasicAuthMiddleware.test.ts +29 -19
  67. package/src/{NodeFileSystem.ts → node/FileSystem.ts} +6 -2
  68. package/src/testing/TestHttpClient.test.ts +26 -26
  69. package/src/testing/TestLogger.test.ts +27 -14
  70. package/src/testing/TestLogger.ts +15 -9
  71. package/src/x/datastar/Datastar.test.ts +47 -48
  72. package/src/x/datastar/Datastar.ts +1 -1
  73. package/src/x/tailwind/TailwindPlugin.test.ts +56 -58
  74. package/src/x/tailwind/plugin.ts +1 -1
  75. package/src/FileHttpRouter.test.ts +0 -239
  76. package/src/FileHttpRouter.ts +0 -194
  77. package/src/Hyper.ts +0 -194
  78. package/src/Route.test.ts +0 -1370
  79. package/src/RouteRender.ts +0 -40
  80. package/src/Router.test.ts +0 -375
  81. package/src/Router.ts +0 -255
  82. package/src/bun/BunRoute_bundles.test.ts +0 -219
  83. /package/src/{Bundle.ts → bundler/Bundle.ts} +0 -0
  84. /package/src/{BundleFiles.ts → bundler/BundleFiles.ts} +0 -0
  85. /package/src/{HyperNode.ts → hyper/HyperNode.ts} +0 -0
  86. /package/src/{jsx-runtime.ts → hyper/jsx-runtime.ts} +0 -0
  87. /package/src/{NodeUtils.ts → node/Utils.ts} +0 -0
@@ -1,5 +1,5 @@
1
1
  import * as FileSystem from "@effect/platform/FileSystem"
2
- import * as t from "bun:test"
2
+ import * as test from "bun:test"
3
3
  import { MemoryFileSystem } from "effect-memfs"
4
4
  import * as Chunk from "effect/Chunk"
5
5
  import * as Effect from "effect/Effect"
@@ -8,8 +8,8 @@ import * as Function from "effect/Function"
8
8
  import * as Stream from "effect/Stream"
9
9
  import * as FileSystemExtra from "./FileSystemExtra.ts"
10
10
 
11
- t.describe(`${FileSystemExtra.watchSource.name}`, () => {
12
- t.it("emits events for file creation", () =>
11
+ test.describe(`${FileSystemExtra.watchSource.name}`, () => {
12
+ test.it("emits events for file creation", () =>
13
13
  Effect
14
14
  .gen(function*() {
15
15
  const fs = yield* FileSystem.FileSystem
@@ -28,11 +28,19 @@ t.describe(`${FileSystemExtra.watchSource.name}`, () => {
28
28
 
29
29
  const events = yield* Fiber.join(fiber)
30
30
 
31
- t.expect(Chunk.size(events)).toBeGreaterThan(0)
31
+ test
32
+ .expect(Chunk.size(events))
33
+ .toBeGreaterThan(0)
32
34
  const first = Chunk.unsafeGet(events, 0)
33
- t.expect(first.path).toContain("test.ts")
34
- t.expect(["rename", "change"]).toContain(first.eventType)
35
- t.expect(first.filename).toBe("test.ts")
35
+ test
36
+ .expect(first.path)
37
+ .toContain("test.ts")
38
+ test
39
+ .expect(["rename", "change"])
40
+ .toContain(first.eventType)
41
+ test
42
+ .expect(first.filename)
43
+ .toBe("test.ts")
36
44
  })
37
45
  .pipe(
38
46
  Effect.scoped,
@@ -42,7 +50,7 @@ t.describe(`${FileSystemExtra.watchSource.name}`, () => {
42
50
  Effect.runPromise,
43
51
  ))
44
52
 
45
- t.it(
53
+ test.it(
46
54
  "emits change event for file modification",
47
55
  () =>
48
56
  Effect
@@ -63,8 +71,12 @@ t.describe(`${FileSystemExtra.watchSource.name}`, () => {
63
71
 
64
72
  const events = yield* Fiber.join(fiber)
65
73
 
66
- t.expect(Chunk.size(events)).toBeGreaterThan(0)
67
- t.expect(Chunk.unsafeGet(events, 0).eventType).toBe("change")
74
+ test
75
+ .expect(Chunk.size(events))
76
+ .toBeGreaterThan(0)
77
+ test
78
+ .expect(Chunk.unsafeGet(events, 0).eventType)
79
+ .toBe("change")
68
80
  })
69
81
  .pipe(
70
82
  Effect.scoped,
@@ -75,7 +87,7 @@ t.describe(`${FileSystemExtra.watchSource.name}`, () => {
75
87
  ),
76
88
  )
77
89
 
78
- t.it("applies custom filter", () =>
90
+ test.it("applies custom filter", () =>
79
91
  Effect
80
92
  .gen(function*() {
81
93
  const fs = yield* FileSystem.FileSystem
@@ -98,8 +110,12 @@ t.describe(`${FileSystemExtra.watchSource.name}`, () => {
98
110
 
99
111
  const events = yield* Fiber.join(fiber)
100
112
 
101
- t.expect(Chunk.size(events)).toBe(1)
102
- t.expect(Chunk.unsafeGet(events, 0).path).toContain("included.ts")
113
+ test
114
+ .expect(Chunk.size(events))
115
+ .toBe(1)
116
+ test
117
+ .expect(Chunk.unsafeGet(events, 0).path)
118
+ .toContain("included.ts")
103
119
  })
104
120
  .pipe(
105
121
  Effect.scoped,
@@ -110,9 +126,9 @@ t.describe(`${FileSystemExtra.watchSource.name}`, () => {
110
126
  ))
111
127
  })
112
128
 
113
- t.describe(`${FileSystemExtra.filterSourceFiles.name}`, () => {
114
- t.it("matches source file extensions", () => {
115
- t
129
+ test.describe(`${FileSystemExtra.filterSourceFiles.name}`, () => {
130
+ test.it("matches source file extensions", () => {
131
+ test
116
132
  .expect(
117
133
  FileSystemExtra.filterSourceFiles({
118
134
  eventType: "change",
@@ -121,7 +137,7 @@ t.describe(`${FileSystemExtra.filterSourceFiles.name}`, () => {
121
137
  }),
122
138
  )
123
139
  .toBe(true)
124
- t
140
+ test
125
141
  .expect(
126
142
  FileSystemExtra.filterSourceFiles({
127
143
  eventType: "change",
@@ -130,7 +146,7 @@ t.describe(`${FileSystemExtra.filterSourceFiles.name}`, () => {
130
146
  }),
131
147
  )
132
148
  .toBe(true)
133
- t
149
+ test
134
150
  .expect(
135
151
  FileSystemExtra.filterSourceFiles({
136
152
  eventType: "change",
@@ -139,7 +155,7 @@ t.describe(`${FileSystemExtra.filterSourceFiles.name}`, () => {
139
155
  }),
140
156
  )
141
157
  .toBe(true)
142
- t
158
+ test
143
159
  .expect(
144
160
  FileSystemExtra.filterSourceFiles({
145
161
  eventType: "change",
@@ -148,7 +164,7 @@ t.describe(`${FileSystemExtra.filterSourceFiles.name}`, () => {
148
164
  }),
149
165
  )
150
166
  .toBe(true)
151
- t
167
+ test
152
168
  .expect(
153
169
  FileSystemExtra.filterSourceFiles({
154
170
  eventType: "change",
@@ -157,7 +173,7 @@ t.describe(`${FileSystemExtra.filterSourceFiles.name}`, () => {
157
173
  }),
158
174
  )
159
175
  .toBe(true)
160
- t
176
+ test
161
177
  .expect(
162
178
  FileSystemExtra.filterSourceFiles({
163
179
  eventType: "change",
@@ -166,7 +182,7 @@ t.describe(`${FileSystemExtra.filterSourceFiles.name}`, () => {
166
182
  }),
167
183
  )
168
184
  .toBe(true)
169
- t
185
+ test
170
186
  .expect(
171
187
  FileSystemExtra.filterSourceFiles({
172
188
  eventType: "change",
@@ -177,8 +193,8 @@ t.describe(`${FileSystemExtra.filterSourceFiles.name}`, () => {
177
193
  .toBe(true)
178
194
  })
179
195
 
180
- t.it("rejects non-source files", () => {
181
- t
196
+ test.it("rejects non-source files", () => {
197
+ test
182
198
  .expect(
183
199
  FileSystemExtra.filterSourceFiles({
184
200
  eventType: "change",
@@ -187,7 +203,7 @@ t.describe(`${FileSystemExtra.filterSourceFiles.name}`, () => {
187
203
  }),
188
204
  )
189
205
  .toBe(false)
190
- t
206
+ test
191
207
  .expect(
192
208
  FileSystemExtra.filterSourceFiles({
193
209
  eventType: "change",
@@ -199,9 +215,9 @@ t.describe(`${FileSystemExtra.filterSourceFiles.name}`, () => {
199
215
  })
200
216
  })
201
217
 
202
- t.describe(`${FileSystemExtra.filterDirectory.name}`, () => {
203
- t.it("matches directories", () => {
204
- t
218
+ test.describe(`${FileSystemExtra.filterDirectory.name}`, () => {
219
+ test.it("matches directories", () => {
220
+ test
205
221
  .expect(
206
222
  FileSystemExtra.filterDirectory({
207
223
  eventType: "change",
@@ -212,8 +228,8 @@ t.describe(`${FileSystemExtra.filterDirectory.name}`, () => {
212
228
  .toBe(true)
213
229
  })
214
230
 
215
- t.it("rejects files", () => {
216
- t
231
+ test.it("rejects files", () => {
232
+ test
217
233
  .expect(
218
234
  FileSystemExtra.filterDirectory({
219
235
  eventType: "change",
@@ -0,0 +1,319 @@
1
+ import * as test from "bun:test"
2
+ import * as Http from "./Http.ts"
3
+
4
+ test.describe("mapHeaders", () => {
5
+ test.it("converts Headers to record with lowercase keys", () => {
6
+ const headers = new Headers({
7
+ "Content-Type": "application/json",
8
+ "X-Custom-Header": "value",
9
+ })
10
+
11
+ const record = Http.mapHeaders(headers)
12
+
13
+ test
14
+ .expect(record)
15
+ .toEqual({
16
+ "content-type": "application/json",
17
+ "x-custom-header": "value",
18
+ })
19
+ })
20
+
21
+ test.it("returns empty record for empty headers", () => {
22
+ const headers = new Headers()
23
+ const record = Http.mapHeaders(headers)
24
+
25
+ test
26
+ .expect(record)
27
+ .toEqual({})
28
+ })
29
+ })
30
+
31
+ test.describe("parseCookies", () => {
32
+ test.it("parses cookie header string", () => {
33
+ const cookieHeader = "session=abc123; token=xyz789"
34
+ const cookies = Http.parseCookies(cookieHeader)
35
+
36
+ test
37
+ .expect(cookies)
38
+ .toEqual({
39
+ session: "abc123",
40
+ token: "xyz789",
41
+ })
42
+ })
43
+
44
+ test.it("handles cookies with = in value", () => {
45
+ const cookieHeader = "data=key=value"
46
+ const cookies = Http.parseCookies(cookieHeader)
47
+
48
+ test
49
+ .expect(cookies)
50
+ .toEqual({
51
+ data: "key=value",
52
+ })
53
+ })
54
+
55
+ test.it("trims whitespace from cookie names and values", () => {
56
+ const cookieHeader = " session = abc123 ; token = xyz789 "
57
+ const cookies = Http.parseCookies(cookieHeader)
58
+
59
+ test
60
+ .expect(cookies)
61
+ .toEqual({
62
+ session: "abc123",
63
+ token: "xyz789",
64
+ })
65
+ })
66
+
67
+ test.it("handles empty cookie values", () => {
68
+ const cookieHeader = "session=; token=xyz789"
69
+ const cookies = Http.parseCookies(cookieHeader)
70
+
71
+ test
72
+ .expect(cookies)
73
+ .toEqual({
74
+ session: "",
75
+ token: "xyz789",
76
+ })
77
+ })
78
+
79
+ test.it("handles cookies without values", () => {
80
+ const cookieHeader = "flag; session=abc123"
81
+ const cookies = Http.parseCookies(cookieHeader)
82
+
83
+ test
84
+ .expect(cookies)
85
+ .toEqual({
86
+ flag: undefined,
87
+ session: "abc123",
88
+ })
89
+ })
90
+
91
+ test.it("ignores empty parts", () => {
92
+ const cookieHeader = "session=abc123;; ; token=xyz789"
93
+ const cookies = Http.parseCookies(cookieHeader)
94
+
95
+ test
96
+ .expect(cookies)
97
+ .toEqual({
98
+ session: "abc123",
99
+ token: "xyz789",
100
+ })
101
+ })
102
+
103
+ test.it("returns empty record for null cookie header", () => {
104
+ const cookies = Http.parseCookies(null)
105
+
106
+ test
107
+ .expect(cookies)
108
+ .toEqual({})
109
+ })
110
+
111
+ test.it("returns empty record for empty cookie header", () => {
112
+ const cookies = Http.parseCookies("")
113
+
114
+ test
115
+ .expect(cookies)
116
+ .toEqual({})
117
+ })
118
+ })
119
+
120
+ test.describe("mapUrlSearchParams", () => {
121
+ test.it("converts single values to strings", () => {
122
+ const params = new URLSearchParams("page=1&limit=10")
123
+ const record = Http.mapUrlSearchParams(params)
124
+
125
+ test
126
+ .expect(record)
127
+ .toEqual({
128
+ page: "1",
129
+ limit: "10",
130
+ })
131
+ })
132
+
133
+ test.it("converts multiple values to arrays", () => {
134
+ const params = new URLSearchParams("tags=red&tags=blue&tags=green")
135
+ const record = Http.mapUrlSearchParams(params)
136
+
137
+ test
138
+ .expect(record)
139
+ .toEqual({
140
+ tags: ["red", "blue", "green"],
141
+ })
142
+ })
143
+
144
+ test.it("handles mixed single and multiple values", () => {
145
+ const params = new URLSearchParams("page=1&tags=red&tags=blue")
146
+ const record = Http.mapUrlSearchParams(params)
147
+
148
+ test
149
+ .expect(record)
150
+ .toEqual({
151
+ page: "1",
152
+ tags: ["red", "blue"],
153
+ })
154
+ })
155
+
156
+ test.it("returns empty record for empty params", () => {
157
+ const params = new URLSearchParams()
158
+ const record = Http.mapUrlSearchParams(params)
159
+
160
+ test
161
+ .expect(record)
162
+ .toEqual({})
163
+ })
164
+ })
165
+
166
+ test.describe("parseFormData", () => {
167
+ function createFormDataRequest(formData: FormData): Request {
168
+ return new Request("http://localhost/", {
169
+ method: "POST",
170
+ body: formData,
171
+ })
172
+ }
173
+
174
+ test.it("parses single string field", async () => {
175
+ const formData = new FormData()
176
+ formData.append("name", "John")
177
+
178
+ const request = createFormDataRequest(formData)
179
+ const result = await Http.parseFormData(request)
180
+
181
+ test
182
+ .expect(result)
183
+ .toEqual({
184
+ name: "John",
185
+ })
186
+ })
187
+
188
+ test.it("parses multiple string fields", async () => {
189
+ const formData = new FormData()
190
+ formData.append("name", "John")
191
+ formData.append("email", "john@example.com")
192
+
193
+ const request = createFormDataRequest(formData)
194
+ const result = await Http.parseFormData(request)
195
+
196
+ test
197
+ .expect(result)
198
+ .toEqual({
199
+ name: "John",
200
+ email: "john@example.com",
201
+ })
202
+ })
203
+
204
+ test.it("parses multiple values for same key as array", async () => {
205
+ const formData = new FormData()
206
+ formData.append("tags", "red")
207
+ formData.append("tags", "blue")
208
+ formData.append("tags", "green")
209
+
210
+ const request = createFormDataRequest(formData)
211
+ const result = await Http.parseFormData(request)
212
+
213
+ test
214
+ .expect(result)
215
+ .toEqual({
216
+ tags: ["red", "blue", "green"],
217
+ })
218
+ })
219
+
220
+ test.it("parses single file upload", async () => {
221
+ const formData = new FormData()
222
+ const fileContent = new Uint8Array([72, 101, 108, 108, 111]) // "Hello"
223
+ const file = new File([fileContent], "test.txt", { type: "text/plain" })
224
+ formData.append("document", file)
225
+
226
+ const request = createFormDataRequest(formData)
227
+ const result = await Http.parseFormData(request)
228
+
229
+ test.expect(result.document).toBeDefined()
230
+ const files = result.document as ReadonlyArray<Http.FilePart>
231
+ test.expect(files).toHaveLength(1)
232
+ test.expect(files[0]._tag).toBe("File")
233
+ test.expect(files[0].key).toBe("document")
234
+ test.expect(files[0].name).toBe("test.txt")
235
+ test.expect(files[0].contentType.startsWith("text/plain")).toBe(true)
236
+ test.expect(files[0].content).toEqual(fileContent)
237
+ })
238
+
239
+ test.it("parses multiple file uploads for same key", async () => {
240
+ const formData = new FormData()
241
+ const file1 = new File([new Uint8Array([1, 2, 3])], "file1.bin", {
242
+ type: "application/octet-stream",
243
+ })
244
+ const file2 = new File([new Uint8Array([4, 5, 6])], "file2.bin", {
245
+ type: "application/octet-stream",
246
+ })
247
+ formData.append("files", file1)
248
+ formData.append("files", file2)
249
+
250
+ const request = createFormDataRequest(formData)
251
+ const result = await Http.parseFormData(request)
252
+
253
+ const files = result.files as ReadonlyArray<Http.FilePart>
254
+ test.expect(files).toHaveLength(2)
255
+ test.expect(files[0].name).toBe("file1.bin")
256
+ test.expect(files[0].content).toEqual(new Uint8Array([1, 2, 3]))
257
+ test.expect(files[1].name).toBe("file2.bin")
258
+ test.expect(files[1].content).toEqual(new Uint8Array([4, 5, 6]))
259
+ })
260
+
261
+ test.it("uses default content type for files without type", async () => {
262
+ const formData = new FormData()
263
+ const file = new File([new Uint8Array([1, 2, 3])], "unknown.dat", {
264
+ type: "",
265
+ })
266
+ formData.append("upload", file)
267
+
268
+ const request = createFormDataRequest(formData)
269
+ const result = await Http.parseFormData(request)
270
+
271
+ const files = result.upload as ReadonlyArray<Http.FilePart>
272
+ test.expect(files[0].contentType).toBe("application/octet-stream")
273
+ })
274
+
275
+ test.it("parses mixed string fields and file uploads", async () => {
276
+ const formData = new FormData()
277
+ formData.append("title", "My Document")
278
+ const file = new File([new Uint8Array([1, 2, 3])], "doc.pdf", {
279
+ type: "application/pdf",
280
+ })
281
+ formData.append("attachment", file)
282
+ formData.append("description", "A test document")
283
+
284
+ const request = createFormDataRequest(formData)
285
+ const result = await Http.parseFormData(request)
286
+
287
+ test.expect(result.title).toBe("My Document")
288
+ test.expect(result.description).toBe("A test document")
289
+ const files = result.attachment as ReadonlyArray<Http.FilePart>
290
+ test.expect(files).toHaveLength(1)
291
+ test.expect(files[0].name).toBe("doc.pdf")
292
+ })
293
+
294
+ test.it("returns empty record for empty form data", async () => {
295
+ const formData = new FormData()
296
+
297
+ const request = createFormDataRequest(formData)
298
+ const result = await Http.parseFormData(request)
299
+
300
+ test
301
+ .expect(result)
302
+ .toEqual({})
303
+ })
304
+
305
+ test.it("parses Blob as file", async () => {
306
+ const formData = new FormData()
307
+ const blob = new Blob([new Uint8Array([10, 20, 30])], { type: "image/png" })
308
+ formData.append("image", blob, "image.png")
309
+
310
+ const request = createFormDataRequest(formData)
311
+ const result = await Http.parseFormData(request)
312
+
313
+ const files = result.image as ReadonlyArray<Http.FilePart>
314
+ test.expect(files).toHaveLength(1)
315
+ test.expect(files[0].name).toBe("image.png")
316
+ test.expect(files[0].contentType).toBe("image/png")
317
+ test.expect(files[0].content).toEqual(new Uint8Array([10, 20, 30]))
318
+ })
319
+ })
package/src/Http.ts ADDED
@@ -0,0 +1,167 @@
1
+ import * as Values from "./Values.ts"
2
+
3
+ export type Method =
4
+ | "GET"
5
+ | "POST"
6
+ | "PUT"
7
+ | "DELETE"
8
+ | "PATCH"
9
+ | "HEAD"
10
+ | "OPTIONS"
11
+
12
+ type Respondable =
13
+ | Response
14
+ | Promise<Response>
15
+
16
+ export type WebHandler = (request: Request) => Respondable
17
+
18
+ export type WebMiddleware = (
19
+ request: Request,
20
+ next: WebHandler,
21
+ ) => Respondable
22
+
23
+ export function fetch(
24
+ handler: WebHandler,
25
+ init:
26
+ & Omit<RequestInit, "body">
27
+ & (
28
+ | { url: string }
29
+ | { path: `/${string}` }
30
+ )
31
+ & { body?: RequestInit["body"] | Record<string, unknown> },
32
+ ): Promise<Response> {
33
+ const url = "path" in init
34
+ ? `http://localhost${init.path}`
35
+ : init.url
36
+
37
+ const isPlain = Values.isPlainObject(init.body)
38
+
39
+ const headers = new Headers(init.headers)
40
+ if (isPlain && !headers.has("Content-Type")) {
41
+ headers.set("Content-Type", "application/json")
42
+ }
43
+
44
+ const body = isPlain ? JSON.stringify(init.body) : init.body
45
+
46
+ const request = new Request(url, {
47
+ ...init,
48
+ headers,
49
+ body: body as BodyInit,
50
+ })
51
+ return Promise.resolve(handler(request))
52
+ }
53
+
54
+ export function createAbortableRequest(
55
+ init:
56
+ & Omit<RequestInit, "signal">
57
+ & (
58
+ | { url: string }
59
+ | { path: `/${string}` }
60
+ ),
61
+ ): { request: Request; abort: () => void } {
62
+ const url = "path" in init
63
+ ? `http://localhost${init.path}`
64
+ : init.url
65
+ const controller = new AbortController()
66
+ const request = new Request(url, { ...init, signal: controller.signal })
67
+ return { request, abort: () => controller.abort() }
68
+ }
69
+
70
+ export function mapHeaders(
71
+ headers: Headers,
72
+ ): Record<string, string | undefined> {
73
+ const result: Record<string, string | undefined> = {}
74
+ headers.forEach((value, key) => {
75
+ result[key.toLowerCase()] = value
76
+ })
77
+ return result
78
+ }
79
+
80
+ export function parseCookies(
81
+ cookieHeader: string | null,
82
+ ): Record<string, string | undefined> {
83
+ if (!cookieHeader) return {}
84
+ const result: Record<string, string | undefined> = {}
85
+ for (const part of cookieHeader.split(";")) {
86
+ const idx = part.indexOf("=")
87
+ if (idx === -1) {
88
+ // Cookie without value (e.g., "name" or just whitespace)
89
+ const key = part.trim()
90
+ if (key) {
91
+ result[key] = undefined
92
+ }
93
+ } else {
94
+ const key = part.slice(0, idx).trim()
95
+ const value = part.slice(idx + 1).trim()
96
+ if (key) {
97
+ result[key] = value
98
+ }
99
+ }
100
+ }
101
+ return result
102
+ }
103
+
104
+ export function mapUrlSearchParams(
105
+ params: URLSearchParams,
106
+ ): Record<string, string | ReadonlyArray<string> | undefined> {
107
+ const result: Record<string, string | ReadonlyArray<string> | undefined> = {}
108
+ for (const key of new Set(params.keys())) {
109
+ const values = params.getAll(key)
110
+ result[key] = values.length === 1 ? values[0] : values
111
+ }
112
+ return result
113
+ }
114
+
115
+ export interface FilePart {
116
+ readonly _tag: "File"
117
+ readonly key: string
118
+ readonly name: string
119
+ readonly contentType: string
120
+ readonly content: Uint8Array
121
+ }
122
+
123
+ export interface FieldPart {
124
+ readonly _tag: "Field"
125
+ readonly key: string
126
+ readonly value: string
127
+ }
128
+
129
+ export type MultipartPart = FilePart | FieldPart
130
+
131
+ export async function parseFormData(
132
+ request: Request,
133
+ ): Promise<
134
+ Record<string, ReadonlyArray<FilePart> | ReadonlyArray<string> | string>
135
+ > {
136
+ const formData = await request.formData()
137
+ const result: Record<
138
+ string,
139
+ ReadonlyArray<FilePart> | ReadonlyArray<string> | string
140
+ > = {}
141
+
142
+ for (const key of new Set(formData.keys())) {
143
+ const values = formData.getAll(key)
144
+ const first = values[0]
145
+
146
+ if (typeof first === "string") {
147
+ result[key] = values.length === 1 ? first : (values as string[])
148
+ } else {
149
+ const files: FilePart[] = []
150
+ for (const value of values) {
151
+ if (typeof value !== "string") {
152
+ const content = new Uint8Array(await value.arrayBuffer())
153
+ files.push({
154
+ _tag: "File",
155
+ key,
156
+ name: value.name,
157
+ contentType: value.type || "application/octet-stream",
158
+ content,
159
+ })
160
+ }
161
+ }
162
+ result[key] = files
163
+ }
164
+ }
165
+
166
+ return result
167
+ }