effect-start 0.10.0 → 0.11.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.
@@ -1,69 +1,119 @@
1
+ import * as HttpMiddleware from "@effect/platform/HttpMiddleware"
2
+ import * as HttpServerResponse from "@effect/platform/HttpServerResponse"
1
3
  import type { HTMLBundle } from "bun"
2
4
  import * as t from "bun:test"
3
5
  import * as Effect from "effect/Effect"
6
+ import * as Layer from "effect/Layer"
4
7
  import * as Route from "../Route.ts"
5
- import type * as Router from "../Router.ts"
8
+ import * as Router from "../Router.ts"
9
+ import * as BunHttpServer from "./BunHttpServer.ts"
6
10
  import * as BunRoute from "./BunRoute.ts"
7
11
 
8
- t.describe(`${BunRoute.loadBundle.name}`, () => {
9
- t.test("creates BunRoute from HTMLBundle", () => {
10
- const mockBundle = { index: "index.html" } as HTMLBundle
11
- const bunRoute = BunRoute.loadBundle(() => Promise.resolve(mockBundle))
12
12
 
13
- t.expect(BunRoute.isBunRoute(bunRoute)).toBe(true)
14
- t.expect(Route.isRouteSet(bunRoute)).toBe(true)
15
- t.expect(bunRoute.set).toHaveLength(1)
16
- t.expect(bunRoute.method as string).toBe("GET")
17
- t.expect(bunRoute.media as string).toBe("text/html")
13
+ t.describe(`${BunRoute.validateBunPattern.name}`, () => {
14
+ t.test("allows exact paths", () => {
15
+ const result = BunRoute.validateBunPattern("/users")
16
+ t.expect(result._tag).toBe("None")
18
17
  })
19
18
 
20
- t.test("unwraps default export", async () => {
21
- const mockBundle = { index: "index.html" } as HTMLBundle
22
- const bunRoute = BunRoute.loadBundle(() =>
23
- Promise.resolve({ default: mockBundle })
24
- )
25
-
26
- const loaded = await bunRoute.load()
27
- t.expect(loaded).toBe(mockBundle)
19
+ t.test("allows full-segment params", () => {
20
+ const result = BunRoute.validateBunPattern("/users/[id]")
21
+ t.expect(result._tag).toBe("None")
28
22
  })
29
23
 
30
- t.test("returns bundle directly when no default", async () => {
31
- const mockBundle = { index: "index.html" } as HTMLBundle
32
- const bunRoute = BunRoute.loadBundle(() => Promise.resolve(mockBundle))
24
+ t.test("allows rest params", () => {
25
+ const result = BunRoute.validateBunPattern("/docs/[...path]")
26
+ t.expect(result._tag).toBe("None")
27
+ })
33
28
 
34
- const loaded = await bunRoute.load()
35
- t.expect(loaded).toBe(mockBundle)
29
+ t.test("rejects prefixed params", () => {
30
+ const result = BunRoute.validateBunPattern("/users/pk_[id]")
31
+ t.expect(result._tag).toBe("Some")
32
+ if (result._tag === "Some") {
33
+ t.expect(result.value.reason).toBe("UnsupportedPattern")
34
+ t.expect(result.value.pattern).toBe("/users/pk_[id]")
35
+ }
36
36
  })
37
- })
38
37
 
39
- t.describe(`${BunRoute.isBunRoute.name}`, () => {
40
- t.test("returns true for BunRoute", () => {
41
- const mockBundle = { index: "index.html" } as HTMLBundle
42
- const bunRoute = BunRoute.loadBundle(() => Promise.resolve(mockBundle))
38
+ t.test("rejects suffixed params", () => {
39
+ const result = BunRoute.validateBunPattern("/users/[id]_details")
40
+ t.expect(result._tag).toBe("Some")
41
+ if (result._tag === "Some") {
42
+ t.expect(result.value.reason).toBe("UnsupportedPattern")
43
+ }
44
+ })
43
45
 
44
- t.expect(BunRoute.isBunRoute(bunRoute)).toBe(true)
46
+ t.test("rejects dot suffix on params", () => {
47
+ const result = BunRoute.validateBunPattern("/api/[id].json")
48
+ t.expect(result._tag).toBe("Some")
49
+ if (result._tag === "Some") {
50
+ t.expect(result.value.reason).toBe("UnsupportedPattern")
51
+ }
45
52
  })
46
53
 
47
- t.test("returns false for regular Route", () => {
48
- const route = Route.text(Effect.succeed("hello"))
54
+ t.test("rejects tilde suffix on params", () => {
55
+ const result = BunRoute.validateBunPattern("/api/[id]~test")
56
+ t.expect(result._tag).toBe("Some")
57
+ if (result._tag === "Some") {
58
+ t.expect(result.value.reason).toBe("UnsupportedPattern")
59
+ }
60
+ })
49
61
 
50
- t.expect(BunRoute.isBunRoute(route)).toBe(false)
62
+ t.test("allows optional params (implemented via two patterns)", () => {
63
+ const result = BunRoute.validateBunPattern("/users/[[id]]")
64
+ t.expect(result._tag).toBe("None")
51
65
  })
52
66
 
53
- t.test("returns false for non-route values", () => {
54
- t.expect(BunRoute.isBunRoute(null)).toBe(false)
55
- t.expect(BunRoute.isBunRoute(undefined)).toBe(false)
56
- t.expect(BunRoute.isBunRoute({})).toBe(false)
57
- t.expect(BunRoute.isBunRoute("string")).toBe(false)
67
+ t.test("allows optional rest params (implemented via two patterns)", () => {
68
+ const result = BunRoute.validateBunPattern("/docs/[[...path]]")
69
+ t.expect(result._tag).toBe("None")
58
70
  })
59
71
  })
60
72
 
61
73
  t.describe(`${BunRoute.routesFromRouter.name}`, () => {
74
+ t.test("fails with RouterError for unsupported patterns", async () => {
75
+ const result = await Effect.runPromise(
76
+ BunRoute
77
+ .routesFromRouter(
78
+ Router.mount("/users/pk_[id]", Route.text("user")),
79
+ )
80
+ .pipe(
81
+ Effect.either,
82
+ Effect.provide(BunHttpServer.layer({ port: 0 })),
83
+ ),
84
+ )
85
+
86
+ t.expect(result._tag).toBe("Left")
87
+ if (result._tag === "Left") {
88
+ t.expect(result.left._tag).toBe("RouterError")
89
+ t.expect(result.left.reason).toBe("UnsupportedPattern")
90
+ }
91
+ })
92
+
93
+ t.it(
94
+ "converts text route to fetch handler",
95
+ () =>
96
+ Effect.runPromise(
97
+ Effect
98
+ .gen(function*() {
99
+ const bunServer = yield* BunHttpServer.BunServer
100
+ return 23
101
+ })
102
+ .pipe(
103
+ Effect.provide(
104
+ Layer.mergeAll(
105
+ BunHttpServer.layer({
106
+ port: 0,
107
+ }),
108
+ ),
109
+ ),
110
+ ),
111
+ ),
112
+ )
113
+
62
114
  t.test("converts text route to fetch handler", async () => {
63
115
  const fetch = await makeFetch(
64
- makeRouter([
65
- { path: "/hello", routes: Route.text(Effect.succeed("Hello World")) },
66
- ]),
116
+ Router.mount("/hello", Route.text("Hello World")),
67
117
  )
68
118
 
69
119
  const response = await fetch("/hello")
@@ -74,12 +124,7 @@ t.describe(`${BunRoute.routesFromRouter.name}`, () => {
74
124
 
75
125
  t.test("converts json route to fetch handler", async () => {
76
126
  const fetch = await makeFetch(
77
- makeRouter([
78
- {
79
- path: "/api/data",
80
- routes: Route.json(Effect.succeed({ message: "ok", count: 42 })),
81
- },
82
- ]),
127
+ Router.mount("/api/data", Route.json({ message: "ok", count: 42 })),
83
128
  )
84
129
 
85
130
  const response = await fetch("/api/data")
@@ -90,9 +135,7 @@ t.describe(`${BunRoute.routesFromRouter.name}`, () => {
90
135
 
91
136
  t.test("converts html route to fetch handler", async () => {
92
137
  const fetch = await makeFetch(
93
- makeRouter([
94
- { path: "/page", routes: Route.html(Effect.succeed("<h1>Title</h1>")) },
95
- ]),
138
+ Router.mount("/page", Route.html(Effect.succeed("<h1>Title</h1>"))),
96
139
  )
97
140
 
98
141
  const response = await fetch("/page")
@@ -103,14 +146,12 @@ t.describe(`${BunRoute.routesFromRouter.name}`, () => {
103
146
 
104
147
  t.test("handles method-specific routes", async () => {
105
148
  const fetch = await makeFetch(
106
- makeRouter([
107
- {
108
- path: "/users",
109
- routes: Route.get(Route.json(Effect.succeed({ users: [] }))).post(
110
- Route.json(Effect.succeed({ created: true })),
111
- ),
112
- },
113
- ]),
149
+ Router.mount(
150
+ "/users",
151
+ Route.get(Route.json({ users: [] })).post(
152
+ Route.json({ created: true }),
153
+ ),
154
+ ),
114
155
  )
115
156
 
116
157
  const getResponse = await fetch("/users")
@@ -121,16 +162,10 @@ t.describe(`${BunRoute.routesFromRouter.name}`, () => {
121
162
  })
122
163
 
123
164
  t.test("converts path syntax to Bun format", async () => {
124
- const routes = await Effect.runPromise(
125
- BunRoute.routesFromRouter(
126
- makeRouter([
127
- { path: "/users/[id]", routes: Route.text(Effect.succeed("user")) },
128
- {
129
- path: "/docs/[...path]",
130
- routes: Route.text(Effect.succeed("docs")),
131
- },
132
- ]),
133
- ),
165
+ const routes = await makeBunRoutes(
166
+ Router
167
+ .mount("/users/[id]", Route.text("user"))
168
+ .mount("/docs/[...path]", Route.text("docs")),
134
169
  )
135
170
 
136
171
  t.expect(routes["/users/:id"]).toBeDefined()
@@ -141,17 +176,16 @@ t.describe(`${BunRoute.routesFromRouter.name}`, () => {
141
176
 
142
177
  t.test("creates proxy and internal routes for BunRoute", async () => {
143
178
  const mockBundle = { index: "index.html" } as HTMLBundle
144
- const bunRoute = BunRoute.loadBundle(() => Promise.resolve(mockBundle))
179
+ const bunRoute = BunRoute.html(() => Promise.resolve(mockBundle))
145
180
 
146
- const routes = await Effect.runPromise(
147
- BunRoute.routesFromRouter(
148
- makeRouter([{ path: "/app", routes: bunRoute }]),
149
- ),
181
+ const routes = await makeBunRoutes(
182
+ Router.mount("/app", bunRoute),
150
183
  )
151
184
 
152
185
  const internalPath = Object.keys(routes).find((k) =>
153
- k.includes("~BunRoute-")
186
+ k.includes(".BunRoute-")
154
187
  )
188
+
155
189
  t.expect(internalPath).toBeDefined()
156
190
  t.expect(routes[internalPath!]).toBe(mockBundle)
157
191
  t.expect(typeof routes["/app"]).toBe("function")
@@ -159,29 +193,18 @@ t.describe(`${BunRoute.routesFromRouter.name}`, () => {
159
193
 
160
194
  t.test("handles mixed BunRoute and regular routes", async () => {
161
195
  const mockBundle = { index: "index.html" } as HTMLBundle
162
- const bunRoute = BunRoute.loadBundle(() => Promise.resolve(mockBundle))
163
-
164
- const routes = await Effect.runPromise(
165
- BunRoute.routesFromRouter({
166
- routes: [
167
- {
168
- path: "/app",
169
- load: () => Promise.resolve({ default: bunRoute }),
170
- },
171
- {
172
- path: "/api/health",
173
- load: () =>
174
- Promise.resolve({
175
- default: Route.json(Effect.succeed({ ok: true })),
176
- }),
177
- },
178
- ],
179
- }),
196
+ const bunRoute = BunRoute.html(() => Promise.resolve(mockBundle))
197
+
198
+ const routes = await makeBunRoutes(
199
+ Router
200
+ .mount("/app", bunRoute)
201
+ .mount("/api/health", Route.json({ ok: true })),
180
202
  )
181
203
 
182
204
  const internalPath = Object.keys(routes).find((k) =>
183
- k.includes("~BunRoute-")
205
+ k.includes(".BunRoute-")
184
206
  )
207
+
185
208
  t.expect(internalPath).toBeDefined()
186
209
  t.expect(routes[internalPath!]).toBe(mockBundle)
187
210
  t.expect(typeof routes["/app"]).toBe("function")
@@ -191,15 +214,13 @@ t.describe(`${BunRoute.routesFromRouter.name}`, () => {
191
214
 
192
215
  t.test("groups multiple methods under same path", async () => {
193
216
  const fetch = await makeFetch(
194
- makeRouter([
195
- {
196
- path: "/resource",
197
- routes: Route
198
- .get(Route.text(Effect.succeed("get")))
199
- .post(Route.text(Effect.succeed("post")))
200
- .del(Route.text(Effect.succeed("delete"))),
201
- },
202
- ]),
217
+ Router.mount(
218
+ "/resource",
219
+ Route
220
+ .get(Route.text("get"))
221
+ .post(Route.text("post"))
222
+ .delete(Route.text("delete")),
223
+ ),
203
224
  )
204
225
 
205
226
  const getRes = await fetch("/resource")
@@ -215,10 +236,7 @@ t.describe(`${BunRoute.routesFromRouter.name}`, () => {
215
236
  t.describe("fetch handler Response", () => {
216
237
  t.test("returns Response instance", async () => {
217
238
  const fetch = await makeFetch(
218
- makeRouter([{
219
- path: "/test",
220
- routes: Route.text(Effect.succeed("test")),
221
- }]),
239
+ Router.mount("/test", Route.text("test")),
222
240
  )
223
241
 
224
242
  const response = await fetch("/test")
@@ -228,10 +246,7 @@ t.describe("fetch handler Response", () => {
228
246
 
229
247
  t.test("text response has correct content-type", async () => {
230
248
  const fetch = await makeFetch(
231
- makeRouter([{
232
- path: "/text",
233
- routes: Route.text(Effect.succeed("hello")),
234
- }]),
249
+ Router.mount("/text", Route.text("hello")),
235
250
  )
236
251
 
237
252
  const response = await fetch("/text")
@@ -241,10 +256,7 @@ t.describe("fetch handler Response", () => {
241
256
 
242
257
  t.test("json response has correct content-type", async () => {
243
258
  const fetch = await makeFetch(
244
- makeRouter([{
245
- path: "/json",
246
- routes: Route.json(Effect.succeed({ data: 1 })),
247
- }]),
259
+ Router.mount("/json", Route.json({ data: 1 })),
248
260
  )
249
261
 
250
262
  const response = await fetch("/json")
@@ -254,10 +266,7 @@ t.describe("fetch handler Response", () => {
254
266
 
255
267
  t.test("html response has correct content-type", async () => {
256
268
  const fetch = await makeFetch(
257
- makeRouter([{
258
- path: "/html",
259
- routes: Route.html(Effect.succeed("<p>hi</p>")),
260
- }]),
269
+ Router.mount("/html", Route.html(Effect.succeed("<p>hi</p>"))),
261
270
  )
262
271
 
263
272
  const response = await fetch("/html")
@@ -267,10 +276,7 @@ t.describe("fetch handler Response", () => {
267
276
 
268
277
  t.test("response body is readable", async () => {
269
278
  const fetch = await makeFetch(
270
- makeRouter([{
271
- path: "/body",
272
- routes: Route.text(Effect.succeed("readable body")),
273
- }]),
279
+ Router.mount("/body", Route.text("readable body")),
274
280
  )
275
281
 
276
282
  const response = await fetch("/body")
@@ -283,7 +289,7 @@ t.describe("fetch handler Response", () => {
283
289
 
284
290
  t.test("response ok is true for 200 status", async () => {
285
291
  const fetch = await makeFetch(
286
- makeRouter([{ path: "/ok", routes: Route.text(Effect.succeed("ok")) }]),
292
+ Router.mount("/ok", Route.text("ok")),
287
293
  )
288
294
 
289
295
  const response = await fetch("/ok")
@@ -293,18 +299,174 @@ t.describe("fetch handler Response", () => {
293
299
  })
294
300
  })
295
301
 
296
- const makeRouter = (
297
- routesList: Array<{
298
- path: `/${string}`
299
- routes: Route.RouteSet.Default
300
- }>,
301
- ): Router.RouterContext => ({
302
- routes: routesList.map((m) => ({
303
- path: m.path,
304
- load: () => Promise.resolve({ default: m.routes }),
305
- })),
302
+ t.describe("Route.layer httpMiddleware", () => {
303
+ t.test("applies middleware headers to child routes", async () => {
304
+ const addHeader = HttpMiddleware.make((app) =>
305
+ Effect.gen(function*() {
306
+ const response = yield* app
307
+ return HttpServerResponse.setHeader(response, "X-Layer-Applied", "true")
308
+ })
309
+ ) as Route.HttpMiddlewareFunction
310
+
311
+ const router = Router
312
+ .use(Route.layer(Route.http(addHeader)))
313
+ .mount("/child", Route.text("child content"))
314
+
315
+ const fetch = await makeFetch(router)
316
+ const response = await fetch("/child")
317
+
318
+ t.expect(response.headers.get("X-Layer-Applied")).toBe("true")
319
+ t.expect(await response.text()).toBe("child content")
320
+ })
321
+
322
+ t.test("middleware only applies to children, not siblings", async () => {
323
+ const addHeader = HttpMiddleware.make((app) =>
324
+ Effect.gen(function*() {
325
+ const response = yield* app
326
+ return HttpServerResponse.setHeader(response, "X-Layer-Applied", "true")
327
+ })
328
+ ) as Route.HttpMiddlewareFunction
329
+
330
+ const router = Router
331
+ .mount("/outside", Route.text("outside content"))
332
+ .use(Route.layer(Route.http(addHeader)))
333
+ .mount("/inside", Route.text("inside content"))
334
+
335
+ const fetch = await makeFetch(router)
336
+
337
+ const insideResponse = await fetch("/inside")
338
+ t.expect(insideResponse.headers.get("X-Layer-Applied")).toBe("true")
339
+
340
+ const outsideResponse = await fetch("/outside")
341
+ t.expect(outsideResponse.headers.get("X-Layer-Applied")).toBeNull()
342
+ })
343
+
344
+ t.test("multiple middleware are applied in order", async () => {
345
+ const addHeader1 = HttpMiddleware.make((app) =>
346
+ Effect.gen(function*() {
347
+ const response = yield* app
348
+ return HttpServerResponse.setHeader(response, "X-First", "1")
349
+ })
350
+ ) as Route.HttpMiddlewareFunction
351
+
352
+ const addHeader2 = HttpMiddleware.make((app) =>
353
+ Effect.gen(function*() {
354
+ const response = yield* app
355
+ return HttpServerResponse.setHeader(response, "X-Second", "2")
356
+ })
357
+ ) as Route.HttpMiddlewareFunction
358
+
359
+ const router = Router
360
+ .use(Route.layer(Route.http(addHeader1), Route.http(addHeader2)))
361
+ .mount("/test", Route.text("test"))
362
+
363
+ const fetch = await makeFetch(router)
364
+ const response = await fetch("/test")
365
+
366
+ t.expect(response.headers.get("X-First")).toBe("1")
367
+ t.expect(response.headers.get("X-Second")).toBe("2")
368
+ })
369
+
370
+ t.test("nested layers apply all middleware", async () => {
371
+ const outerHeader = HttpMiddleware.make((app) =>
372
+ Effect.gen(function*() {
373
+ const response = yield* app
374
+ return HttpServerResponse.setHeader(response, "X-Outer", "outer")
375
+ })
376
+ ) as Route.HttpMiddlewareFunction
377
+
378
+ const innerHeader = HttpMiddleware.make((app) =>
379
+ Effect.gen(function*() {
380
+ const response = yield* app
381
+ return HttpServerResponse.setHeader(response, "X-Inner", "inner")
382
+ })
383
+ ) as Route.HttpMiddlewareFunction
384
+
385
+ const router = Router
386
+ .use(Route.layer(Route.http(outerHeader)))
387
+ .use(Route.layer(Route.http(innerHeader)))
388
+ .mount("/nested", Route.text("nested"))
389
+
390
+ const fetch = await makeFetch(router)
391
+ const response = await fetch("/nested")
392
+
393
+ t.expect(response.headers.get("X-Outer")).toBe("outer")
394
+ t.expect(response.headers.get("X-Inner")).toBe("inner")
395
+ })
396
+ })
397
+
398
+ t.describe("BunRoute placeholder replacement", () => {
399
+ t.test("%yield% is replaced with nested route content via layer", async () => {
400
+ const layoutBundle = BunRoute.html(() =>
401
+ import("../../static/LayoutSlots.html")
402
+ )
403
+
404
+ const router = Router
405
+ .use(Route.layer(layoutBundle))
406
+ .mount("/page", Route.html(Effect.succeed("<p>Child Content</p>")))
407
+
408
+ await Effect.runPromise(
409
+ Effect
410
+ .gen(function*() {
411
+ const bunServer = yield* BunHttpServer.BunServer
412
+ const routes = yield* BunRoute.routesFromRouter(router)
413
+ bunServer.addRoutes(routes)
414
+
415
+ const baseUrl =
416
+ `http://${bunServer.server.hostname}:${bunServer.server.port}`
417
+ const response = yield* Effect.promise(() => fetch(`${baseUrl}/page`))
418
+
419
+ const html = yield* Effect.promise(() => response.text())
420
+
421
+ t.expect(html).toContain("<body>")
422
+ t.expect(html).toContain("<p>Child Content</p>")
423
+ t.expect(html).not.toContain("%yield%")
424
+ t.expect(html).not.toContain("%slots.")
425
+ })
426
+ .pipe(
427
+ Effect.scoped,
428
+ Effect.provide(BunHttpServer.layer({ port: 0 })),
429
+ ),
430
+ )
431
+ })
432
+
433
+ t.test("%yield% and %slots% are replaced with empty when no nested content", async () => {
434
+ const layoutBundle = BunRoute.html(() =>
435
+ import("../../static/LayoutSlots.html")
436
+ )
437
+
438
+ const router = Router.mount("/layout", layoutBundle)
439
+
440
+ await Effect.runPromise(
441
+ Effect
442
+ .gen(function*() {
443
+ const bunServer = yield* BunHttpServer.BunServer
444
+ const routes = yield* BunRoute.routesFromRouter(router)
445
+ bunServer.addRoutes(routes)
446
+
447
+ const baseUrl =
448
+ `http://${bunServer.server.hostname}:${bunServer.server.port}`
449
+ const response = yield* Effect.promise(() =>
450
+ fetch(`${baseUrl}/layout`)
451
+ )
452
+
453
+ const html = yield* Effect.promise(() => response.text())
454
+
455
+ t.expect(html).toContain("<title></title>")
456
+ t.expect(html).toContain("<body>")
457
+ t.expect(html).not.toContain("%yield%")
458
+ t.expect(html).not.toContain("%slots.")
459
+ })
460
+ .pipe(
461
+ Effect.scoped,
462
+ Effect.provide(BunHttpServer.layer({ port: 0 })),
463
+ ),
464
+ )
465
+ })
306
466
  })
307
467
 
468
+
469
+
308
470
  type FetchFn = (path: string, init?: { method?: string }) => Promise<Response>
309
471
 
310
472
  type HandlerFn = (
@@ -312,8 +474,19 @@ type HandlerFn = (
312
474
  server: unknown,
313
475
  ) => Response | Promise<Response>
314
476
 
315
- async function makeFetch(router: Router.RouterContext): Promise<FetchFn> {
316
- const routes = await Effect.runPromise(BunRoute.routesFromRouter(router))
477
+
478
+ async function makeBunRoutes(
479
+ router: Router.RouterBuilder.Any,
480
+ ): Promise<BunRoute.BunRoutes> {
481
+ return Effect.runPromise(
482
+ BunRoute.routesFromRouter(router).pipe(
483
+ Effect.provide(BunHttpServer.layer({ port: 0 })),
484
+ ),
485
+ )
486
+ }
487
+
488
+ async function makeFetch(router: Router.RouterBuilder.Any): Promise<FetchFn> {
489
+ const routes = await makeBunRoutes(router)
317
490
  const mockServer = {} as import("bun").Server<unknown>
318
491
 
319
492
  return async (path, init) => {