effect-start 0.30.1 → 0.32.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 (122) hide show
  1. package/dist/Fetch.d.ts +1 -1
  2. package/dist/Fetch.d.ts.map +1 -1
  3. package/dist/Fetch.js +1 -1
  4. package/dist/Fetch.js.map +1 -1
  5. package/dist/GlobalLayer.d.ts.map +1 -1
  6. package/dist/GlobalLayer.js.map +1 -1
  7. package/dist/Html.js +3 -3
  8. package/dist/Html.js.map +1 -1
  9. package/dist/Password.d.ts +26 -0
  10. package/dist/Password.d.ts.map +1 -0
  11. package/dist/Password.js +83 -0
  12. package/dist/Password.js.map +1 -0
  13. package/dist/Route.d.ts.map +1 -1
  14. package/dist/Route.js +1 -1
  15. package/dist/Route.js.map +1 -1
  16. package/dist/RouteBody.d.ts +10 -5
  17. package/dist/RouteBody.d.ts.map +1 -1
  18. package/dist/RouteBody.js +6 -2
  19. package/dist/RouteBody.js.map +1 -1
  20. package/dist/RouteHttp.d.ts.map +1 -1
  21. package/dist/RouteHttp.js +10 -3
  22. package/dist/RouteHttp.js.map +1 -1
  23. package/dist/RouteSse.d.ts +3 -4
  24. package/dist/RouteSse.d.ts.map +1 -1
  25. package/dist/RouteSse.js +3 -3
  26. package/dist/RouteSse.js.map +1 -1
  27. package/dist/RouteTree.d.ts +20 -5
  28. package/dist/RouteTree.d.ts.map +1 -1
  29. package/dist/RouteTree.js +172 -40
  30. package/dist/RouteTree.js.map +1 -1
  31. package/dist/StaticFiles.d.ts +23 -0
  32. package/dist/StaticFiles.d.ts.map +1 -0
  33. package/dist/StaticFiles.js +74 -0
  34. package/dist/StaticFiles.js.map +1 -0
  35. package/dist/_Mime.d.ts +2 -0
  36. package/dist/_Mime.d.ts.map +1 -0
  37. package/dist/_Mime.js +33 -0
  38. package/dist/_Mime.js.map +1 -0
  39. package/dist/_PathPattern.js +4 -4
  40. package/dist/_PathPattern.js.map +1 -1
  41. package/dist/_StreamExtra.d.ts.map +1 -1
  42. package/dist/_StreamExtra.js +0 -1
  43. package/dist/_StreamExtra.js.map +1 -1
  44. package/dist/bun/BunRoute.d.ts.map +1 -1
  45. package/dist/bun/BunRoute.js +6 -0
  46. package/dist/bun/BunRoute.js.map +1 -1
  47. package/dist/bun/BunServer.d.ts +2 -0
  48. package/dist/bun/BunServer.d.ts.map +1 -1
  49. package/dist/bun/BunServer.js +18 -7
  50. package/dist/bun/BunServer.js.map +1 -1
  51. package/dist/studio/Studio.d.ts +1 -1
  52. package/dist/studio/Studio.d.ts.map +1 -1
  53. package/dist/studio/Studio.js +5 -4
  54. package/dist/studio/Studio.js.map +1 -1
  55. package/dist/studio/StudioErrors.d.ts.map +1 -1
  56. package/dist/studio/StudioErrors.js +2 -2
  57. package/dist/studio/StudioErrors.js.map +1 -1
  58. package/dist/studio/StudioLogger.d.ts.map +1 -1
  59. package/dist/studio/StudioLogger.js +1 -3
  60. package/dist/studio/StudioLogger.js.map +1 -1
  61. package/dist/studio/StudioStore.d.ts +23 -17
  62. package/dist/studio/StudioStore.d.ts.map +1 -1
  63. package/dist/studio/StudioStore.js +48 -44
  64. package/dist/studio/StudioStore.js.map +1 -1
  65. package/dist/studio/StudioTracer.d.ts.map +1 -1
  66. package/dist/studio/StudioTracer.js +10 -4
  67. package/dist/studio/StudioTracer.js.map +1 -1
  68. package/dist/studio/routes/errors/route.d.ts +2 -2
  69. package/dist/studio/routes/errors/route.d.ts.map +1 -1
  70. package/dist/studio/routes/errors/route.js +2 -2
  71. package/dist/studio/routes/errors/route.js.map +1 -1
  72. package/dist/studio/routes/fiberDetail.d.ts +1 -1
  73. package/dist/studio/routes/fiberDetail.js +4 -4
  74. package/dist/studio/routes/fiberDetail.js.map +1 -1
  75. package/dist/studio/routes/fibers/route.d.ts +3 -3
  76. package/dist/studio/routes/fibers/route.js +4 -4
  77. package/dist/studio/routes/fibers/route.js.map +1 -1
  78. package/dist/studio/routes/logs/route.d.ts +2 -2
  79. package/dist/studio/routes/logs/route.d.ts.map +1 -1
  80. package/dist/studio/routes/logs/route.js +2 -2
  81. package/dist/studio/routes/logs/route.js.map +1 -1
  82. package/dist/studio/routes/metrics/route.d.ts +1 -1
  83. package/dist/studio/routes/system/route.d.ts +1 -1
  84. package/dist/studio/routes/traceDetail.d.ts +1 -1
  85. package/dist/studio/routes/traceDetail.js +1 -1
  86. package/dist/studio/routes/traceDetail.js.map +1 -1
  87. package/dist/studio/routes/traces/route.d.ts +3 -3
  88. package/dist/studio/routes/traces/route.js +5 -5
  89. package/dist/studio/routes/traces/route.js.map +1 -1
  90. package/dist/studio/routes/tree.d.ts +14 -14
  91. package/dist/studio/ui/Traces.d.ts +1 -0
  92. package/dist/studio/ui/Traces.d.ts.map +1 -1
  93. package/dist/studio/ui/Traces.js +32 -11
  94. package/dist/studio/ui/Traces.js.map +1 -1
  95. package/package.json +2 -2
  96. package/src/Fetch.ts +2 -2
  97. package/src/GlobalLayer.ts +1 -3
  98. package/src/Html.ts +3 -3
  99. package/src/Password.ts +130 -0
  100. package/src/Route.ts +10 -12
  101. package/src/RouteBody.ts +36 -14
  102. package/src/RouteHttp.ts +14 -3
  103. package/src/RouteSse.ts +10 -10
  104. package/src/RouteTree.ts +252 -61
  105. package/src/StaticFiles.ts +112 -0
  106. package/src/_Mime.ts +33 -0
  107. package/src/_PathPattern.ts +4 -4
  108. package/src/_StreamExtra.ts +0 -1
  109. package/src/bun/BunRoute.ts +10 -0
  110. package/src/bun/BunServer.ts +33 -7
  111. package/src/studio/Studio.ts +6 -5
  112. package/src/studio/StudioErrors.ts +2 -3
  113. package/src/studio/StudioLogger.ts +2 -3
  114. package/src/studio/StudioStore.ts +159 -115
  115. package/src/studio/StudioTracer.ts +11 -5
  116. package/src/studio/routes/errors/route.tsx +4 -5
  117. package/src/studio/routes/fiberDetail.tsx +4 -4
  118. package/src/studio/routes/fibers/route.tsx +4 -4
  119. package/src/studio/routes/logs/route.tsx +3 -1
  120. package/src/studio/routes/traceDetail.tsx +1 -1
  121. package/src/studio/routes/traces/route.tsx +8 -8
  122. package/src/studio/ui/Traces.tsx +41 -13
