effect-start 0.9.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.
Files changed (54) hide show
  1. package/package.json +15 -14
  2. package/src/BundleHttp.test.ts +1 -1
  3. package/src/Commander.test.ts +15 -15
  4. package/src/Commander.ts +58 -88
  5. package/src/EncryptedCookies.test.ts +4 -4
  6. package/src/FileHttpRouter.test.ts +85 -16
  7. package/src/FileHttpRouter.ts +119 -32
  8. package/src/FileRouter.ts +62 -166
  9. package/src/FileRouterCodegen.test.ts +252 -66
  10. package/src/FileRouterCodegen.ts +13 -56
  11. package/src/FileRouterPattern.test.ts +116 -0
  12. package/src/FileRouterPattern.ts +59 -0
  13. package/src/FileRouter_path.test.ts +63 -102
  14. package/src/FileSystemExtra.test.ts +226 -0
  15. package/src/FileSystemExtra.ts +24 -60
  16. package/src/HttpAppExtra.test.ts +84 -0
  17. package/src/HttpAppExtra.ts +399 -47
  18. package/src/HttpUtils.test.ts +68 -0
  19. package/src/HttpUtils.ts +15 -0
  20. package/src/HyperHtml.ts +24 -5
  21. package/src/JsModule.test.ts +1 -1
  22. package/src/NodeFileSystem.ts +764 -0
  23. package/src/Random.ts +59 -0
  24. package/src/Route.test.ts +515 -18
  25. package/src/Route.ts +321 -166
  26. package/src/RouteRender.ts +40 -0
  27. package/src/Router.test.ts +416 -0
  28. package/src/Router.ts +288 -31
  29. package/src/RouterPattern.test.ts +655 -0
  30. package/src/RouterPattern.ts +416 -0
  31. package/src/Start.ts +14 -52
  32. package/src/TestHttpClient.test.ts +29 -0
  33. package/src/TestHttpClient.ts +122 -73
  34. package/src/assets.d.ts +39 -0
  35. package/src/bun/BunBundle.test.ts +0 -3
  36. package/src/bun/BunHttpServer.test.ts +74 -0
  37. package/src/bun/BunHttpServer.ts +259 -0
  38. package/src/bun/BunHttpServer_web.ts +384 -0
  39. package/src/bun/BunRoute.test.ts +514 -0
  40. package/src/bun/BunRoute.ts +427 -0
  41. package/src/bun/BunRoute_bundles.test.ts +218 -0
  42. package/src/bun/BunRuntime.ts +33 -0
  43. package/src/bun/BunTailwindPlugin.test.ts +1 -1
  44. package/src/bun/_empty.html +1 -0
  45. package/src/bun/index.ts +2 -1
  46. package/src/index.ts +14 -14
  47. package/src/middlewares/BasicAuthMiddleware.test.ts +74 -0
  48. package/src/middlewares/BasicAuthMiddleware.ts +36 -0
  49. package/src/testing.ts +12 -3
  50. package/src/Datastar.test.ts +0 -267
  51. package/src/Datastar.ts +0 -68
  52. package/src/bun/BunFullstackServer.ts +0 -45
  53. package/src/bun/BunFullstackServer_httpServer.ts +0 -541
  54. package/src/jsx-datastar.d.ts +0 -63
@@ -3,9 +3,14 @@ import * as HttpApp from "@effect/platform/HttpApp"
3
3
  import * as HttpMiddleware from "@effect/platform/HttpMiddleware"
4
4
  import * as HttpRouter from "@effect/platform/HttpRouter"
5
5
  import * as HttpServerRequest from "@effect/platform/HttpServerRequest"
6
+ import * as HttpServerResponse from "@effect/platform/HttpServerResponse"
6
7
  import * as Effect from "effect/Effect"
7
8
  import * as Function from "effect/Function"
9
+ import * as HttpUtils from "./HttpUtils.ts"
10
+ import * as Route from "./Route.ts"
8
11
  import * as Router from "./Router.ts"
