effect-start 0.9.0 → 0.10.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 (44) hide show
  1. package/package.json +12 -13
  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 +81 -12
  7. package/src/FileHttpRouter.ts +115 -26
  8. package/src/FileRouter.ts +60 -162
  9. package/src/FileRouterCodegen.test.ts +250 -64
  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/HttpUtils.test.ts +68 -0
  17. package/src/HttpUtils.ts +15 -0
  18. package/src/HyperHtml.ts +24 -5
  19. package/src/JsModule.test.ts +1 -1
  20. package/src/NodeFileSystem.ts +764 -0
  21. package/src/Random.ts +59 -0
  22. package/src/Route.test.ts +471 -0
  23. package/src/Route.ts +298 -153
  24. package/src/RouteRender.ts +38 -0
  25. package/src/Router.ts +11 -33
  26. package/src/RouterPattern.test.ts +629 -0
  27. package/src/RouterPattern.ts +391 -0
  28. package/src/Start.ts +14 -52
  29. package/src/bun/BunBundle.test.ts +0 -3
  30. package/src/bun/BunHttpServer.ts +246 -0
  31. package/src/bun/BunHttpServer_web.ts +384 -0
  32. package/src/bun/BunRoute.test.ts +341 -0
  33. package/src/bun/BunRoute.ts +326 -0
  34. package/src/bun/BunRoute_bundles.test.ts +218 -0
  35. package/src/bun/BunRuntime.ts +33 -0
  36. package/src/bun/BunTailwindPlugin.test.ts +1 -1
  37. package/src/bun/_empty.html +1 -0
  38. package/src/bun/index.ts +2 -1
  39. package/src/testing.ts +12 -3
  40. package/src/Datastar.test.ts +0 -267
  41. package/src/Datastar.ts +0 -68
  42. package/src/bun/BunFullstackServer.ts +0 -45
  43. package/src/bun/BunFullstackServer_httpServer.ts +0 -541
  44. 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,49 +59,132 @@ 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)
