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.
- package/package.json +8 -9
- package/src/Commander.test.ts +507 -245
- package/src/ContentNegotiation.test.ts +603 -0
- package/src/ContentNegotiation.ts +542 -0
- package/src/Entity.test.ts +592 -0
- package/src/Entity.ts +362 -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 +319 -0
- package/src/Http.ts +167 -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 +485 -0
- package/src/Route.ts +266 -1069
- package/src/RouteBody.test.ts +234 -0
- package/src/RouteBody.ts +193 -0
- package/src/RouteHook.test.ts +40 -0
- package/src/RouteHook.ts +106 -0
- package/src/RouteHttp.test.ts +2906 -0
- package/src/RouteHttp.ts +427 -0
- package/src/RouteHttpTracer.ts +92 -0
- package/src/RouteMount.test.ts +481 -0
- package/src/RouteMount.ts +470 -0
- package/src/RouteSchema.test.ts +427 -0
- package/src/RouteSchema.ts +423 -0
- package/src/RouteTree.test.ts +494 -0
- package/src/RouteTree.ts +219 -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/StreamExtra.ts +21 -1
- package/src/TuplePathPattern.ts +64 -0
- package/src/Values.test.ts +263 -0
- package/src/Values.ts +76 -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 +72 -32
- package/src/bun/BunHttpServer_web.ts +18 -6
- package/src/bun/BunImportTrackerPlugin.test.ts +3 -3
- package/src/bun/BunRoute.test.ts +124 -442
- package/src/bun/BunRoute.ts +146 -286
- 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 +3 -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 -14
- package/src/testing/TestLogger.ts +15 -9
- 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_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
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import * as FileSystem from "@effect/platform/FileSystem"
|
|
2
|
-
import * as
|
|
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
|
-
|
|
12
|
-
|
|
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
|
-
|
|
31
|
+
test
|
|
32
|
+
.expect(Chunk.size(events))
|
|
33
|
+
.toBeGreaterThan(0)
|
|
32
34
|
const first = Chunk.unsafeGet(events, 0)
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
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
|
-
|
|
67
|
-
|
|
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
|
-
|
|
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
|
-
|
|
102
|
-
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
181
|
-
|
|
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
|
-
|
|
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
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
-
|
|
216
|
-
|
|
231
|
+
test.it("rejects files", () => {
|
|
232
|
+
test
|
|
217
233
|
.expect(
|
|
218
234
|
FileSystemExtra.filterDirectory({
|
|
219
235
|
eventType: "change",
|
package/src/Http.test.ts
ADDED
|
@@ -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
|
+
}
|