effect-start 0.30.0 → 0.31.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 (69) hide show
  1. package/dist/FileRouter.d.ts.map +1 -1
  2. package/dist/FileRouter.js +1 -0
  3. package/dist/FileRouter.js.map +1 -1
  4. package/dist/GlobalLayer.d.ts.map +1 -1
  5. package/dist/GlobalLayer.js.map +1 -1
  6. package/dist/Html.js +3 -3
  7. package/dist/Html.js.map +1 -1
  8. package/dist/Route.d.ts.map +1 -1
  9. package/dist/Route.js +1 -1
  10. package/dist/Route.js.map +1 -1
  11. package/dist/RouteBody.d.ts +10 -5
  12. package/dist/RouteBody.d.ts.map +1 -1
  13. package/dist/RouteBody.js +6 -2
  14. package/dist/RouteBody.js.map +1 -1
  15. package/dist/RouteHttp.d.ts.map +1 -1
  16. package/dist/RouteHttp.js +10 -3
  17. package/dist/RouteHttp.js.map +1 -1
  18. package/dist/RouteSse.d.ts +2 -2
  19. package/dist/RouteSse.d.ts.map +1 -1
  20. package/dist/RouteSse.js +1 -1
  21. package/dist/RouteSse.js.map +1 -1
  22. package/dist/RouteTree.d.ts +20 -5
  23. package/dist/RouteTree.d.ts.map +1 -1
  24. package/dist/RouteTree.js +172 -40
  25. package/dist/RouteTree.js.map +1 -1
  26. package/dist/StaticFiles.d.ts +23 -0
  27. package/dist/StaticFiles.d.ts.map +1 -0
  28. package/dist/StaticFiles.js +74 -0
  29. package/dist/StaticFiles.js.map +1 -0
  30. package/dist/_Mime.d.ts +2 -0
  31. package/dist/_Mime.d.ts.map +1 -0
  32. package/dist/_Mime.js +33 -0
  33. package/dist/_Mime.js.map +1 -0
  34. package/dist/_PathPattern.js +4 -4
  35. package/dist/_PathPattern.js.map +1 -1
  36. package/dist/bun/BunRoute.d.ts.map +1 -1
  37. package/dist/bun/BunRoute.js +6 -0
  38. package/dist/bun/BunRoute.js.map +1 -1
  39. package/dist/bun/BunServer.d.ts +2 -0
  40. package/dist/bun/BunServer.d.ts.map +1 -1
  41. package/dist/bun/BunServer.js +18 -7
  42. package/dist/bun/BunServer.js.map +1 -1
  43. package/dist/studio/routes/errors/route.d.ts +1 -1
  44. package/dist/studio/routes/errors/route.d.ts.map +1 -1
  45. package/dist/studio/routes/errors/route.js.map +1 -1
  46. package/dist/studio/routes/fibers/route.d.ts +1 -1
  47. package/dist/studio/routes/logs/route.d.ts +1 -1
  48. package/dist/studio/routes/metrics/route.d.ts +1 -1
  49. package/dist/studio/routes/system/route.d.ts +1 -1
  50. package/dist/studio/routes/traces/route.d.ts +1 -1
  51. package/dist/studio/routes/traces/route.d.ts.map +1 -1
  52. package/dist/studio/routes/traces/route.js.map +1 -1
  53. package/dist/studio/routes/tree.d.ts +6 -6
  54. package/package.json +2 -2
  55. package/src/FileRouter.ts +1 -0
  56. package/src/GlobalLayer.ts +1 -3
  57. package/src/Html.ts +3 -3
  58. package/src/Route.ts +10 -12
  59. package/src/RouteBody.ts +36 -14
  60. package/src/RouteHttp.ts +14 -3
  61. package/src/RouteSse.ts +6 -6
  62. package/src/RouteTree.ts +252 -61
  63. package/src/StaticFiles.ts +112 -0
  64. package/src/_Mime.ts +33 -0
  65. package/src/_PathPattern.ts +4 -4
  66. package/src/bun/BunRoute.ts +10 -0
  67. package/src/bun/BunServer.ts +33 -7
  68. package/src/studio/routes/errors/route.tsx +1 -4
  69. package/src/studio/routes/traces/route.tsx +1 -4