12
+ import * as RouteRender from "./RouteRender.ts"
13
+ import * as RouterPattern from "./RouterPattern.ts"
9
14
 
10
15
  /**
11
16
  * Combines Effect error channel from a record of effects.
@@ -54,52 +59,133 @@ export type HttpRouterFromServerRoutes<
54
59
  >
55
60
 
56
61
  /**
57
- * Converts file-based route path format to HttpRouter path format.
58
- * Examples:
59
- * /movies/[id] -> /movies/:id
60
- * /docs/[[...slug]] -> /docs/*
61
- * /api/[...path] -> /api/*
62
+ * Find layer routes that match a given route's method and media type.
62
63
  */
63
- function convertPathFormat(path: string): string {
64
- return path
65
- // Convert required params: [id] -> :id
66
- .replace(/\[([^\]\.]+)\]/g, ":$1")
67
- // Convert optional rest params: [[...slug]] -> *
68
- .replace(/\[\[\.\.\.([^\]]+)\]\]/g, "*")
69
- // Convert required rest params: [...path] -> *
70
- .replace(/\[\.\.\.([^\]]+)\]/g, "*")
64
+ function findMatchingLayerRoutes(
65
+ route: Route.Route.Default,
66
+ layers: Route.RouteLayer[],
67
+ ): Route.Route.Default[] {
68
+ const matchingRoutes: Route.Route.Default[] = []
69
+
70
+ for (const layer of layers) {
71
+ for (const layerRoute of layer.set) {
72
+ if (Route.matches(layerRoute, route)) {
73
+ matchingRoutes.push(layerRoute)
74
+ }
75
+ }
76
+ }
77
+
78
+ return matchingRoutes
79
+ }
80
+
81
+ /**
82
+ * Wrap an inner route with a layer route.
83
+ * Returns a new route that, when executed, provides next() to call the inner route.
84
+ */
85
+ function wrapWithLayerRoute(
86
+ innerRoute: Route.Route.Default,
87
+ layerRoute: Route.Route.Default,
88
+ ): Route.Route.Default {
89
+ const handler: Route.RouteHandler = (context) => {
90
+ const innerNext = () => innerRoute.handler(context)
91
+
92
+ const contextWithNext: Route.RouteContext = {
93
+ ...context,
94
+ next: innerNext,
95
+ }
96
+
97
+ return layerRoute.handler(contextWithNext)
98
+ }
99
+
100
+ return Route.make({
101
+ method: layerRoute.method,
102
+ media: layerRoute.media,
103
+ handler,
104
+ schemas: {},
105
+ })
71
106
  }
72
107
 
73
108
  /**
74
109
  * Makes a HttpRouter from file-based routes.
75
110
  */
