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.
@@ -2,34 +2,119 @@ import * as HttpApp from "@effect/platform/HttpApp"
2
2
  import * as HttpServerRequest from "@effect/platform/HttpServerRequest"
3
3
  import * as HttpServerResponse from "@effect/platform/HttpServerResponse"
4
4
  import type * as Bun from "bun"
5
+ import * as Array from "effect/Array"
5
6
  import * as Effect from "effect/Effect"
6
-
7
7
  import * as Function from "effect/Function"
8
+ import * as Option from "effect/Option"
8
9
  import * as Predicate from "effect/Predicate"
9
10
  import type * as Runtime from "effect/Runtime"
11
+ import * as HttpAppExtra from "../HttpAppExtra.ts"
10
12
  import * as HttpUtils from "../HttpUtils.ts"
13
+ import * as HyperHtml from "../HyperHtml.ts"
11
14
  import * as Random from "../Random.ts"
12
15
  import * as Route from "../Route.ts"
13
16
  import * as Router from "../Router.ts"
14
17
  import * as RouteRender from "../RouteRender.ts"
15
18
  import * as RouterPattern from "../RouterPattern.ts"
19
+ import * as BunHttpServer from "./BunHttpServer.ts"
16
20
 
17
21
  const TypeId: unique symbol = Symbol.for("effect-start/BunRoute")
18
22
 
23
+ const INTERNAL_FETCH_HEADER = "x-effect-start-internal-fetch"
24
+
19
25
  export type BunRoute =
20
26
  & Route.Route
21
27
  & {
22
28
  [TypeId]: typeof TypeId
29
+ // Prefix because Bun.serve routes ignore everything after `*` in wildcard patterns.
30
+ // A suffix like `/*~internal` would match the same as `/*`, shadowing the internal route.
31
+ internalPathPrefix: string
23
32
  load: () => Promise<Bun.HTMLBundle>
24
33
  }
25
34
 
