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.
Files changed (46) hide show
  1. package/package.json +2 -1
  2. package/src/ContentNegotiation.test.ts +103 -0
  3. package/src/ContentNegotiation.ts +10 -3
  4. package/src/Development.test.ts +119 -0
  5. package/src/Development.ts +137 -0
  6. package/src/Entity.test.ts +592 -0
  7. package/src/Entity.ts +359 -0
  8. package/src/FileRouter.ts +2 -2
  9. package/src/Http.test.ts +315 -20
  10. package/src/Http.ts +153 -11
  11. package/src/PathPattern.ts +3 -1
  12. package/src/Route.ts +26 -10
  13. package/src/RouteBody.test.ts +98 -66
  14. package/src/RouteBody.ts +125 -35
  15. package/src/RouteHook.ts +15 -14
  16. package/src/RouteHttp.test.ts +2549 -83
  17. package/src/RouteHttp.ts +337 -113
  18. package/src/RouteHttpTracer.ts +92 -0
  19. package/src/RouteMount.test.ts +23 -10
  20. package/src/RouteMount.ts +161 -4
  21. package/src/RouteSchema.test.ts +346 -0
  22. package/src/RouteSchema.ts +386 -7
  23. package/src/RouteSse.test.ts +249 -0
  24. package/src/RouteSse.ts +195 -0
  25. package/src/RouteTree.test.ts +233 -85
  26. package/src/RouteTree.ts +98 -44
  27. package/src/StreamExtra.ts +21 -1
  28. package/src/Values.test.ts +263 -0
  29. package/src/Values.ts +68 -6
  30. package/src/bun/BunBundle.ts +0 -73
  31. package/src/bun/BunHttpServer.ts +23 -7
  32. package/src/bun/BunRoute.test.ts +162 -0
  33. package/src/bun/BunRoute.ts +144 -105
  34. package/src/hyper/HyperHtml.test.ts +119 -0
  35. package/src/hyper/HyperHtml.ts +10 -2
  36. package/src/hyper/HyperNode.ts +2 -0
  37. package/src/hyper/HyperRoute.test.tsx +197 -0
  38. package/src/hyper/HyperRoute.ts +61 -0
  39. package/src/hyper/index.ts +4 -0
  40. package/src/hyper/jsx.d.ts +15 -0
  41. package/src/index.ts +2 -0
  42. package/src/node/FileSystem.ts +8 -0
  43. package/src/testing/TestLogger.test.ts +0 -3
  44. package/src/testing/TestLogger.ts +15 -9
  45. package/src/FileSystemExtra.test.ts +0 -242
  46. 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.it("cloneRequest copies request and adds props", () => {
5
- const request = new Request("http://localhost/test", {
6
- method: "POST",
7
- headers: { "Content-Type": "application/json" },
8
- })
9
-
10
- const cloned = Http.cloneRequest(request, { params: { id: "123" } })
11
-
12
- test
13
- .expect(cloned.url)
14
- .toBe("http://localhost/test")
15
- test
16
- .expect(cloned.method)
17
- .toBe("POST")
18
- test
19
- .expect(cloned.headers.get("Content-Type"))
20
- .toBe("application/json")
21
- test
22
- .expect(cloned.params)
23
- .toEqual({ id: "123" })
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
- export type WebHandler = (request: Request) => Response | Promise<Response>
12
+ type Respondable =
13
+ | Response
14
+ | Promise<Response>
15
+
16
+ export type WebHandler = (request: Request) => Respondable
11
17
 
12
- export function cloneRequest<T extends object>(
18
+ export type WebMiddleware = (
13
19
  request: Request,
14
- props: T,
15
- ): Request & T {
16
- const cloned = new Request(request.url, {
17
- method: request.method,
18
- headers: request.headers,
19
- body: request.body,
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
- for (const [key, value] of Object.entries(props)) {
22
- ;(cloned as any)[key] = value
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 cloned as Request & T
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
  }
@@ -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 = `/${withOptionalSegments.map(formatSegment).join("/")}`
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: B) => Effect.Effect<A>,
103
- ) => Effect.Effect<A, E, R>
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
- return self[RouteDescriptor]
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