package/src/RouteTree.ts CHANGED
@@ -1,10 +1,11 @@
1
1
  import * as Predicate from "effect/Predicate"
2
- import * as PathPattern from "./_PathPattern.ts"
2
+ import type * as PathPattern from "./_PathPattern.ts"
3
3
  import * as Route from "./Route.ts"
4
4
  import type * as RouteMount from "./RouteMount.ts"
5
5
 
6
6
  const TypeId = "~effect-start/RouteTree" as const
7
7
  const RouteTreeRoutes: unique symbol = Symbol()
8
+ const CompiledRoutesKey: unique symbol = Symbol()
8
9
 
9
10
  type MethodRoute = Route.Route.With<{ method: string }>
10
11
 
@@ -27,40 +28,25 @@ export type RouteMap = {
27
28
 
28
29
  export type Routes<T extends RouteTree> = T[typeof RouteTreeRoutes]
29
30
 
30
- export interface RouteTree<Routes extends RouteMap = RouteMap> {
31
- [TypeId]: typeof TypeId
32
- [RouteTreeRoutes]: Routes
31
+ interface CompiledMethod {
32
+ regex: RegExp
33
+ table: Array<{
34
+ path: PathPattern.PathPattern
35
+ routes: Array<RouteMount.MountedRoute>
36
+ paramNames: Array<string>
37
+ paramGroupIndices: Array<number>
38
+ sentinelIndex: number
39
+ }>
33
40
  }
34
41
 
35
- function routes<Routes extends RouteMap>(tree: RouteTree<Routes>): Routes {
36
- return tree[RouteTreeRoutes]
42
+ interface CompiledRoutes {
43
+ methods: Record<string, CompiledMethod>
37
44
  }
38
45
 
39
- // segment priority: static (0) < :param (1) < :param? (2) < :param+ (3) < :param* (4)
40
- function sortScore(path: string): number {
41
- const segments = path.split("/")
42
- const greedyIdx = segments.findIndex((s) => s.endsWith("*") || s.endsWith("+"))
43
- const maxPriority = Math.max(
44
- ...segments.map((s) =>
45
- !s.startsWith(":") ? 0 : s.endsWith("*") ? 4 : s.endsWith("+") ? 3 : s.endsWith("?") ? 2 : 1,
46
- ),
47
- 0,
48
- )
49
-
50
- return greedyIdx === -1
51
- ? // non-greedy: sort by depth, then by max segment priority
52
- (segments.length << 16) + (maxPriority << 8)
53
- : // greedy: sort after non-greedy, by greedy position (later = first), then priority
54
- (1 << 24) + ((16 - greedyIdx) << 16) + (maxPriority << 8)
55
- }
56
-
57
- function sortRoutes(input: RouteMap): RouteMap {
58
- const keys = Object.keys(input).sort((a, b) => sortScore(a) - sortScore(b) || a.localeCompare(b))
59
- const sorted: RouteMap = {}
60
- for (const key of keys) {
61
- sorted[key as PathPattern.PathPattern] = input[key as PathPattern.PathPattern]
62
- }
63
- return sorted
46
+ export interface RouteTree<Routes extends RouteMap = RouteMap> {
47
+ [TypeId]: typeof TypeId
48
+ [RouteTreeRoutes]: Routes
49
+ [CompiledRoutesKey]?: CompiledRoutes
64
50
  }
65
51
 
66
52
  type PrefixKeys<T, Prefix extends string> = {
@@ -92,6 +78,16 @@ type UnionToIntersection<U> = (U extends any ? (x: U) => void : never) extends (
92
78
  ? I
93
79
  : never
94
80
 
81
+ export type WalkDescriptor = {
82
+ path: PathPattern.PathPattern
83
+ method: string
84
+ } & Route.RouteDescriptor.Any
85
+
86
+ export interface LookupResult {
87
+ route: RouteMount.MountedRoute
88
+ params: Record<string, string>
89
+ }
90
+
95
91
  export function make<const Routes extends InputRouteMap>(
96
92
  input: Routes,
97
93
  ): RouteTree<FlattenRouteMap<Routes>> {
@@ -115,30 +111,13 @@ export function make<const Routes extends InputRouteMap>(
115
111
 
116
112
  flatten(input, "", layerRoutes)
117
113
 
114
+ const sorted = sortRoutes(merged)
118
115
  return {
119
116
  [TypeId]: TypeId,
120
- [RouteTreeRoutes]: sortRoutes(merged),
117
+ [RouteTreeRoutes]: sorted,
121
118
  } as RouteTree<FlattenRouteMap<Routes>>
122
119
  }
123
120
 
124
- export type WalkDescriptor = {
125
- path: PathPattern.PathPattern
126
- method: string
127
- } & Route.RouteDescriptor.Any
128
-
129
- function* flattenRoutes(
130
- path: PathPattern.PathPattern,
131
- routes: Iterable<MethodRoute>,
132
- ): Generator<RouteMount.MountedRoute> {
133
- for (const route of routes) {
134
- const descriptor = {
135
- ...route[Route.RouteDescriptor],
136
- path,
137
- }
138
- yield Route.make(route.handler as any, descriptor) as RouteMount.MountedRoute
139
- }
140
- }
141
-
142
121
  export function* walk(tree: RouteTree): Generator<RouteMount.MountedRoute> {
143
122
  const _routes = routes(tree) as RouteMap
144
123
 
@@ -153,9 +132,10 @@ export function merge(a: RouteTree, b: RouteTree): RouteTree {
153
132
  const key = path as PathPattern.PathPattern
154
133
  combined[key] = combined[key] ? [...combined[key], ...items] : items
155
134
  }
135
+ const sorted = sortRoutes(combined)
156
136
  return {
157
137
  [TypeId]: TypeId,
158
- [RouteTreeRoutes]: sortRoutes(combined),
138
+ [RouteTreeRoutes]: sorted,
159
139
  } as RouteTree
160
140
  }
161
141
 
@@ -163,21 +143,232 @@ export function isRouteTree(input: unknown): input is RouteTree {
163
143
  return Predicate.hasProperty(input, TypeId)
164
144
  }
165
145
 
166
- export interface LookupResult {
167
- route: RouteMount.MountedRoute
168
- params: Record<string, string>
146
+ export function lookup(tree: RouteTree, method: string, path: string): LookupResult | null {
147
+ tree[CompiledRoutesKey] ??= compileRoutes(routes(tree))
148
+ const { methods } = tree[CompiledRoutesKey]
149
+
150
+ const wildcard = methods["*"]
151
+ if (wildcard) {
152
+ const result = execCompiled(wildcard, path)
153
+ if (result) return result
154
+ }
155
+
156
+ const specific = methods[method]
157
+ if (specific) {
158
+ const result = execCompiled(specific, path)
159
+ if (result) return result
160
+ }
161
+
162
+ return null
169
163
  }
170
164
 
171
- export function lookup(tree: RouteTree, method: string, path: string): LookupResult | null {
172
- for (const route of walk(tree)) {
173
- const descriptor = Route.descriptor(route)
165
+ function routes<Routes extends RouteMap>(tree: RouteTree<Routes>): Routes {
166
+ return tree[RouteTreeRoutes]
167
+ }
174
168
 
175
- if (descriptor.method !== "*" && descriptor.method !== method) continue
169
+ // segment priority: static (0) < :param (1) < :param? (2) < :param+ (3) < :param* (4)
170
+ function sortScore(path: string): number {
171
+ const segments = path.split("/")
172
+ const greedyIdx = segments.findIndex((s) => s.endsWith("*") || s.endsWith("+"))
173
+ const maxPriority = Math.max(
174
+ ...segments.map((s) =>
175
+ !s.startsWith(":") ? 0 : s.endsWith("*") ? 4 : s.endsWith("+") ? 3 : s.endsWith("?") ? 2 : 1,
176
+ ),
177
+ 0,
178
+ )
179
+
180
+ return greedyIdx === -1
181
+ ? // non-greedy: sort by depth, then by max segment priority
182
+ (segments.length << 16) + (maxPriority << 8)
183
+ : // greedy: sort after non-greedy, by greedy position (later = first), then priority
184
+ (1 << 24) + ((16 - greedyIdx) << 16) + (maxPriority << 8)
185
+ }
186
+
187
+ function sortRoutes(input: RouteMap): RouteMap {
188
+ const keys = Object.keys(input).sort((a, b) => sortScore(a) - sortScore(b) || a.localeCompare(b))
189
+ const sorted: RouteMap = {}
190
+ for (const key of keys) {
191
+ sorted[key as PathPattern.PathPattern] = input[key as PathPattern.PathPattern]
192
+ }
193
+ return sorted
194
+ }
195
+
196
+ function* flattenRoutes(
197
+ path: PathPattern.PathPattern,
198
+ routes: Iterable<MethodRoute>,
199
+ ): Generator<RouteMount.MountedRoute> {
200
+ for (const route of routes) {
201
+ const descriptor = {
202
+ ...route[Route.RouteDescriptor],
203
+ path,
204
+ }
205
+ yield Route.make(route.handler as any, descriptor) as RouteMount.MountedRoute
206
+ }
207
+ }
208
+
209
+ function escapeRegex(s: string): string {
210
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
211
+ }
212
+
213
+ function patternToRegex(pattern: string): {
214
+ fragment: string
215
+ paramNames: Array<string>
216
+ groupCount: number
217
+ } {
218
+ const segments = pattern.split("/").filter(Boolean)
219
+ const paramNames: Array<string> = []
220
+ let fragment = ""
221
+ let groupCount = 0
222
+
223
+ for (let i = 0; i < segments.length; i++) {
224
+ const seg = segments[i]
225
+
226
+ if (seg.startsWith(":")) {
227
+ const last = seg[seg.length - 1]
228
+ if (last === "+") {
229
+ const name = seg.slice(1, -1)
230
+ paramNames.push(name)
231
+ fragment += "\\/(.+)"
232
+ groupCount++
233
+ } else if (last === "*") {
234
+ const name = seg.slice(1, -1)
235
+ paramNames.push(name)
236
+ fragment += "(?:\\/(.+))?"
237
+ groupCount++
238
+ } else if (last === "?") {
239
+ const name = seg.slice(1, -1)
240
+ paramNames.push(name)
241
+ fragment += "(?:\\/([^\\/]+))?"
242
+ groupCount++
243
+ } else {
244
+ const name = seg.slice(1)
245
+ paramNames.push(name)
246
+ fragment += "\\/([^\\/]+)"
247
+ groupCount++
248
+ }
249
+ } else {
250
+ fragment += "\\/" + escapeRegex(seg)
251
+ }
252
+ }
253
+
254
+ return { fragment, paramNames, groupCount }
255
+ }
256
+
257
+ function compileRoutes(sortedRoutes: RouteMap): CompiledRoutes {
258
+ const methodGroups: Record<
259
+ string,
260
+ Array<{
261
+ path: PathPattern.PathPattern
262
+ route: RouteMount.MountedRoute
263
+ }>
264
+ > = {}
265
+
266
+ for (const path of Object.keys(sortedRoutes) as Array<PathPattern.PathPattern>) {
267
+ for (const routeData of sortedRoutes[path]) {
268
+ const descriptor = {
269
+ ...routeData[Route.RouteDescriptor],
270
+ path,
271
+ }
272
+ const mounted = Route.make(routeData.handler as any, descriptor) as RouteMount.MountedRoute
273
+ const method = (descriptor as { method: string }).method
274
+
275
+ if (!methodGroups[method]) methodGroups[method] = []
276
+ methodGroups[method].push({ path, route: mounted })
277
+ }
278
+ }
279
+
280
+ const pathRoutesByMethod: Record<
281
+ string,
282
+ Map<
283
+ string,
284
+ {
285
+ path: PathPattern.PathPattern
286
+ routes: Array<RouteMount.MountedRoute>
287
+ }
288
+ >
289
+ > = {}
290
+
291
+ for (const method of Object.keys(methodGroups)) {
292
+ const map = new Map<
293
+ string,
294
+ {
295
+ path: PathPattern.PathPattern
296
+ routes: Array<RouteMount.MountedRoute>
297
+ }
298
+ >()
299
+ for (const { path, route } of methodGroups[method]) {
300
+ let entry = map.get(path)
301
+ if (!entry) {
302
+ entry = { path, routes: [] }
303
+ map.set(path, entry)
304
+ }
305
+ entry.routes.push(route)
306
+ }
307
+ pathRoutesByMethod[method] = map
308
+ }
176
309
 
177
- const params = PathPattern.match(descriptor.path, path)
178
- if (params !== null) {
179
- return { route, params }
310
+ const methods: Record<string, CompiledMethod> = {}
311
+
312
+ for (const method of Object.keys(pathRoutesByMethod)) {
313
+ const pathMap = pathRoutesByMethod[method]
314
+ const sortedPaths = Object.keys(sortedRoutes) as Array<PathPattern.PathPattern>
315
+ const orderedPaths = sortedPaths.filter((p) => pathMap.has(p))
316
+
317
+ const branches: Array<string> = []
318
+ const table: CompiledMethod["table"] = []
319
+ let groupOffset = 1
320
+
321
+ for (const path of orderedPaths) {
322
+ const entry = pathMap.get(path)!
323
+ const { fragment, paramNames, groupCount } = patternToRegex(path)
324
+
325
+ const paramGroupIndices: Array<number> = []
326
+ for (let i = 0; i < groupCount; i++) {
327
+ paramGroupIndices.push(groupOffset + i)
328
+ }
329
+
330
+ const sentinelIndex = groupOffset + groupCount
331
+ branches.push(fragment + "()")
332
+ groupOffset += groupCount + 1
333
+
334
+ table.push({
335
+ path: entry.path,
336
+ routes: entry.routes,
337
+ paramNames,
338
+ paramGroupIndices,
339
+ sentinelIndex,
340
+ })
341
+ }
342
+
343
+ if (branches.length === 0) continue
344
+
345
+ const pattern = "^(?:" + branches.join("|") + ")\\/*$"
346
+ methods[method] = {
347
+ regex: new RegExp(pattern),
348
+ table,
180
349
  }
181
350
  }
351
+
352
+ return { methods }
353
+ }
354
+
355
+ function execCompiled(compiled: CompiledMethod, path: string): LookupResult | null {
356
+ const match = compiled.regex.exec(path)
357
+ if (!match) return null
358
+
359
+ for (const entry of compiled.table) {
360
+ if (match[entry.sentinelIndex] === undefined) continue
361
+
362
+ const params: Record<string, string> = {}
363
+ for (let i = 0; i < entry.paramNames.length; i++) {
364
+ const val = match[entry.paramGroupIndices[i]]
365
+ if (val !== undefined) {
366
+ params[entry.paramNames[i]] = val
367
+ }
368
+ }
369
+
370
+ return { route: entry.routes[0], params }
371
+ }
372
+
182
373
  return null
183
374
  }
@@ -0,0 +1,112 @@
1
+ import * as Effect from "effect/Effect"
2
+ import * as Layer from "effect/Layer"
3
+ import * as Option from "effect/Option"
4
+ import * as Schema from "effect/Schema"
5
+ import * as Entity from "./Entity.ts"
6
+ import * as FileSystem from "./FileSystem.ts"
7
+ import * as PathPattern from "./_PathPattern.ts"
8
+ import * as Mime from "./_Mime.ts"
9
+ import * as Route from "./Route.ts"
10
+ import * as RouteSchema from "./RouteSchema.ts"
11
+ import * as RouteTree from "./RouteTree.ts"
12
+ import * as System from "./System.ts"
13
+
14
+ const defaultMountPath = "/assets"
15
+ const PathParamsSchema = Schema.Struct({
16
+ path: Schema.optional(Schema.String),
17
+ })
18
+
19
+ const emptyNotFound = Entity.make(new Uint8Array(0), { status: 404 })
20
+
21
+ export const make = (directory: string) =>
22
+ Route.get(
23
+ RouteSchema.schemaPathParams(PathParamsSchema),
24
+ Route.render(function* (ctx) {
25
+ const fs = yield* FileSystem.FileSystem
26
+ const relativePath =
27
+ typeof ctx.pathParams.path === "string" ? normalizeRelativePath(ctx.pathParams.path) : null
28
+
29
+ if (!relativePath) {
30
+ return emptyNotFound
31
+ }
32
+
33
+ const absolutePath = `${directory.replace(/[\\/]+$/, "")}/${relativePath}`
34
+ const info = yield* fs.stat(absolutePath).pipe(
35
+ Effect.catchAll((error) =>
36
+ isNotFound(error) ? Effect.succeed(null) : Effect.fail(error),
37
+ ),
38
+ )
39
+
40
+ if (info === null || info.type !== "File") {
41
+ return emptyNotFound
42
+ }
43
+
44
+ const headers: Entity.Headers = {
45
+ "content-length": String(info.size),
46
+ "content-type": Mime.fromPath(relativePath),
47
+ }
48
+
49
+ if (Option.isSome(info.mtime)) {
50
+ headers["last-modified"] = info.mtime.value.toUTCString()
51
+ }
52
+
53
+ const bytes = yield* fs.readFile(absolutePath)
54
+ return Entity.make(bytes, { headers })
55
+ }),
56
+ )
57
+
58
+ export const layer = (options: {
59
+ directory: string
60
+ path?: string
61
+ }) =>
62
+ Layer.effect(
63
+ Route.Routes,
64
+ Effect.gen(function* () {
65
+ const existing = yield* Effect.serviceOption(Route.Routes).pipe(
66
+ Effect.andThen(Option.getOrUndefined),
67
+ )
68
+ const trimmedMountPath = (options.path ?? defaultMountPath).trim().replace(/^\/+|\/+$/g, "")
69
+ const mountPattern = (
70
+ trimmedMountPath.length > 0 ? `/${trimmedMountPath}/:path+` : "/:path+"
71
+ ) as PathPattern.PathPattern
72
+ const staticFilesTree = Route.tree({
73
+ [mountPattern]: make(options.directory),
74
+ })
75
+ if (!existing) {
76
+ return staticFilesTree
77
+ }
78
+ return RouteTree.merge(existing, staticFilesTree)
79
+ }),
80
+ )
81
+
82
+ function normalizeRelativePath(path: string): string | null {
83
+ if (path.length === 0 || path.startsWith("/") || path.startsWith("\\")) {
84
+ return null
85
+ }
86
+
87
+ const segments = path.split(/[\\/]+/)
88
+ const normalized: Array<string> = []
89
+
90
+ for (const segment of segments) {
91
+ if (segment === "" || segment === ".") {
92
+ continue
93
+ }
94
+ if (segment === ".." || segment.includes("\0")) {
95
+ return null
96
+ }
97
+ normalized.push(segment)
98
+ }
99
+
100
+ return normalized.length > 0 ? normalized.join("/") : null
101
+ }
102
+
103
+ function isNotFound(error: unknown): boolean {
104
+ return (
105
+ typeof error === "object" &&
106
+ error !== null &&
107
+ "_tag" in error &&
108
+ error._tag === "SystemError" &&
109
+ "reason" in error &&
110
+ error.reason === "NotFound"
111
+ )
112
+ }
package/src/_Mime.ts ADDED
@@ -0,0 +1,33 @@
1
+ const mediaTypes: Record<string, string> = {
2
+ ".avif": "image/avif",
3
+ ".bmp": "image/bmp",
4
+ ".css": "text/css",
5
+ ".csv": "text/csv",
6
+ ".gif": "image/gif",
7
+ ".html": "text/html",
8
+ ".ico": "image/x-icon",
9
+ ".js": "text/javascript",
10
+ ".jpeg": "image/jpeg",
11
+ ".jpg": "image/jpeg",
12
+ ".json": "application/json",
13
+ ".map": "application/json",
14
+ ".md": "text/markdown",
15
+ ".mjs": "text/javascript",
16
+ ".pdf": "application/pdf",
17
+ ".png": "image/png",
18
+ ".svg": "image/svg+xml",
19
+ ".txt": "text/plain",
20
+ ".wasm": "application/wasm",
21
+ ".webm": "video/webm",
22
+ ".webp": "image/webp",
23
+ ".woff": "font/woff",
24
+ ".woff2": "font/woff2",
25
+ ".xml": "application/xml",
26
+ }
27
+
28
+ export function fromPath(path: string): string {
29
+ const dotIndex = path.lastIndexOf(".")
30
+ const extension = dotIndex === -1 ? "" : path.slice(dotIndex).toLowerCase()
31
+ const mediaType = mediaTypes[extension] ?? "application/octet-stream"
32
+ return mediaType.startsWith("text/") ? `${mediaType}; charset=utf-8` : mediaType
33
+ }
@@ -175,7 +175,7 @@ export function match(pattern: string, path: string): Record<string, string> | n
175
175
  if (remaining.length === 0) {
176
176
  return null
177
177
  }
178
- params[name] = remaining.join("/")
178
+ params[name] = remaining.map(decodeURIComponent).join("/")
179
179
  return params
180
180
  }
181
181
 
@@ -183,7 +183,7 @@ export function match(pattern: string, path: string): Record<string, string> | n
183
183
  const name = rest.slice(0, -1)
184
184
  const remaining = pathSegments.slice(pathIndex)
185
185
  if (remaining.length > 0) {
186
- params[name] = remaining.join("/")
186
+ params[name] = remaining.map(decodeURIComponent).join("/")
187
187
  }
188
188
  return params
189
189
  }
@@ -191,7 +191,7 @@ export function match(pattern: string, path: string): Record<string, string> | n
191
191
  if (rest.endsWith("?")) {
192
192
  const name = rest.slice(0, -1)
193
193
  if (pathIndex < pathSegments.length) {
194
- params[name] = pathSegments[pathIndex]
194
+ params[name] = decodeURIComponent(pathSegments[pathIndex])
195
195
  pathIndex++
196
196
  }
197
197
  patternIndex++
@@ -202,7 +202,7 @@ export function match(pattern: string, path: string): Record<string, string> | n
202
202
  return null
203
203
  }
204
204
 
205
- params[rest] = pathSegments[pathIndex]
205
+ params[rest] = decodeURIComponent(pathSegments[pathIndex])
206
206
  pathIndex++
207
207
  patternIndex++
208
208
  continue
@@ -139,6 +139,16 @@ export function htmlBundle(load: () => HTMLBundleModule | Promise<HTMLBundleModu
139
139
  const childEntity = yield* next(context).pipe(
140
140
  Effect.locally(bundleDepthRef, bundleDepth + 1),
141
141
  )
142
+
143
+ if (
144
+ Entity.isEntity(childEntity) &&
145
+ childEntity.status &&
146
+ childEntity.status >= 300 &&
147
+ childEntity.status < 400
148
+ ) {
149
+ return childEntity
150
+ }
151
+
142
152
  const children = childEntity?.body ?? childEntity
143
153
 
144
154
  let childrenHtml = ""
@@ -50,6 +50,7 @@ export type BunServer = {
50
50
  readonly server: Bun.Server<WebSocketContext>
51
51
  readonly pushHandler: (fetch: FetchHandler) => void
52
52
  readonly popHandler: () => void
53
+ readonly setRoutes: (tree: RouteTree.RouteTree) => Effect.Effect<void>
53
54
  }
54
55
 
55
56
  export const BunServer = Context.GenericTag<BunServer>("effect-start/BunServer")
@@ -77,6 +78,9 @@ export const make = (
77
78
  },
78
79
  ]
79
80
 
81
+ const setRoutesDeferred =
82
+ yield* Deferred.make<(tree: RouteTree.RouteTree) => Effect.Effect<void>>()
83
+
80
84
  const service = BunServer.of({
81
85
  // During the construction we need to create a service imlpementation
82
86
  // first so we can provide it in the runtime that will be used in web
@@ -93,6 +97,11 @@ export const make = (
93
97
  handlerStack.pop()
94
98
  reload()
95
99
  },
100
+ setRoutes(tree) {
101
+ return Deferred.await(setRoutesDeferred).pipe(
102
+ Effect.flatMap((applyRoutes) => applyRoutes(tree)),
103
+ )
104
+ },
96
105
  })
97
106
 
98
107
  const runtime = yield* Effect.runtime().pipe(
@@ -150,6 +159,18 @@ export const make = (
150
159
  })
151
160
  }
152
161
 
162
+ yield* Deferred.succeed(setRoutesDeferred, (tree) =>
163
+ walkBunRoutes(runtime, tree).pipe(
164
+ Effect.tap((bunRoutes) =>
165
+ Effect.sync(() => {
166
+ currentRoutes = bunRoutes
167
+ reload()
168
+ }),
169
+ ),
170
+ Effect.asVoid,
171
+ ),
172
+ )
173
+
153
174
  const bunServer = BunServer.of({
154
175
  server,
155
176
  pushHandler(fetch) {
@@ -160,6 +181,11 @@ export const make = (
160
181
  handlerStack.pop()
161
182
  reload()
162
183
  },
184
+ setRoutes(tree) {
185
+ return Deferred.await(setRoutesDeferred).pipe(
186
+ Effect.flatMap((applyRoutes) => applyRoutes(tree)),
187
+ )
188
+ },
163
189
  })
164
190
 
165
191
  return bunServer
@@ -185,6 +211,7 @@ export const layerRoutes = (
185
211
  /**
186
212
  * Resolves the Bun server in one place for Start.serve so routes are available:
187
213
  * 1) Reuse a user-provided BunServer when one already exists in context.
214
+ * If Route.Routes are available, upgrade the existing server with them.
188
215
  * 2) Otherwise create the server from Route.Routes when routes are available.
189
216
  * 3) Otherwise create a fallback server with the default 404 handler.
190
217
  */
@@ -195,18 +222,17 @@ export const layerStart = (
195
222
  BunServer,
196
223
  Effect.gen(function* () {
197
224
  const app = yield* StartApp.StartApp
225
+ const routes = yield* Effect.serviceOption(Route.Routes)
226
+ const routeTree = Option.getOrNull(routes)
198
227
  const existing = yield* Effect.serviceOption(BunServer)
199
228
  if (Option.isSome(existing)) {
229
+ if (routeTree !== null) {
230
+ yield* existing.value.setRoutes(routeTree)
231
+ }
200
232
  yield* Deferred.succeed(app.server, existing.value)
201
233
  return existing.value
202
234
  }
203
- const routes = yield* Effect.serviceOption(Route.Routes)
204
- if (Option.isSome(routes)) {
205
- const server = yield* make(options ?? {}, routes.value)
206
- yield* Deferred.succeed(app.server, server)
207
- return server
208
- }
209
- const server = yield* make(options ?? {})
235
+ const server = yield* make(options ?? {}, routeTree ?? undefined)
210
236
  yield* Deferred.succeed(app.server, server)
211
237
  return server
212
238
  }),
@@ -75,10 +75,7 @@ export default Route.get(
75
75
  Stream.fromPubSub(StudioStore.store.events).pipe(
76
76
  Stream.filter((e) => e._tag === "Error"),
77
77
  Stream.map((e) => {
78
- const html = Html.renderToString(<Errors.ErrorLine error={e.error} />).replace(
79
- /\n/g,
80
- "",
81
- )
78
+ const html = Html.renderToString(<Errors.ErrorLine error={e.error} />).replace(/\n/g, "")
82
79
  return {
83
80
  event: "datastar-patch-elements",
84
81
  data: `selector #errors-list\nmode prepend\nelements ${html}`,
@@ -56,10 +56,7 @@ export default Route.get(
56
56
  Stream.mapEffect(() =>
57
57
  Effect.gen(function* () {
58
58
  const spans = yield* StudioStore.allSpans(StudioStore.store.sql)
59
- const html = Html.renderToString(<Traces.TraceGroups spans={spans} />).replace(
60
- /\n/g,
61
- "",
62
- )
59
+ const html = Html.renderToString(<Traces.TraceGroups spans={spans} />).replace(/\n/g, "")
63
60
  return {
64
61
  event: "datastar-patch-elements",
65
62
  data: `selector #traces-container\nmode inner\nelements ${html}`,