26
- export function loadBundle(
35
+ export function html(
27
36
  load: () => Promise<Bun.HTMLBundle | { default: Bun.HTMLBundle }>,
28
37
  ): BunRoute {
38
+ const internalPathPrefix = `/.BunRoute-${Random.token(6)}`
39
+
40
+ const handler: Route.RouteHandler<
41
+ HttpServerResponse.HttpServerResponse,
42
+ Router.RouterError,
43
+ BunHttpServer.BunServer
44
+ > = (context) =>
45
+ Effect.gen(function*() {
46
+ const originalRequest = context.request.source as Request
47
+
48
+ if (
49
+ originalRequest.headers.get(INTERNAL_FETCH_HEADER) === "true"
50
+ ) {
51
+ return yield* Effect.fail(
52
+ new Router.RouterError({
53
+ reason: "ProxyError",
54
+ pattern: context.url.pathname,
55
+ message:
56
+ "Request to internal Bun server was caught by BunRoute handler. This should not happen. Please report a bug.",
57
+ }),
58
+ )
59
+ }
60
+
61
+ const bunServer = yield* BunHttpServer.BunServer
62
+ const internalPath = `${internalPathPrefix}${context.url.pathname}`
63
+ const internalUrl = new URL(internalPath, bunServer.server.url)
64
+
65
+ const headers = new Headers(originalRequest.headers)
66
+ headers.set(INTERNAL_FETCH_HEADER, "true")
67
+
68
+ const proxyRequest = new Request(internalUrl, {
69
+ method: originalRequest.method,
70
+ headers,
71
+ })
72
+
73
+ const response = yield* Effect.tryPromise({
74
+ try: () => fetch(proxyRequest),
75
+ catch: (error) =>
76
+ new Router.RouterError({
77
+ reason: "ProxyError",
78
+ pattern: internalPath,
79
+ message: `Failed to fetch internal HTML bundle: ${String(error)}`,
80
+ }),
81
+ })
82
+
83
+ let html = yield* Effect.tryPromise({
84
+ try: () => response.text(),
85
+ catch: (error) =>
86
+ new Router.RouterError({
87
+ reason: "ProxyError",
88
+ pattern: internalPath,
89
+ message: String(error),
90
+ }),
91
+ })
92
+
93
+ const children = yield* context.next<Router.RouterError, never>()
94
+ let childrenHtml = ""
95
+ if (children != null) {
96
+ if (HttpServerResponse.isServerResponse(children)) {
97
+ const webResponse = HttpServerResponse.toWeb(children)
98
+ childrenHtml = yield* Effect.promise(() => webResponse.text())
99
+ } else if (Route.isGenericJsxObject(children)) {
100
+ childrenHtml = HyperHtml.renderToString(children)
101
+ } else {
102
+ childrenHtml = String(children)
103
+ }
104
+ }
105
+
106
+ html = html.replace(/%yield%/g, childrenHtml)
107
+ html = html.replace(/%slots\.(\w+)%/g, (_, name) =>
108
+ context.slots[name] ?? "")
109
+
110
+ return HttpServerResponse
111
+ .html(html)
112
+ })
113
+
29
114
  const route = Route.make({
30
- method: "GET",
115
+ method: "*",
31
116
  media: "text/html",
32
- handler: () => HttpServerResponse.text("Empty BunRoute"),
117
+ handler,
33
118
  schemas: {},
34
119
  })
35
120
 
@@ -37,11 +122,13 @@ export function loadBundle(
37
122
  Object.create(route),
38
123
  {
39
124
  [TypeId]: TypeId,
125
+ internalPathPrefix,
40
126
  load: () => load().then(mod => "default" in mod ? mod.default : mod),
127
+ set: [],
41
128
  },
42
129
  )
43
130
 
44
- bunRoute.set = [bunRoute]
131
+ bunRoute.set.push(bunRoute)
45
132
 
46
133
  return bunRoute
47
134
  }
@@ -50,41 +137,47 @@ export function isBunRoute(input: unknown): input is BunRoute {
50
137
  return Predicate.hasProperty(input, TypeId)
51
138
  }
52
139
 
53
- function findMatchingLayerRoutes(
54
- route: Route.Route.Default,
55
- layers: Route.RouteLayer[],
56
- ): Route.Route.Default[] {
57
- const matchingRoutes: Route.Route.Default[] = []
58
- for (const layer of layers) {
59
- for (const layerRoute of layer.set) {
60
- if (Route.matches(layerRoute, route)) {
61
- matchingRoutes.push(layerRoute)
62
- }
63
- }
64
- }
65
- return matchingRoutes
66
- }
140
+ function makeHandler(routes: Route.Route.Default[]) {
141
+ return Effect.gen(function*() {
142
+ const request = yield* HttpServerRequest.HttpServerRequest
143
+ const accept = request.headers.accept ?? ""
67
144
 
68
- function wrapWithLayerRoute(
69
- innerRoute: Route.Route.Default,
70
- layerRoute: Route.Route.Default,
71
- ): Route.Route.Default {
72
- const handler: Route.RouteHandler = (context) => {
73
- const innerNext = () => innerRoute.handler(context)
145
+ let selectedRoute: Route.Route.Default | undefined
74
146
 
75
- const contextWithNext: Route.RouteContext = {
76
- ...context,
77
- next: innerNext,
147
+ if (accept.includes("application/json")) {
148
+ selectedRoute = routes.find((r) => r.media === "application/json")
149
+ }
150
+ if (!selectedRoute && accept.includes("text/plain")) {
151
+ selectedRoute = routes.find((r) => r.media === "text/plain")
152
+ }
153
+ if (
154
+ !selectedRoute
155
+ && (accept.includes("text/html")
156
+ || accept.includes("*/*")
157
+ || !accept)
158
+ ) {
159
+ selectedRoute = routes.find((r) => r.media === "text/html")
160
+ }
161
+ if (!selectedRoute) {
162
+ selectedRoute = routes[0]
78
163
  }
79
164
 
80
- return layerRoute.handler(contextWithNext)
81
- }
165
+ if (!selectedRoute) {
166
+ return HttpServerResponse.empty({ status: 406 })
167
+ }
82
168
 
83
- return Route.make({
84
- method: layerRoute.method,
85
- media: layerRoute.media,
86
- handler,
87
- schemas: {},
169
+ const context: Route.RouteContext = {
170
+ request,
171
+ get url() {
172
+ return HttpUtils.makeUrlFromRequest(request)
173
+ },
174
+ slots: {},
175
+ next: () => Effect.void,
176
+ }
177
+
178
+ return yield* RouteRender.render(selectedRoute, context).pipe(
179
+ Effect.catchAllCause((cause) => HttpAppExtra.renderError(cause, accept)),
180
+ )
88
181
  })
89
182
  }
90
183
 
@@ -126,9 +219,12 @@ export function bundlesFromRouter(
126
219
  ([path, route]) =>
127
220
  Effect.promise(() =>
128
221
  route.load().then((bundle) => {
129
- const httpPath = RouterPattern.toHttpPath(path)
222
+ const httpPath = RouterPattern.toBun(path)
130
223
 
131
- return [httpPath, bundle] as const
224
+ return [
225
+ httpPath,
226
+ bundle,
227
+ ] as const
132
228
  })
133
229
  ),
134
230
  { concurrency: "unbounded" },
@@ -161,7 +257,54 @@ function isMethodHandlers(value: unknown): value is MethodHandlers {
161
257
  }
162
258
 
163
259
  /**
164
- * Converts a Router into Bun-compatible routes passed to {@link Bun.serve}.
260
+ * Validates that a route pattern can be implemented with Bun.serve routes.
261
+ *
262
+ * Supported patterns (native or via multiple routes):
263
+ * - /exact - Exact match
264
+ * - /users/:id - Full-segment named param
265
+ * - /path/* - Directory wildcard
266
+ * - /* - Catch-all
267
+ * - /[[id]] - Optional param (implemented via `/` and `/:id`)
268
+ * - /[[...rest]] - Optional rest param (implemented via `/` and `/*`)
269
+ *
270
+ * Unsupported patterns (cannot be implemented in Bun):
271
+ * - /pk_[id] - Prefix before param
272
+ * - /[id]_sfx - Suffix after param
273
+ * - /[id].json - Suffix with dot
274
+ * - /[id]~test - Suffix with tilde
275
+ * - /hello-* - Inline prefix wildcard
276
+ */
277
+
278
+ export function validateBunPattern(
279
+ pattern: string,
280
+ ): Option.Option<Router.RouterError> {
281
+ const segments = RouterPattern.parse(pattern)
282
+
283
+ const unsupported = Array.findFirst(segments, (seg) => {
284
+ if (seg._tag === "ParamSegment") {
285
+ return seg.prefix !== undefined || seg.suffix !== undefined
286
+ }
287
+
288
+ return false
289
+ })
290
+
291
+ if (Option.isSome(unsupported)) {
292
+ return Option.some(
293
+ new Router.RouterError({
294
+ reason: "UnsupportedPattern",
295
+ pattern,
296
+ message:
297
+ `Pattern "${pattern}" uses prefixed/suffixed params (prefix_[param] or [param]_suffix) `
298
+ + `which cannot be implemented in Bun.serve.`,
299
+ }),
300
+ )
301
+ }
302
+
303
+ return Option.none()
304
+ }
305
+
306
+ /**
307
+ * Converts a RouterBuilder into Bun-compatible routes passed to {@link Bun.serve}.
165
308
  *
166
309
  * For BunRoutes (HtmlBundle), creates two routes:
167
310
  * - An internal route at `${path}~BunRoute-${nonce}:${path}` holding the actual HtmlBundle
@@ -171,129 +314,87 @@ function isMethodHandlers(value: unknown): value is MethodHandlers {
171
314
  * the HtmlBundle natively on the internal route.
172
315
  */
173
316
  export function routesFromRouter(
174
- router: Router.RouterContext,
175
- runtime?: Runtime.Runtime<never>,
176
- ): Effect.Effect<BunRoutes> {
317
+ router: Router.RouterBuilder.Any,
318
+ runtime?: Runtime.Runtime<BunHttpServer.BunServer>,
319
+ ): Effect.Effect<BunRoutes, Router.RouterError, BunHttpServer.BunServer> {
177
320
  return Effect.gen(function*() {
178
- const rt = runtime ?? (yield* Effect.runtime<never>())
179
- const nonce = Random.token(6)
180
-
181
- const loadedRoutes = yield* Effect.forEach(
182
- router.routes,
183
- (mod) =>
184
- Effect.gen(function*() {
185
- const routeModule = yield* Effect.promise(() => mod.load())
186
-
187
- const layerModules = mod.layers
188
- ? yield* Effect.forEach(
189
- mod.layers,
190
- (layerLoad) => Effect.promise(() => layerLoad()),
191
- )
192
- : []
193
-
194
- const layers = layerModules
195
- .map((m: any) => m.default)
196
- .filter(Route.isRouteLayer)
197
-
198
- return {
199
- path: mod.path,
200
- exported: routeModule.default,
201
- layers,
202
- }
203
- }),
204
- )
205
-
321
+ const rt = runtime ?? (yield* Effect.runtime<BunHttpServer.BunServer>())
206
322
  const result: BunRoutes = {}
207
323
 
208
- for (const { path, exported, layers } of loadedRoutes) {
209
- const httpPaths = RouterPattern.toBun(path)
324
+ for (const entry of router.entries) {
325
+ const { path, route: routeSet, layers } = entry
210
326
 
211
- const byMethod = new Map<Route.RouteMethod, Route.Route.Default[]>()
212
- for (const route of exported.set) {
327
+ const validationError = validateBunPattern(path)
328
+ if (Option.isSome(validationError)) {
329
+ return yield* Effect.fail(validationError.value)
330
+ }
331
+
332
+ for (const route of routeSet.set) {
213
333
  if (isBunRoute(route)) {
214
334
  const bundle = yield* Effect.promise(() => route.load())
215
- const internalPath = `${path}~BunRoute-${nonce}`
216
-
217
- result[internalPath] = bundle
218
-
219
- const proxyHandler: BunServerFetchHandler = (request) => {
220
- const url = new URL(internalPath, request.url)
221
- return fetch(new Request(url, request))
335
+ const bunPaths = RouterPattern.toBun(path)
336
+ for (const bunPath of bunPaths) {
337
+ const internalPath = `${route.internalPathPrefix}${bunPath}`
338
+ result[internalPath] = bundle
222
339
  }
340
+ }
341
+ }
223
342
 
224
- for (const httpPath of httpPaths) {
225
- if (!(httpPath in result)) {
226
- result[httpPath] = proxyHandler
343
+ for (const layer of layers) {
344
+ for (const route of layer.set) {
345
+ if (isBunRoute(route)) {
346
+ const bundle = yield* Effect.promise(() => route.load())
347
+ const bunPaths = RouterPattern.toBun(path)
348
+ for (const bunPath of bunPaths) {
349
+ const internalPath = `${route.internalPathPrefix}${bunPath}`
350
+ result[internalPath] = bundle
227
351
  }
228
352
  }
229
- } else {
230
- const existing = byMethod.get(route.method) ?? []
231
- existing.push(route)
232
- byMethod.set(route.method, existing)
233
353
  }
234
354
  }
355
+ }
235
356
 
236
- for (const [method, routes] of byMethod) {
237
- const httpApp = Effect.gen(function*() {
238
- const request = yield* HttpServerRequest.HttpServerRequest
239
- const accept = request.headers.accept ?? ""
240
-
241
- let selectedRoute: Route.Route.Default | undefined
242
-
243
- if (accept.includes("application/json")) {
244
- selectedRoute = routes.find((r) => r.media === "application/json")
245
- }
246
- if (!selectedRoute && accept.includes("text/plain")) {
247
- selectedRoute = routes.find((r) => r.media === "text/plain")
248
- }
249
- if (
250
- !selectedRoute
251
- && (accept.includes("text/html")
252
- || accept.includes("*/*")
253
- || !accept)
254
- ) {
255
- selectedRoute = routes.find((r) => r.media === "text/html")
256
- }
257
- if (!selectedRoute) {
258
- selectedRoute = routes[0]
259
- }
357
+ for (const path of Object.keys(router.mounts)) {
358
+ const routeSet = router.mounts[path]
260
359
 
261
- if (!selectedRoute) {
262
- return HttpServerResponse.empty({ status: 406 })
263
- }
360
+ const validationError = validateBunPattern(path)
361
+ if (Option.isSome(validationError)) {
362
+ continue
363
+ }
264
364
 
265
- const matchingLayerRoutes = findMatchingLayerRoutes(
266
- selectedRoute,
267
- layers,
268
- )
269
- let wrappedRoute = selectedRoute
270
- for (const layerRoute of matchingLayerRoutes.reverse()) {
271
- wrappedRoute = wrapWithLayerRoute(wrappedRoute, layerRoute)
272
- }
365
+ const httpPaths = RouterPattern.toBun(path as Route.RoutePattern)
273
366
 
274
- const context: Route.RouteContext = {
275
- request,
276
- get url() {
277
- return HttpUtils.makeUrlFromRequest(request)
278
- },
279
- slots: {},
280
- next: () => Effect.void,
281
- }
367
+ const byMethod = new Map<Route.RouteMethod, Route.Route.Default[]>()
368
+ for (const route of routeSet.set) {
369
+ const existing = byMethod.get(route.method) ?? []
370
+ existing.push(route)
371
+ byMethod.set(route.method, existing)
372
+ }
282
373
 
283
- return yield* RouteRender.render(wrappedRoute, context)
284
- })
374
+ const entry = router.entries.find((e) => e.path === path)
375
+ const allMiddleware = (entry?.layers ?? [])
376
+ .map((layer) => layer.httpMiddleware)
377
+ .filter((m): m is Route.HttpMiddlewareFunction => m !== undefined)
285
378
 
286
- const allMiddleware = layers
287
- .map((layer) => layer.httpMiddleware)
288
- .filter((m): m is Route.HttpMiddlewareFunction => m !== undefined)
379
+ for (const [method, routes] of byMethod) {
380
+ let httpApp: HttpApp.Default<any, any> = makeHandler(routes)
289
381
 
290
- let finalHandler = httpApp
291
382
  for (const middleware of allMiddleware) {
292
- finalHandler = middleware(finalHandler)
383
+ httpApp = middleware(httpApp)
293
384
  }
294
385
 
295
- const webHandler = HttpApp.toWebHandlerRuntime(rt)(finalHandler)
296
- const handler: BunServerFetchHandler = (request) => webHandler(request)
386
+ const webHandler = HttpApp.toWebHandlerRuntime(rt)(httpApp)
387
+ const handler: BunServerFetchHandler = (request) => {
388
+ const url = new URL(request.url)
389
+ if (url.pathname.startsWith("/.BunRoute-")) {
390
+ return new Response(
391
+ "Internal routing error: BunRoute internal path was not matched. "
392
+ + "This indicates the HTMLBundle route was not registered. Please report a bug.",
393
+ { status: 500 },
394
+ )
395
+ }
396
+ return webHandler(request)
397
+ }
297
398
 
298
399
  for (const httpPath of httpPaths) {
299
400
  if (method === "*") {