148
+ for (const { path, routeSet, layers } of routesWithModules) {
149
+ const httpRouterPath = RouterPattern.toHttpPath(path)
95
150
 
96
151
  for (const route of routeSet.set) {
152
+ const matchingLayerRoutes = findMatchingLayerRoutes(route, layers)
153
+
154
+ let wrappedRoute = route
155
+ // Reverse so first layer in array becomes outermost wrapper.
156
+ // Example: [outerLayer, innerLayer] wraps as outer(inner(route))
157
+ for (const layerRoute of matchingLayerRoutes.reverse()) {
158
+ wrappedRoute = wrapWithLayerRoute(wrappedRoute, layerRoute)
159
+ }
160
+
161
+ const wrappedHandler: HttpApp.Default = Effect.gen(function*() {
162
+ const request = yield* HttpServerRequest.HttpServerRequest
163
+
164
+ const context: Route.RouteContext = {
165
+ request,
166
+ get url() {
167
+ return HttpUtils.makeUrlFromRequest(request)
168
+ },
169
+ slots: {},
170
+ next: () => Effect.void,
171
+ }
172
+
173
+ return yield* RouteRender.render(wrappedRoute, context)
174
+ })
175
+
176
+ const allMiddleware = layers
177
+ .map((layer) => layer.httpMiddleware)
178
+ .filter((m): m is Route.HttpMiddlewareFunction => m !== undefined)
179
+
180
+ let finalHandler = wrappedHandler
181
+ for (const middleware of allMiddleware) {
182
+ finalHandler = middleware(finalHandler)
183
+ }
184
+
97
185
  router = HttpRouter.route(route.method)(
98
186
  httpRouterPath,
99
- route.handler as any,
187
+ finalHandler as any,
100
188
  )(
101
189
  router,
102
190
  )
@@ -111,11 +199,12 @@ export function middleware() {
111
199
  return HttpMiddleware.make((app) =>
112
200
  Effect.gen(function*() {
113
201
  const routerContext = yield* Router.Router
114
- const router = routerContext.httpRouter
202
+ const router = yield* make(
203
+ routerContext.routes as ReadonlyArray<Router.LazyRoute>,
204
+ )
115
205
  const res = yield* router.pipe(
116
206
  Effect.catchTag("RouteNotFound", () => app),
117
207
  )
118
-
119
208
  return res
120
209
  })
121
210
  )
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,105 +41,23 @@ 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
  )
@@ -186,7 +65,7 @@ export function parseRoute(
186
65
 
187
66
  // Validate Route constraints: 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
@@ -199,7 +78,11 @@ export function parseRoute(
199
78
  // Validate that all segments before the rest are 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
  )
@@ -208,7 +91,11 @@ export function parseRoute(
208
91
  } else {
209
92
  // No rest: validate that 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,35 @@ export function parseRoute(
216
103
  }
217
104
  }
218
105
 
219
- // Construct routePath from path segments (excluding handle and groups)
106
+ // Construct routePath from path segments (excluding groups)
220
107
  // 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}`
108
+ const routePathSegments = pathSegments.filter(
109
+ seg => seg._tag !== "GroupSegment",
110
+ )
111
+ const routePath = FileRouterPattern.format(routePathSegments)
228
112
 
229
113
  return {
230
- handle: handle.handle,
114
+ handle,
231
115
  modulePath: path,
232
116
  routePath,
233
- segments: segs,
117
+ segments: pathSegments,
234
118
  }
235
119
  }
236
120
 
237
121
  /**
238
- * Generates a file that references all routes.
122
+ * Generates a manifest file that references all routes.
239
123
  */
240
- export function layer(options: {
124
+ export function layerManifest(options: {
241
125
  load: () => Promise<unknown>
242
126
  path: string
243
127
  }) {
244
128
  let manifestPath = options.path
245
-
246
- // handle use of import.meta.resolve
247
129
  if (manifestPath.startsWith("file://")) {
248
130
  manifestPath = NUrl.fileURLToPath(manifestPath)
249
131
  }
132
+ if (NPath.extname(manifestPath) === "") {
133
+ manifestPath = NPath.join(manifestPath, "index.ts")
134
+ }
250
135
 
251
136
  const routesPath = NPath.dirname(manifestPath)
252
137
  const manifestFilename = NPath.basename(manifestPath)
@@ -277,6 +162,19 @@ export function layer(options: {
277
162
  )
278
163
  }
279
164
 
165
+ export function layer(options: {
166
+ load: () => Promise<Router.RouterManifest>
167
+ path: string
168
+ }) {
169
+ return Layer.mergeAll(
170
+ Layer.effect(
171
+ Router.Router,
172
+ Effect.promise(() => options.load()),
173
+ ),
174
+ layerManifest(options),
175
+ )
176
+ }
177
+
280
178
  export function walkRoutesDirectory(
281
179
  dir: string,
282
180
  ): Effect.Effect<
@@ -299,10 +197,10 @@ export function getRouteHandlesFromPaths(
299
197
  paths: string[],
300
198
  ): OrderedRouteHandles {
301
199
  const handles = paths
302
- .map(f => f.match(ROUTE_PATH_REGEX) as RoutePathMatch)
200
+ .map(f => f.match(ROUTE_PATH_REGEX))
303
201
  .filter(Boolean)
304
202
  .map(v => {
305
- const path = v[0]
203
+ const path = v![0]
306
204
  try {
307
205
  return parseRoute(path)
308
206
  } catch {
@@ -313,8 +211,8 @@ export function getRouteHandlesFromPaths(
313
211
  .toSorted((a, b) => {
314
212
  const aDepth = a.segments.length
315
213
  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)
214
+ const aHasRest = a.segments.some(seg => seg._tag === "RestSegment")
215
+ const bHasRest = b.segments.some(seg => seg._tag === "RestSegment")
318
216
 
319
217
  return (
320
218
  // rest is a dominant factor (routes with rest come last)