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.
package/src/Route.ts CHANGED
@@ -2,9 +2,7 @@ import * as HttpMethod from "@effect/platform/HttpMethod"
2
2
  import * as HttpMiddleware from "@effect/platform/HttpMiddleware"
3
3
  import * as HttpServerRequest from "@effect/platform/HttpServerRequest"
4
4
  import * as HttpServerResponse from "@effect/platform/HttpServerResponse"
5
-
6
5
  import * as Effect from "effect/Effect"
7
- import * as Function from "effect/Function"
8
6
  import * as Pipeable from "effect/Pipeable"
9
7
  import * as Predicate from "effect/Predicate"
10
8
  import * as Schema from "effect/Schema"
@@ -71,6 +69,7 @@ export type RouteMethod =
71
69
  | "*"
72
70
  | HttpMethod.HttpMethod
73
71
 
72
+ // TODO: This should be a RouterPattern and moved to its file?
74
73
  export type RoutePattern = `/${string}`
75
74
 
76
75
  /**
@@ -185,7 +184,7 @@ type RouteBuilder = {
185
184
  get: typeof get
186
185
  put: typeof put
187
186
  patch: typeof patch
188
- del: typeof del
187
+ delete: typeof _delete
189
188
  options: typeof options
190
189
  head: typeof head
191
190
 
@@ -285,20 +284,26 @@ export function matches(
285
284
  }
286
285
 
287
286
  export const post = makeMethodModifier("POST")
288
-
289
287
  export const get = makeMethodModifier("GET")
290
288
  export const put = makeMethodModifier("PUT")
291
289
  export const patch = makeMethodModifier("PATCH")
292
- export const del = makeMethodModifier("DELETE")
293
290
  export const options = makeMethodModifier("OPTIONS")
294
291
  export const head = makeMethodModifier("HEAD")
292
+ const _delete = makeMethodModifier("DELETE")
293
+ export {
294
+ _delete as delete,
295
+ }
295
296
 
296
297
  export const text = makeMediaFunction<"GET", "text/plain", string>(
297
298
  "GET",
298
299
  "text/plain",
299
300
  )
300
301
 
301
- export const html = makeMediaFunction<"GET", "text/html", string | JsxObject>(
302
+ export const html = makeMediaFunction<
303
+ "GET",
304
+ "text/html",
305
+ string | GenericJsxObject
306
+ >(
302
307
  "GET",
303
308
  "text/html",
304
309
  )
@@ -525,7 +530,7 @@ const SetProto = {
525
530
  get,
526
531
  put,
527
532
  patch,
528
- del,
533
+ delete: _delete,
529
534
  options,
530
535
  head,
531
536
 
@@ -611,21 +616,21 @@ export type DecodeRouteSchemas<Schemas extends RouteSchemas> =
611
616
 
612
617
  /**
613
618
  * Context passed to route handler functions.
619
+ *
620
+ * @template {Input} Decoded schema values (pathParams, urlParams, etc.)
621
+ * @template {Next} Return type of next() based on media type
614
622
  */
615
623
  export type RouteContext<
616
624
  Input extends RouteContextDecoded = {},
617
- > = {
618
- request: HttpServerRequest.HttpServerRequest
619
- get url(): URL
620
- slots: Record<string, string>
621
- next: () => Effect.Effect<any, any, any>
622
- } & Input
623
-
624
- /**
625
- * Extracts fields from a Schema.Struct or returns never if not a struct.
626
- */
627
- type ExtractStructFields<S> = S extends Schema.Struct<infer Fields> ? Fields
628
- : never
625
+ Next = unknown,
626
+ > =
627
+ & {
628
+ request: HttpServerRequest.HttpServerRequest
629
+ get url(): URL
630
+ slots: Record<string, string>
631
+ next: <E = unknown, R = unknown>() => Effect.Effect<Next, E, R>
632
+ }
633
+ & Input
629
634
 