package/src/RouteSse.ts CHANGED
@@ -12,10 +12,9 @@ const HEARTBEAT_INTERVAL = Duration.seconds(5)
12
12
  const HEARTBEAT = ": <3\n\n"
13
13
 
14
14
  interface SseEvent {
15
- readonly [key: string]: unknown
16
15
  readonly _tag?: string
17
16
  readonly data?: string | undefined
18
- readonly type?: string
17
+ readonly event?: string
19
18
  readonly retry?: number
20
19
  }
21
20
 
@@ -26,8 +25,8 @@ function formatSseEvent(event: SseEvent): string {
26
25
  }
27
26
 
28
27
  let result = ""
29
- if (event.type) {
30
- result += `event: ${event.type}\n`
28
+ if (event.event) {
29
+ result += `event: ${event.event}\n`
31
30
  }
32
31
  if (typeof event.data === "string") {
33
32
  for (const line of event.data.split("\n")) {
@@ -48,6 +47,7 @@ type SseHandlerInput<B, E, R> =
48
47
  | Effect.Effect<Stream.Stream<SseEvent, E, R>, E, R>
49
48
  | ((
50
49
  context: Values.Simplify<B>,
50
+ // TODO: typing is super loose here
51
51
  next: (context?: Partial<B> & Record<string, unknown>) => Entity.Entity<string>,
52
52
  ) =>
53
53
  | Stream.Stream<SseEvent, E, R>
@@ -64,10 +64,10 @@ export function sse<
64
64
  I extends Route.Route.Tuple,
65
65
  E = never,
66
66
  R = never,
67
- >(handler: SseHandlerInput<NoInfer<D & B & Route.ExtractBindings<I> & { format: "text" }>, E, R>) {
67
+ >(handler: SseHandlerInput<NoInfer<D & B & Route.ExtractBindings<I> & { format: "sse" }>, E, R>) {
68
68
  return function (self: Route.RouteSet.RouteSet<D, B, I>) {
69
69
  const sseHandler: Route.Route.Handler<
70
- D & B & Route.ExtractBindings<I> & { format: "text" },
70
+ D & B & Route.ExtractBindings<I> & { format: "sse" },
71
71
  Stream.Stream<string, E, R>,
72
72
  E,
73
73
  R
@@ -113,20 +113,20 @@ export function sse<
113
113
  })
114
114
  }
115
115
 
116
- const route = Route.make<{ format: "text" }, {}, Stream.Stream<string, E, R>, E, R>(
116
+ const route = Route.make<{ format: "sse" }, {}, Stream.Stream<string, E, R>, E, R>(
117
117
  sseHandler as any,
118
- { format: "text" },
118
+ { format: "sse" },
119
119
  )
120
120
 
121
121
  const items: [
122
122
  ...I,
123
- Route.Route.Route<{ format: "text" }, {}, Stream.Stream<string, E, R>, E, R>,
123
+ Route.Route.Route<{ format: "sse" }, {}, Stream.Stream<string, E, R>, E, R>,
124
124
  ] = [...Route.items(self), route]
125
125
 
126
126
  return Route.set<
127
127
  D,
128
128
  B,
129
- [...I, Route.Route.Route<{ format: "text" }, {}, Stream.Stream<string, E, R>, E, R>]
129
+ [...I, Route.Route.Route<{ format: "sse" }, {}, Stream.Stream<string, E, R>, E, R>]
130
130
  >(items, Route.descriptor(self))
131
131
  }
132
132
  }
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
@@ -78,7 +78,6 @@ export const toReadableStreamRuntimePatched = Function.dual<
78
78
  ),
79
79
  ),
80
80
  )
81
- // --- CHANGES HERE ---
82
81
  // In original code, we had fiber.addObserver here that called
83
82
  // error() or close() on controller. This patched version removes it.
84
83
  },
@@ -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 = ""