effect-start 0.15.0 → 0.17.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 +2 -1
- package/src/ContentNegotiation.test.ts +103 -0
- package/src/ContentNegotiation.ts +10 -3
- package/src/Development.test.ts +119 -0
- package/src/Development.ts +137 -0
- package/src/Entity.test.ts +592 -0
- package/src/Entity.ts +359 -0
- package/src/FileRouter.ts +2 -2
- package/src/Http.test.ts +315 -20
- package/src/Http.ts +153 -11
- package/src/PathPattern.ts +3 -1
- package/src/Route.ts +26 -10
- package/src/RouteBody.test.ts +98 -66
- package/src/RouteBody.ts +125 -35
- package/src/RouteHook.ts +15 -14
- package/src/RouteHttp.test.ts +2549 -83
- package/src/RouteHttp.ts +337 -113
- package/src/RouteHttpTracer.ts +92 -0
- package/src/RouteMount.test.ts +23 -10
- package/src/RouteMount.ts +161 -4
- package/src/RouteSchema.test.ts +346 -0
- package/src/RouteSchema.ts +386 -7
- package/src/RouteSse.test.ts +249 -0
- package/src/RouteSse.ts +195 -0
- package/src/RouteTree.test.ts +233 -85
- package/src/RouteTree.ts +98 -44
- package/src/StreamExtra.ts +21 -1
- package/src/Values.test.ts +263 -0
- package/src/Values.ts +68 -6
- package/src/bun/BunBundle.ts +0 -73
- package/src/bun/BunHttpServer.ts +23 -7
- package/src/bun/BunRoute.test.ts +162 -0
- package/src/bun/BunRoute.ts +144 -105
- package/src/hyper/HyperHtml.test.ts +119 -0
- package/src/hyper/HyperHtml.ts +10 -2
- package/src/hyper/HyperNode.ts +2 -0
- package/src/hyper/HyperRoute.test.tsx +197 -0
- package/src/hyper/HyperRoute.ts +61 -0
- package/src/hyper/index.ts +4 -0
- package/src/hyper/jsx.d.ts +15 -0
- package/src/index.ts +2 -0
- package/src/node/FileSystem.ts +8 -0
- package/src/testing/TestLogger.test.ts +0 -3
- package/src/testing/TestLogger.ts +15 -9
- package/src/FileSystemExtra.test.ts +0 -242
- package/src/FileSystemExtra.ts +0 -66
package/src/Http.test.ts
CHANGED
|
@@ -1,24 +1,319 @@
|
|
|
1
1
|
import * as test from "bun:test"
|
|
2
2
|
import * as Http from "./Http.ts"
|
|
3
3
|
|
|
4
|
-
test.
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
test
|
|
22
|
-
|
|
23
|
-
|
|
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
|
+
})
|
|
24
319
|
})
|
package/src/Http.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import * as Values from "./Values.ts"
|
|
2
|
+
|
|
1
3
|
export type Method =
|
|
2
4
|
| "GET"
|
|
3
5
|
| "POST"
|
|
@@ -7,19 +9,159 @@ export type Method =
|
|
|
7
9
|
| "HEAD"
|
|
8
10
|
| "OPTIONS"
|
|
9
11
|
|
|
10
|
-
|
|
12
|
+
type Respondable =
|
|
13
|
+
| Response
|
|
14
|
+
| Promise<Response>
|
|
15
|
+
|
|
16
|
+
export type WebHandler = (request: Request) => Respondable
|
|
11
17
|
|
|
12
|
-
export
|
|
18
|
+
export type WebMiddleware = (
|
|
13
19
|
request: Request,
|
|
14
|
-
|
|
15
|
-
)
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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,
|
|
20
50
|
})
|
|
21
|
-
|
|
22
|
-
|
|
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
|
+
}
|
|
23
100
|
}
|
|
24
|
-
return
|
|
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
|
|
25
167
|
}
|
package/src/PathPattern.ts
CHANGED
|
@@ -477,7 +477,9 @@ export function toBun(path: string): PathPattern[] {
|
|
|
477
477
|
: `:${optionalName}`
|
|
478
478
|
|
|
479
479
|
const withOptionalSegments = [...before, requiredOptional, ...after]
|
|
480
|
-
const withOptionalPath: PathPattern = `/${
|
|
480
|
+
const withOptionalPath: PathPattern = `/${
|
|
481
|
+
withOptionalSegments.map(formatSegment).join("/")
|
|
482
|
+
}`
|
|
481
483
|
|
|
482
484
|
return [...toBun(basePath), ...toBun(withOptionalPath)]
|
|
483
485
|
}
|
package/src/Route.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type * as Effect from "effect/Effect"
|
|
|
3
3
|
import * as Layer from "effect/Layer"
|
|
4
4
|
import * as Pipeable from "effect/Pipeable"
|
|
5
5
|
import * as Predicate from "effect/Predicate"
|
|
6
|
+
import type * as Entity from "./Entity.ts"
|
|
6
7
|
import * as RouteBody from "./RouteBody.ts"
|
|
7
8
|
import * as RouteTree from "./RouteTree.ts"
|
|
8
9
|
import * as Values from "./Values.ts"
|
|
@@ -99,14 +100,8 @@ export namespace Route {
|
|
|
99
100
|
|
|
100
101
|
export type Handler<B, A, E, R> = (
|
|
101
102
|
context: B,
|
|
102
|
-
next: (context
|
|
103
|
-
) => Effect.Effect<A
|
|
104
|
-
|
|
105
|
-
// handler that cannot modify the context
|
|
106
|
-
export type HandlerImmutable<B, A, E, R> = (
|
|
107
|
-
context: B,
|
|
108
|
-
next: () => Effect.Effect<A>,
|
|
109
|
-
) => Effect.Effect<A, E, R>
|
|
103
|
+
next: (context?: Partial<B> & Record<string, unknown>) => Entity.Entity<A>,
|
|
104
|
+
) => Effect.Effect<Entity.Entity<A>, E, R>
|
|
110
105
|
|
|
111
106
|
/**
|
|
112
107
|
* Extracts only the bindings (B) from routes, excluding descriptors.
|
|
@@ -246,8 +241,21 @@ export function descriptor<
|
|
|
246
241
|
T extends RouteSet.Data<any, any, any>,
|
|
247
242
|
>(
|
|
248
243
|
self: T,
|
|
249
|
-
): T[typeof RouteDescriptor]
|
|
250
|
-
|
|
244
|
+
): T[typeof RouteDescriptor]
|
|
245
|
+
export function descriptor<
|
|
246
|
+
T extends RouteSet.Data<any, any, any>,
|
|
247
|
+
>(
|
|
248
|
+
self: Iterable<T>,
|
|
249
|
+
): T[typeof RouteDescriptor][]
|
|
250
|
+
export function descriptor(
|
|
251
|
+
self:
|
|
252
|
+
| RouteSet.Data<any, any, any>
|
|
253
|
+
| Iterable<RouteSet.Data<any, any, any>>,
|
|
254
|
+
): RouteDescriptor.Any | RouteDescriptor.Any[] {
|
|
255
|
+
if (RouteDescriptor in self) {
|
|
256
|
+
return self[RouteDescriptor]
|
|
257
|
+
}
|
|
258
|
+
return [...self].map((r) => r[RouteDescriptor])
|
|
251
259
|
}
|
|
252
260
|
|
|
253
261
|
export type ExtractBindings<
|
|
@@ -311,6 +319,14 @@ export const bytes = RouteBody.build<Uint8Array, "bytes">({
|
|
|
311
319
|
format: "bytes",
|
|
312
320
|
})
|
|
313
321
|
|
|
322
|
+
export {
|
|
323
|
+
render,
|
|
324
|
+
} from "./RouteBody.ts"
|
|
325
|
+
|
|
326
|
+
export {
|
|
327
|
+
sse,
|
|
328
|
+
} from "./RouteSse.ts"
|
|
329
|
+
|
|
314
330
|
export class Routes extends Context.Tag("effect-start/Routes")<
|
|
315
331
|
Routes,
|
|
316
332
|
RouteTree.RouteTree
|