630
635
  /**
631
636
  * Merges two RouteSchemas types.
@@ -789,6 +794,31 @@ function makeSet<
789
794
  ) as RouteSet<M, Schemas>
790
795
  }
791
796
 
797
+ type HandlerInput<A, E, R> =
798
+ | A
799
+ | Effect.Effect<A, E, R>
800
+ | ((context: RouteContext) =>
801
+ | Effect.Effect<A, E, R>
802
+ | Generator<YieldWrap<Effect.Effect<A, E, R>>, A, never>)
803
+
804
+ function normalizeHandler<A, E, R>(
805
+ handler: HandlerInput<A, E, R>,
806
+ ): RouteHandler<A, E, R> {
807
+ if (typeof handler === "function") {
808
+ return (context): Effect.Effect<A, E, R> => {
809
+ const result = (handler as Function)(context)
810
+ if (Effect.isEffect(result)) {
811
+ return result as Effect.Effect<A, E, R>
812
+ }
813
+ return Effect.gen(() => result) as Effect.Effect<A, E, R>
814
+ }
815
+ }
816
+ if (Effect.isEffect(handler)) {
817
+ return () => handler
818
+ }
819
+ return () => Effect.succeed(handler as A)
820
+ }
821
+
792
822
  /**
793
823
  * Factory function that creates Route for a specific method & media.
794
824
  * Accepts Effect, function that returns Effect, and effectful generator.
@@ -809,13 +839,17 @@ function makeMediaFunction<
809
839
  >(
810
840
  this: S,
811
841
  handler: S extends RouteSet<infer _Routes, infer Schemas> ?
842
+ | A
812
843
  | Effect.Effect<A, E, R>
813
- | ((context: RouteContext<DecodeRouteSchemas<Schemas>>) =>
844
+ | ((
845
+ context: RouteContext<DecodeRouteSchemas<Schemas>, ExpectedValue>,
846
+ ) =>
814
847
  | Effect.Effect<A, E, R>
815
848
  | Generator<YieldWrap<Effect.Effect<A, E, R>>, A, never>)
816
849
  :
850
+ | A
817
851
  | Effect.Effect<A, E, R>
818
- | ((context: RouteContext<{}>) =>
852
+ | ((context: RouteContext<{}, ExpectedValue>) =>
819
853
  | Effect.Effect<A, E, R>
820
854
  | Generator<YieldWrap<Effect.Effect<A, E, R>>, A, never>),
821
855
  ): S extends RouteSet<infer Routes, infer Schemas> ? RouteSet<[
@@ -836,23 +870,6 @@ function makeMediaFunction<
836
870
  >,
837
871
  ], RouteSchemas.Empty>
838
872
  {
839
- const normalizedHandler: RouteHandler<A, E, R> =
840
- typeof handler === "function"
841
- ? (context: RouteContext): Effect.Effect<A, E, R> => {
842
- const result = handler(context)
843
- if (Effect.isEffect(result)) {
844
- return result
845
- }
846
- return Effect.gen(() =>
847
- result as Generator<
848
- YieldWrap<Effect.Effect<A, E, R>>,
849
- A,
850
- never
851
- >
852
- ) as Effect.Effect<A, E, R>
853
- }
854
- : () => handler
855
-
856
873
  const baseRoutes = isRouteSet(this)
857
874
  ? this.set
858
875
  : [] as const
@@ -866,7 +883,7 @@ function makeMediaFunction<
866
883
  make({
867
884
  method,
868
885
  media,
869
- handler: normalizedHandler,
886
+ handler: normalizeHandler(handler),
870
887
  schemas: baseSchema,
871
888
  }),
872
889
  ] as ReadonlyArray<Route.Default>,
@@ -952,12 +969,12 @@ function makeMethodModifier<
952
969
  }
953
970
  }
954
971
 
955
- export type JsxObject = {
972
+ export type GenericJsxObject = {
956
973
  type: any
957
974
  props: any
958
975
  }
959
976
 
960
- export function isJsxObject(value: unknown): value is JsxObject {
977
+ export function isGenericJsxObject(value: unknown): value is GenericJsxObject {
961
978
  return typeof value === "object"
962
979
  && value !== null
963
980
  && "type" in value
@@ -1021,14 +1038,7 @@ export function layer(
1021
1038
  const layerRoutes: Route.Default[] = []
1022
1039
 
1023
1040
  for (const routeSet of routeSets) {
1024
- for (const route of routeSet.set) {
1025
- layerRoutes.push(make({
1026
- method: route.method,
1027
- media: route.media,
1028
- handler: route.handler,
1029
- schemas: {},
1030
- }))
1031
- }
1041
+ layerRoutes.push(...routeSet.set)
1032
1042
  }
1033
1043
 
1034
1044
  const middlewareFunctions = routeMiddleware.map((spec) => spec.middleware)
@@ -14,12 +14,17 @@ export function render<E, R>(
14
14
  return Effect.gen(function*() {
15
15
  const raw = yield* route.handler(context)
16
16
 
17
+ // Allow handlers to return HttpServerResponse directly (e.g. BunRoute proxy)
18
+ if (HttpServerResponse.isServerResponse(raw)) {
19
+ return raw
20
+ }
21
+
17
22
  switch (route.media) {
18
23
  case "text/plain":
19
24
  return HttpServerResponse.text(raw as string)
20
25
 
21
26
  case "text/html":
22
- if (Route.isJsxObject(raw)) {
27
+ if (Route.isGenericJsxObject(raw)) {
23
28
  return HttpServerResponse.html(HyperHtml.renderToString(raw))
24
29
  }
25
30
  return HttpServerResponse.html(raw as string)
@@ -29,9 +34,6 @@ export function render<E, R>(
29
34
 
30
35
  case "*":
31
36
  default:
32
- if (HttpServerResponse.isServerResponse(raw)) {
33
- return raw
34
- }
35
37
  return HttpServerResponse.text(String(raw))
36
38
  }
37
39
  })
@@ -0,0 +1,416 @@
1
+ import * as t from "bun:test"
2
+ import * as Effect from "effect/Effect"
3
+ import * as Route from "./Route.ts"
4
+ import * as Router from "./Router.ts"
5
+
6
+ t.describe("Router", () => {
7
+ t.describe("mount", () => {
8
+ t.test("creates router with single route", () => {
9
+ const router = Router.mount("/hello", Route.text("Hello World"))
10
+
11
+ t.expect(router.entries).toHaveLength(1)
12
+ t.expect(router.entries[0].path).toBe("/hello")
13
+ t.expect(router.entries[0].route.set).toHaveLength(1)
14
+ })
15
+
16
+ t.test("chains multiple routes", () => {
17
+ const router = Router
18
+ .mount("/hello", Route.text("Hello"))
19
+ .mount("/world", Route.text("World"))
20
+
21
+ t.expect(router.entries).toHaveLength(2)
22
+ t.expect(router.entries[0].path).toBe("/hello")
23
+ t.expect(router.entries[1].path).toBe("/world")
24
+ })
25
+
26
+ t.test("merges routes at same path", () => {
27
+ const router = Router
28
+ .mount("/api", Route.get(Route.json({ method: "get" })))
29
+ .mount("/api", Route.post(Route.json({ method: "post" })))
30
+
31
+ t.expect(router.entries).toHaveLength(1)
32
+ t.expect(router.entries[0].path).toBe("/api")
33
+ })
34
+ })
35
+
36
+ t.describe("mounts", () => {
37
+ t.test("exposes mounted routes as Record", () => {
38
+ const router = Router
39
+ .mount("/hello", Route.text("Hello"))
40
+ .mount("/world", Route.text("World"))
41
+
42
+ t.expect(router.mounts["/hello"]).toBeDefined()
43
+ t.expect(router.mounts["/world"]).toBeDefined()
44
+ t.expect(router.mounts["/hello"].set).toHaveLength(1)
45
+ })
46
+
47
+ t.test("mounts contain routes with layers applied", async () => {
48
+ const layer = Route.layer(
49
+ Route.html(function*(c) {
50
+ const inner = yield* c.next()
51
+ return `<wrap>${inner}</wrap>`
52
+ }),
53
+ )
54
+
55
+ const router = Router
56
+ .use(layer)
57
+ .mount("/page", Route.html(Effect.succeed("content")))
58
+
59
+ const mountedRoute = router.mounts["/page"]
60
+ t.expect(mountedRoute).toBeDefined()
61
+ t.expect(mountedRoute.set).toHaveLength(1)
62
+
63
+ const route = mountedRoute.set[0]
64
+ const mockContext: Route.RouteContext = {
65
+ request: {} as any,
66
+ url: new URL("http://localhost/page"),
67
+ slots: {},
68
+ next: () => Effect.void,
69
+ }
70
+
71
+ const result = await Effect.runPromise(
72
+ route.handler(mockContext) as Effect.Effect<unknown>,
73
+ )
74
+
75
+ t.expect(result).toBe("<wrap>content</wrap>")
76
+ })
77
+ })
78
+
79
+ t.describe("use", () => {
80
+ t.test("adds global layer", () => {
81
+ const layer = Route.layer(
82
+ Route.html(function*(c) {
83
+ const inner = yield* c.next()
84
+ return `<html><body>${inner}</body></html>`
85
+ }),
86
+ )
87
+
88
+ const router = Router.use(layer)
89
+
90
+ t.expect(router.globalLayers).toHaveLength(1)
91
+ t.expect(router.entries).toHaveLength(0)
92
+ })
93
+
94
+ t.test("applies layer to subsequently mounted routes", () => {
95
+ const layer = Route.layer(
96
+ Route.html(function*(c) {
97
+ const inner = yield* c.next()
98
+ return `<html><body>${inner}</body></html>`
99
+ }),
100
+ )
101
+
102
+ const router = Router
103
+ .use(layer)
104
+ .mount("/", Route.text("Hello world!"))
105
+
106
+ t.expect(router.globalLayers).toHaveLength(1)
107
+ t.expect(router.entries).toHaveLength(1)
108
+ t.expect(router.entries[0].layers).toHaveLength(1)
109
+ })
110
+
111
+ t.test("layer only applies to routes mounted after use()", async () => {
112
+ const layer = Route.layer(
113
+ Route.html(function*(c) {
114
+ const inner = yield* c.next()
115
+ return `<wrap>${inner}</wrap>`
116
+ }),
117
+ )
118
+
119
+ const router = Router
120
+ .mount("/before", Route.html(Effect.succeed("before-content")))
121
+ .use(layer)
122
+ .mount("/after", Route.html(Effect.succeed("after-content")))
123
+
124
+ const mockContext = (path: string): Route.RouteContext => ({
125
+ request: {} as any,
126
+ url: new URL(`http://localhost${path}`),
127
+ slots: {},
128
+ next: () => Effect.void,
129
+ })
130
+
131
+ const beforeRoute = router.mounts["/before"].set[0]
132
+ const afterRoute = router.mounts["/after"].set[0]
133
+
134
+ const beforeResult = await Effect.runPromise(
135
+ beforeRoute.handler(mockContext("/before")) as Effect.Effect<unknown>,
136
+ )
137
+ const afterResult = await Effect.runPromise(
138
+ afterRoute.handler(mockContext("/after")) as Effect.Effect<unknown>,
139
+ )
140
+
141
+ t.expect(beforeResult).toBe("before-content")
142
+ t.expect(afterResult).toBe("<wrap>after-content</wrap>")
143
+ })
144
+ })
145
+
146
+ t.describe("layer application - runtime behavior", () => {
147
+ t.test("layer handler wraps route handler", async () => {
148
+ const layer = Route.layer(
149
+ Route.html(function*(c) {
150
+ const inner = yield* c.next()
151
+ return `<wrap>${inner}</wrap>`
152
+ }),
153
+ )
154
+
155
+ const router = Router
156
+ .use(layer)
157
+ .mount("/page", Route.html(Effect.succeed("content")))
158
+
159
+ const mountedRoute = router.mounts["/page"]
160
+ const route = mountedRoute.set[0]
161
+
162
+ const mockContext: Route.RouteContext = {
163
+ request: {} as any,
164
+ url: new URL("http://localhost/page"),
165
+ slots: {},
166
+ next: () => Effect.succeed("unused"),
167
+ }
168
+
169
+ const result = await Effect.runPromise(
170
+ route.handler(mockContext) as Effect.Effect<unknown>,
171
+ )
172
+
173
+ t.expect(result).toBe("<wrap>content</wrap>")
174
+ })
175
+
176
+ t.test("multiple layers are applied in order", async () => {
177
+ const outerLayer = Route.layer(
178
+ Route.html(function*(c) {
179
+ const inner = yield* c.next()
180
+ return `<outer>${inner}</outer>`
181
+ }),
182
+ )
183
+
184
+ const innerLayer = Route.layer(
185
+ Route.html(function*(c) {
186
+ const inner = yield* c.next()
187
+ return `<inner>${inner}</inner>`
188
+ }),
189
+ )
190
+
191
+ const router = Router
192
+ .use(outerLayer)
193
+ .use(innerLayer)
194
+ .mount("/page", Route.html(Effect.succeed("content")))
195
+
196
+ const mountedRoute = router.mounts["/page"]
197
+ const route = mountedRoute.set[0]
198
+
199
+ const mockContext: Route.RouteContext = {
200
+ request: {} as any,
201
+ url: new URL("http://localhost/page"),
202
+ slots: {},
203
+ next: () => Effect.succeed("unused"),
204
+ }
205
+
206
+ const result = await Effect.runPromise(
207
+ route.handler(mockContext) as Effect.Effect<unknown>,
208
+ )
209
+
210
+ t.expect(result).toBe("<outer><inner>content</inner></outer>")
211
+ })
212
+
213
+ t.test("layer only applies to matching media type", async () => {
214
+ const htmlLayer = Route.layer(
215
+ Route.html(function*(c) {
216
+ const inner = yield* c.next()
217
+ return `<wrap>${inner}</wrap>`
218
+ }),
219
+ )
220
+
221
+ const router = Router
222
+ .use(htmlLayer)
223
+ .mount("/api", Route.json({ data: "value" }))
224
+
225
+ const mountedRoute = router.mounts["/api"]
226
+ const route = mountedRoute.set[0]
227
+
228
+ const mockContext: Route.RouteContext = {
229
+ request: {} as any,
230
+ url: new URL("http://localhost/api"),
231
+ slots: {},
232
+ next: () => Effect.succeed("unused"),
233
+ }
234
+
235
+ const result = await Effect.runPromise(
236
+ route.handler(mockContext) as Effect.Effect<unknown>,
237
+ )
238
+
239
+ t.expect(result).toEqual({ data: "value" })
240
+ })
241
+
242
+ t.test("route without layer is not wrapped", async () => {
243
+ const router = Router.mount("/hello", Route.text("Hello"))
244
+
245
+ const mountedRoute = router.mounts["/hello"]
246
+ const route = mountedRoute.set[0]
247
+
248
+ const mockContext: Route.RouteContext = {
249
+ request: {} as any,
250
+ url: new URL("http://localhost/hello"),
251
+ slots: {},
252
+ next: () => Effect.succeed("unused"),
253
+ }
254
+
255
+ const result = await Effect.runPromise(
256
+ route.handler(mockContext) as Effect.Effect<unknown>,
257
+ )
258
+
259
+ t.expect(result).toBe("Hello")
260
+ })
261
+ })
262
+
263
+ t.describe("type inference", () => {
264
+ t.test("infers never for routes without requirements", () => {
265
+ const router = Router.mount("/hello", Route.text("Hello"))
266
+
267
+ type RouterError = Router.RouterBuilder.Error<typeof router>
268
+ type RouterContext = Router.RouterBuilder.Context<typeof router>
269
+
270
+ const _checkError: RouterError = undefined as never
271
+ const _checkContext: RouterContext = undefined as never
272
+
273
+ t.expect(true).toBe(true)
274
+ })
275
+
276
+ t.test("infers error type from route handler", () => {
277
+ class MyError {
278
+ readonly _tag = "MyError"
279
+ }
280
+
281
+ const router = Router.mount(
282
+ "/fail",
283
+ Route.text(Effect.fail(new MyError())),
284
+ )
285
+
286
+ type RouterError = Router.RouterBuilder.Error<typeof router>
287
+
288
+ const _checkError: MyError extends RouterError ? true : false = true
289
+
290
+ t.expect(true).toBe(true)
291
+ })
292
+
293
+ t.test("infers context type from route handler", () => {
294
+ class MyService extends Effect.Tag("MyService")<
295
+ MyService,
296
+ { getValue(): string }
297
+ >() {}
298
+
299
+ const router = Router.mount(
300
+ "/service",
301
+ Route.text(
302
+ Effect.gen(function*() {
303
+ const svc = yield* MyService
304
+ return svc.getValue()
305
+ }),
306
+ ),
307
+ )
308
+
309
+ type RouterContext = Router.RouterBuilder.Context<typeof router>
310
+
311
+ const _checkContext: MyService extends RouterContext ? true : false = true
312
+
313
+ t.expect(true).toBe(true)
314
+ })
315
+
316
+ t.test("unions error types from multiple routes", () => {
317
+ class ErrorA {
318
+ readonly _tag = "ErrorA"
319
+ }
320
+ class ErrorB {
321
+ readonly _tag = "ErrorB"
322
+ }
323
+
324
+ const router = Router
325
+ .mount("/a", Route.text(Effect.fail(new ErrorA())))
326
+ .mount("/b", Route.text(Effect.fail(new ErrorB())))
327
+
328
+ type RouterError = Router.RouterBuilder.Error<typeof router>
329
+
330
+ const _checkA: ErrorA extends RouterError ? true : false = true
331
+ const _checkB: ErrorB extends RouterError ? true : false = true
332
+
333
+ t.expect(true).toBe(true)
334
+ })
335
+
336
+ t.test("unions context types from multiple routes", () => {
337
+ class ServiceA extends Effect.Tag("ServiceA")<
338
+ ServiceA,
339
+ { getA(): string }
340
+ >() {}
341
+ class ServiceB extends Effect.Tag("ServiceB")<
342
+ ServiceB,
343
+ { getB(): string }
344
+ >() {}
345
+
346
+ const router = Router
347
+ .mount(
348
+ "/a",
349
+ Route.text(
350
+ Effect.gen(function*() {
351
+ const svc = yield* ServiceA
352
+ return svc.getA()
353
+ }),
354
+ ),
355
+ )
356
+ .mount(
357
+ "/b",
358
+ Route.text(
359
+ Effect.gen(function*() {
360
+ const svc = yield* ServiceB
361
+ return svc.getB()
362
+ }),
363
+ ),
364
+ )
365
+
366
+ type RouterContext = Router.RouterBuilder.Context<typeof router>
367
+
368
+ const _checkA: ServiceA extends RouterContext ? true : false = true
369
+ const _checkB: ServiceB extends RouterContext ? true : false = true
370
+
371
+ t.expect(true).toBe(true)
372
+ })
373
+ })
374
+
375
+ t.describe("fromManifest", () => {
376
+ t.test("loads routes from manifest", async () => {
377
+ const manifest: Router.RouterManifest = {
378
+ routes: [
379
+ {
380
+ path: "/test",
381
+ load: () => Promise.resolve({ default: Route.text("Test") }),
382
+ },
383
+ ],
384
+ }
385
+
386
+ const router = await Effect.runPromise(Router.fromManifest(manifest))
387
+
388
+ t.expect(router.entries).toHaveLength(1)
389
+ t.expect(router.entries[0].path).toBe("/test")
390
+ })
391
+
392
+ t.test("loads layers from manifest", async () => {
393
+ const layer = Route.layer(
394
+ Route.html(function*(c) {
395
+ const inner = yield* c.next()
396
+ return `<wrap>${inner}</wrap>`
397
+ }),
398
+ )
399
+
400
+ const manifest: Router.RouterManifest = {
401
+ routes: [
402
+ {
403
+ path: "/test",
404
+ load: () => Promise.resolve({ default: Route.text("Test") }),
405
+ layers: [() => Promise.resolve({ default: layer })],
406
+ },
407
+ ],
408
+ }
409
+
410
+ const router = await Effect.runPromise(Router.fromManifest(manifest))
411
+
412
+ t.expect(router.entries).toHaveLength(1)
413
+ t.expect(router.entries[0].layers).toHaveLength(1)
414
+ })
415
+ })
416
+ })