76
- export function make<Routes extends Router.ServerRoutes>(
111
+
112
+ export function make<
113
+ Routes extends ReadonlyArray<Router.ServerRoute>,
114
+ >(
77
115
  routes: Routes,
78
116
  ): Effect.Effect<HttpRouterFromServerRoutes<Routes>> {
79
117
  return Effect.gen(function*() {
80
- const modules = yield* Effect.forEach(
118
+ const routesWithModules = yield* Effect.forEach(
81
119
  routes,
82
120
  (route) =>
83
- Function.pipe(
84
- Effect.tryPromise(() => route.load()),
85
- Effect.orDie,
86
- Effect.map((module) => ({ path: route.path, module })),
87
- ),
121
+ Effect.gen(function*() {
122
+ const module = yield* Effect.tryPromise(() => route.load()).pipe(
123
+ Effect.orDie,
124
+ )
125
+
126
+ const layerModules = route.layers
127
+ ? yield* Effect.forEach(
128
+ route.layers,
129
+ (layerLoad) =>
130
+ Effect.tryPromise(() => layerLoad()).pipe(Effect.orDie),
131
+ )
132
+ : []
133
+
134
+ const layers = layerModules
135
+ .map((mod: any) => mod.default)
136
+ .filter(Route.isRouteLayer)
137
+
138
+ return {
139
+ path: route.path,
140
+ routeSet: module.default,
141
+ layers,
142
+ }
143
+ }),
88
144
  )
89
145
 
90
146
  let router: HttpRouter.HttpRouter<any, any> = HttpRouter.empty
91
147
 
92
- for (const { path, module } of modules) {
93
- const routeSet = module.default
94
- const httpRouterPath = convertPathFormat(path)
95
-
148
+ for (const { path, routeSet, layers } of routesWithModules) {
96
149
  for (const route of routeSet.set) {
97
- router = HttpRouter.route(route.method)(
98
- httpRouterPath,
99
- route.handler as any,
100
- )(
101
- router,
102
- )
150
+ const matchingLayerRoutes = findMatchingLayerRoutes(route, layers)
151
+
152
+ let wrappedRoute = route
153
+ // Reverse so first layer in array becomes outermost wrapper.
154
+ // Example: [outerLayer, innerLayer] wraps as outer(inner(route))
155
+ for (const layerRoute of matchingLayerRoutes.reverse()) {
156
+ wrappedRoute = wrapWithLayerRoute(wrappedRoute, layerRoute)
157
+ }
158
+
159
+ const wrappedHandler: HttpApp.Default = Effect.gen(function*() {
160
+ const request = yield* HttpServerRequest.HttpServerRequest
161
+
162
+ const context: Route.RouteContext = {
163
+ request,
164
+ get url() {
165
+ return HttpUtils.makeUrlFromRequest(request)
166
+ },
167
+ slots: {},
168
+ next: () => Effect.void,
169
+ }
170
+
171
+ return yield* RouteRender.render(wrappedRoute, context)
172
+ })
173
+
174
+ const allMiddleware = layers
175
+ .map((layer) => layer.httpMiddleware)
176
+ .filter((m): m is Route.HttpMiddlewareFunction => m !== undefined)
177
+
178
+ let finalHandler = wrappedHandler
179
+ for (const middleware of allMiddleware) {
180
+ finalHandler = middleware(finalHandler)
181
+ }
182
+
183
+ for (const pattern of RouterPattern.toEffect(path)) {
184
+ router = HttpRouter.route(route.method)(
185
+ pattern,
186
+ finalHandler as any,
187
+ )(router)
188
+ }
103
189
  }
104
190
  }
105
191
 
@@ -111,11 +197,12 @@ export function middleware() {
111
197
  return HttpMiddleware.make((app) =>
112
198
  Effect.gen(function*() {
113
199
  const routerContext = yield* Router.Router
114
- const router = routerContext.httpRouter
200
+ const router = yield* make(
201
+ routerContext.routes as ReadonlyArray<Router.LazyRoute>,
202
+ )
115
203
  const res = yield* router.pipe(
116
204
  Effect.catchTag("RouteNotFound", () => app),
117
205
  )
118
-
119
206
  return res
120
207
  })
121
208
  )
package/src/FileRouter.ts CHANGED
@@ -9,64 +9,25 @@ import * as Stream from "effect/Stream"
9
9
  import * as NPath from "node:path"
10
10
  import * as NUrl from "node:url"
11
11
  import * as FileRouterCodegen from "./FileRouterCodegen.ts"
12
+ import * as FileRouterPattern from "./FileRouterPattern.ts"
12
13
  import * as FileSystemExtra from "./FileSystemExtra.ts"
13
- import { ServerModule } from "./Router.ts"
14
+ import * as Router from "./Router.ts"
14
15
 
15
- type LiteralSegment = {
16
- literal: string
17
- }
18
-
19
- type GroupSegment = {
20
- group: string
21
- }
22
-
23
- type ParamSegment = {
24
- param: string
25
- optional?: true
26
- }
27
-
28
- type RestSegment = {
29
- rest: string
30
- optional?: true
31
- }
16
+ export type GroupSegment<Name extends string = string> =
17
+ FileRouterPattern.GroupSegment<Name>
32
18
 
33
- type HandleSegment = {
34
- handle: "route" | "layer"
35
- }
36
-
37
- export type Extension = "tsx" | "jsx" | "ts" | "js"
38
-
39
- export type Segment =
40
- | LiteralSegment
41
- | GroupSegment
42
- | ParamSegment
43
- | RestSegment
44
- | HandleSegment
45
-
46
- export function isSegmentEqual(a: Segment, b: Segment): boolean {
47
- if ("literal" in a && "literal" in b) return a.literal === b.literal
48
- if ("group" in a && "group" in b) return a.group === b.group
49
- if ("param" in a && "param" in b) return a.param === b.param
50
- if ("rest" in a && "rest" in b) return a.rest === b.rest
51
- if ("handle" in a && "handle" in b) return a.handle === b.handle
52
- return false
53
- }
54
-
55
- export type RouteModule = {
56
- path: `/${string}`
57
- segments: readonly Segment[]
58
- load: () => Promise<ServerModule>
59
- layers?: ReadonlyArray<() => Promise<unknown>>
60
- }
19
+ export type Segment = FileRouterPattern.Segment
61
20
 
62
21
  export type RouteManifest = {
63
- Modules: readonly RouteModule[]
22
+ routes: readonly Router.LazyRoute[]
64
23
  }
65
24
 
66
25
  export type RouteHandle = {
67
26
  handle: "route" | "layer"
68
- modulePath: string // eg. `about/route.tsx`, `users/[userId]/route.tsx`, `(admin)/users/route.tsx`
69
- routePath: `/${string}` // eg. `/about`, `/users/[userId]`, `/users` (groups stripped)
27
+ // eg. `about/route.tsx`, `users/[userId]/route.tsx`, `(admin)/users/route.tsx`
28
+ modulePath: string
29
+ // eg. `/about`, `/users/[userId]`, `/users` (groups stripped)
30
+ routePath: `/${string}`
70
31
  segments: Segment[]
71
32
  }
72
33
 
@@ -80,113 +41,31 @@ export type RouteHandle = {
80
41
  */
81
42
  export type OrderedRouteHandles = RouteHandle[]
82
43
 
83
- const ROUTE_PATH_REGEX = /^\/?(.*\/?)((route|layer))\.(jsx?|tsx?)$/
84
-
85
- type RoutePathMatch = [
86
- path: string,
87
- kind: string,
88
- kind: string,
89
- ext: string,
90
- ]
91
-
92
- export function segmentPath(path: string): Segment[] {
93
- const trimmedPath = path.replace(/(^\/)|(\/$)/g, "") // trim leading/trailing slashes
94
-
95
- if (trimmedPath === "") {
96
- return [] // Handles "" and "/"
97
- }
98
-
99
- const segmentStrings = trimmedPath
100
- .split("/")
101
- .filter(s => s !== "") // Remove empty segments from multiple slashes, e.g. "foo//bar"
102
-
103
- if (segmentStrings.length === 0) {
104
- return []
105
- }
106
-
107
- const segments: (Segment | null)[] = segmentStrings.map(
108
- (s): Segment | null => {
109
- // Check if it's a handle (route.ts, layer.tsx, etc.)
110
- const [, handle] = s.match(/^(route|layer)\.(tsx?|jsx?)$/)
111
- ?? []
112
-
113
- if (handle) {
114
- // @ts-expect-error regexp group ain't typed
115
- return { handle }
116
- }
117
-
118
- // (group) - Groups
119
- const groupMatch = s.match(/^\((\w+)\)$/)
120
- if (groupMatch) {
121
- return { group: groupMatch[1] }
122
- }
123
-
124
- // [[...rest]] - Optional rest parameter
125
- const optionalRestMatch = s.match(/^\[\[\.\.\.(\w+)\]\]$/)
126
- if (optionalRestMatch) {
127
- return {
128
- rest: optionalRestMatch[1],
129
- optional: true,
130
- }
131
- }
132
-
133
- // [...rest] - Required rest parameter
134
- const requiredRestMatch = s.match(/^\[\.\.\.(\w+)\]$/)
135
- if (requiredRestMatch) {
136
- return { rest: requiredRestMatch[1] }
137
- }
138
-
139
- // [param] - Dynamic parameter
140
- const paramMatch = s.match(/^\[(\w+)\]$/)
141
- if (paramMatch) {
142
- return { param: paramMatch[1] }
143
- }
144
-
145
- // Literal segment
146
- if (/^[A-Za-z0-9._~-]+$/.test(s)) {
147
- return { literal: s }
148
- }
149
-
150
- return null
151
- },
152
- )
153
-
154
- if (segments.some((seg) => seg === null)) {
155
- throw new Error(
156
- `Invalid path segment in "${path}": contains invalid characters or format`,
157
- )
158
- }
159
-
160
- return segments as Segment[]
161
- }
44
+ const ROUTE_PATH_REGEX = /^\/?(.*\/?)(?:route|layer)\.(jsx?|tsx?)$/
162
45
 
163
- function segmentToText(seg: Segment): string {
164
- if ("literal" in seg) return seg.literal
165
- if ("group" in seg) return `(${seg.group})`
166
- if ("param" in seg) return `[${seg.param}]`
167
- if ("rest" in seg) {
168
- return seg.optional ? `[[...${seg.rest}]]` : `[...${seg.rest}]`
169
- }
170
- if ("handle" in seg) return seg.handle
171
- return ""
172
- }
46
+ export const parse = FileRouterPattern.parse
47
+ export const formatSegment = FileRouterPattern.formatSegment
48
+ export const format = FileRouterPattern.format
173
49
 
174
50
  export function parseRoute(
175
51
  path: string,
176
52
  ): RouteHandle {
177
- const segs = segmentPath(path)
53
+ const segs = parse(path)
178
54
 
179
- const handle = segs.at(-1)
55
+ const lastSeg = segs.at(-1)
56
+ const handleMatch = lastSeg?._tag === "LiteralSegment"
57
+ && lastSeg.value.match(/^(route|layer)\.(tsx?|jsx?)$/)
58
+ const handle = handleMatch ? handleMatch[1] as "route" | "layer" : null
180
59
 
181
- if (!handle || !("handle" in handle)) {
60
+ if (!handle) {
182
61
  throw new Error(
183
62
  `Invalid route path "${path}": must end with a valid handle (route or layer)`,
184
63
  )
185
64
  }
186
65
 
187
- // Validate Route constraints: rest segments must be the last segment before the handle
66
+ // rest segments must be the last segment before the handle
188
67
  const pathSegments = segs.slice(0, -1) // All segments except the handle
189
- const restIndex = pathSegments.findIndex(seg => "rest" in seg)
68
+ const restIndex = pathSegments.findIndex(seg => seg._tag === "RestSegment")
190
69
 
191
70
  if (restIndex !== -1) {
192
71
  // If there's a rest, it must be the last path segment
@@ -196,19 +75,27 @@ export function parseRoute(
196
75
  )
197
76
  }
198
77
 
199
- // Validate that all segments before the rest are literal, param, or group
78
+ // all segments before the rest must be literal, param, or group
200
79
  for (let i = 0; i < restIndex; i++) {
201
80
  const seg = pathSegments[i]
202
- if (!("literal" in seg) && !("param" in seg) && !("group" in seg)) {
81
+ if (
82
+ seg._tag !== "LiteralSegment"
83
+ && seg._tag !== "ParamSegment"
84
+ && seg._tag !== "GroupSegment"
85
+ ) {
203
86
  throw new Error(
204
87
  `Invalid route path "${path}": segments before rest must be literal, param, or group segments`,
205
88
  )
206
89
  }
207
90
  }
208
91
  } else {
209
- // No rest: validate that all path segments are literal, param, or group
92
+ // No rest: all path segments are literal, param, or group
210
93
  for (const seg of pathSegments) {
211
- if (!("literal" in seg) && !("param" in seg) && !("group" in seg)) {
94
+ if (
95
+ seg._tag !== "LiteralSegment"
96
+ && seg._tag !== "ParamSegment"
97
+ && seg._tag !== "GroupSegment"
98
+ ) {
212
99
  throw new Error(
213
100
  `Invalid route path "${path}": path segments must be literal, param, or group segments`,
214
101
  )
@@ -216,37 +103,33 @@ export function parseRoute(
216
103
  }
217
104
  }
218
105
 
219
- // Construct routePath from path segments (excluding handle and groups)
220
- // Groups like (admin) are stripped from the URL path
221
- const routePathSegments = pathSegments
222
- .filter(seg => !("group" in seg))
223
- .map(segmentToText)
224
-
225
- const routePath = (routePathSegments.length > 0
226
- ? `/${routePathSegments.join("/")}`
227
- : "/") as `/${string}`
106
+ const routePathSegments = pathSegments.filter(
107
+ seg => seg._tag !== "GroupSegment",
108
+ )
109
+ const routePath = FileRouterPattern.format(routePathSegments)
228
110
 
229
111
  return {
230
- handle: handle.handle,
112
+ handle,
231
113
  modulePath: path,
232
114
  routePath,
233
- segments: segs,
115
+ segments: pathSegments,
234
116
  }
235
117
  }
236
118
 
237
119
  /**
238
- * Generates a file that references all routes.
120
+ * Generates a manifest file that references all routes.
239
121
  */
240
- export function layer(options: {
122
+ export function layerManifest(options: {
241
123
  load: () => Promise<unknown>
242
124
  path: string
243
125
  }) {
244
126
  let manifestPath = options.path
245
-
246
- // handle use of import.meta.resolve
247
127
  if (manifestPath.startsWith("file://")) {
248
128
  manifestPath = NUrl.fileURLToPath(manifestPath)
249
129
  }
130
+ if (NPath.extname(manifestPath) === "") {
131
+ manifestPath = NPath.join(manifestPath, "index.ts")
132
+ }
250
133
 
251
134
  const routesPath = NPath.dirname(manifestPath)
252
135
  const manifestFilename = NPath.basename(manifestPath)
@@ -277,6 +160,19 @@ export function layer(options: {
277
160
  )
278
161
  }
279
162
 
163
+ export function layer(options: {
164
+ load: () => Promise<Router.RouterManifest>
165
+ path: string
166
+ }) {
167
+ return Layer.provide(
168
+ Layer.effect(
169
+ Router.Router,
170
+ Effect.promise(() => options.load()),
171
+ ),
172
+ layerManifest(options),
173
+ )
174
+ }
175
+
280
176
  export function walkRoutesDirectory(
281
177
  dir: string,
282
178
  ): Effect.Effect<
@@ -299,10 +195,10 @@ export function getRouteHandlesFromPaths(
299
195
  paths: string[],
300
196
  ): OrderedRouteHandles {
301
197
  const handles = paths
302
- .map(f => f.match(ROUTE_PATH_REGEX) as RoutePathMatch)
198
+ .map(f => f.match(ROUTE_PATH_REGEX))
303
199
  .filter(Boolean)
304
200
  .map(v => {
305
- const path = v[0]
201
+ const path = v![0]
306
202
  try {
307
203
  return parseRoute(path)
308
204
  } catch {
@@ -313,8 +209,8 @@ export function getRouteHandlesFromPaths(
313
209
  .toSorted((a, b) => {
314
210
  const aDepth = a.segments.length
315
211
  const bDepth = b.segments.length
316
- const aHasRest = a.segments.some(seg => "rest" in seg)
317
- const bHasRest = b.segments.some(seg => "rest" in seg)
212
+ const aHasRest = a.segments.some(seg => seg._tag === "RestSegment")
213
+ const bHasRest = b.segments.some(seg => seg._tag === "RestSegment")
318
214
 
319
215
  return (
320
216
  // rest is a dominant factor (routes with rest come last)