effect-start 0.11.0 → 0.12.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.
package/README.md CHANGED
@@ -14,96 +14,65 @@ by checking out `examples/` directory.
14
14
  It exports a layer that applies configuration and changes the behavior of the server:
15
15
 
16
16
  ```typescript
17
- import { Start } from "effect-start"
18
- import { BunTailwindPlugin } from "effect-start/bun"
19
-
20
- export default Start.make(
21
- // enable file-based router
22
- Start.router(() => import("./routes/_manifest")),
23
- // bundle client-side code for the browser
24
- Start.bundleClient({
25
- entrypoints: [
26
- "src/index.html",
27
- ],
28
- plugins: [
29
- // enable TailwindCSS for client bundle
30
- BunTailwindPlugin.make(),
31
- ],
17
+ import {
18
+ FileRouter,
19
+ Start,
20
+ } from "effect-start"
21
+
22
+ export default Start.layer(
23
+ FileRouter.layer({
24
+ load: () => import("./routes/manifest.ts"),
25
+ path: import.meta.resolve("./routes/manifest.ts"),
32
26
  }),
33
27
  )
28
+
29
+ if (import.meta.main) {
30
+ Start.serve(() => import("./server.ts"))
31
+ }
34
32
  ```
35
33
 
36
34
  ### File-based Routing
37
35
 
38
- Effect Start provides automatic file-based routing with support for frontend pages, backend endpoints, and stackable layouts.
36
+ Effect Start provides automatic file-based routing with support for frontend pages, backend endpoints, and stackable middlewares called Route Layers.
39
37
 
40
38
  ```
41
- src/routes/
42
- ├── _layout.tsx # Root layout
43
- ├── _page.tsx # Home page (/)
44
- ├── about/
45
- ├── _layout.tsx # Nested layout for /about/*
46
- └── _page.tsx # Static route (/about)
47
- ├── users/
48
- ├── _page.tsx # Users list (/users)
49
- └── $id/_page.tsx # Dynamic route (/users/:id)
50
- └── $/_page.tsx # Splat/catch-all (/**)
51
- ```
52
-
53
- ```ts
54
- import { FileRouter } from "effect-start"
55
-
56
- // Generate route manifest and watch for changes
57
- const routerLayer = FileRouter.layer(import.meta.resolve("routes"))
39
+ $ tree src/routes
40
+ src/routes
41
+ ├── [[...frontend]]
42
+ │   └── route.ts
43
+ ├── admin
44
+    ├── data.json
45
+ │   │   └── route.tsx
46
+    ├── layer.tsx
47
+    └── route.tsx
48
+ ├── layer.tsx
49
+ ├── manifest.ts
50
+ └── route.tsx
58
51
  ```
59
52
 
60
- **Note:** Ensure `FileRouter.layer` is provided after any bundle layer to guarantee the manifest file is properly generated before bundling.
61
-
62
53
  ### Tailwind CSS Support
63
54
 
64
- Effect Start includes built-in support for Tailwind CSS:
65
-
66
- ```ts
67
- import { Start } from "effect-start"
68
- import { BunTailwindPlugin } from "effect-start/bun"
55
+ Install Tailwind plugin:
69
56
 
70
- const ClientBundle = Start.bundleClient({
71
- entrypoints: [
72
- "./src/index.html",
73
- ],
74
- plugins: [
75
- BunTailwindPlugin.make(),
76
- ],
77
- })
78
-
79
- export default Start.make(
80
- ClientBundle,
81
- )
82
-
83
- if (import.meta.main) {
84
- Start.serve(() => import("./server"))
85
- }
57
+ ```sh
58
+ bun add tailwindcss bun-plugin-tailwind
86
59
  ```
87
60
 
88
- Then in your main CSS files add following file:
61
+ In `bunfig.toml`:
89
62
 
90
- ```css
91
- @import "tailwindcss";
63
+ ```toml
64
+ [serve.static]
65
+ plugins = ["bun-plugin-tailwind"]
92
66
  ```
93
67
 
94
- ### Cloudflare Tunnel
95
-
96
- Tunnel your local server to the Internet with `cloudflared`
97
-
98
- ```ts
99
- import { Start } from "effect-start"
100
- import { CloudflareTunnel } from "effect-start/x/cloudflare"
68
+ Finally, include it in your `src/app.html`:
101
69
 
102
- export default Start.make(
103
- CloudflareTunnel.layer(),
104
- )
105
-
106
- if (import.meta.main) {
107
- Start.serve(() => import("./server"))
108
- }
70
+ ```html
71
+ <!doctype html>
72
+ <html>
73
+ <head>
74
+ <link rel="stylesheet" href="tailwindcss" />
75
+ </head>
76
+ <!-- the rest of your HTML... -->
77
+ </html>
109
78
  ```
package/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "effect-start",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "exports": {
7
7
  ".": "./src/index.ts",
8
- "./Router": "./src/Router.ts",
8
+ "./FileRouter": "./src/FileRouter.ts",
9
9
  "./Route": "./src/Route.ts",
10
10
  "./EncryptedCookies": "./src/EncryptedCookies.ts",
11
11
  "./bun": "./src/bun/index.ts",
@@ -25,11 +25,11 @@
25
25
  "deploy": "bunx npm publish --access public"
26
26
  },
27
27
  "dependencies": {
28
- "@effect/platform": "^0.93.3"
28
+ "effect": ">=3.16.4",
29
+ "@effect/platform": "^0.93.6"
29
30
  },
30
31
  "peerDependencies": {
31
- "typescript": "^5.9.3",
32
- "effect": ">=3.16.4"
32
+ "typescript": "^5.9.3"
33
33
  },
34
34
  "peerDependenciesMeta": {
35
35
  "@tailwindcss/node": {
@@ -39,15 +39,14 @@
39
39
  "devDependencies": {
40
40
  "@dprint/json": "^0.21.0",
41
41
  "@dprint/markdown": "^0.20.0",
42
- "@dprint/typescript": "^0.95.12",
43
- "@effect/language-service": "^0.56.0",
42
+ "@dprint/typescript": "^0.95.13",
43
+ "@effect/language-service": "^0.61.0",
44
44
  "@tailwindcss/node": "^4.1.17",
45
- "@types/bun": "^1.3.3",
45
+ "@types/bun": "^1.3.4",
46
46
  "@types/react": "^19.2.7",
47
47
  "@types/react-dom": "^19.2.3",
48
48
  "dprint-cli": "^0.4.1",
49
49
  "dprint-markup": "nounder/dprint-markup",
50
- "effect": "^3.19.6",
51
50
  "effect-memfs": "^0.8.0",
52
51
  "ts-namespace-import": "nounder/ts-namespace-import#140c405"
53
52
  },
@@ -1,6 +1,7 @@
1
1
  import * as t from "bun:test"
2
2
  import * as Effect from "effect/Effect"
3
3
  import * as Schema from "effect/Schema"
4
+ import * as assert from "node:assert"
4
5
  import * as Commander from "./Commander.ts"
5
6
 
6
7
  t.describe(`${Commander.make.name}`, () => {
@@ -1216,10 +1217,8 @@ t.describe("error handling", () => {
1216
1217
  Effect.either(Commander.parse(cmd, ["--port", "invalid"])),
1217
1218
  )
1218
1219
 
1219
- t.expect(result._tag).toBe("Left")
1220
- if (result._tag === "Left") {
1221
- t.expect(result.left.message).toContain("Invalid value")
1222
- }
1220
+ assert.strictEqual(result._tag, "Left")
1221
+ t.expect(result.left.message).toContain("Invalid value")
1223
1222
  })
1224
1223
 
1225
1224
  t.it("should fail on invalid choice", async () => {
@@ -1,14 +1,12 @@
1
1
  import * as Error from "@effect/platform/Error"
2
2
  import * as FileSystem from "@effect/platform/FileSystem"
3
- import * as HttpApp from "@effect/platform/HttpApp"
4
3
  import * as HttpRouter from "@effect/platform/HttpRouter"
5
- import * as HttpServerResponse from "@effect/platform/HttpServerResponse"
6
4
  import * as t from "bun:test"
7
5
  import * as Data from "effect/Data"
8
6
  import * as Effect from "effect/Effect"
9
7
  import * as FileHttpRouter from "./FileHttpRouter.ts"
8
+ import * as FileRouter from "./FileRouter.ts"
10
9
  import * as Route from "./Route.ts"
11
- import * as Router from "./Router.ts"
12
10
  import * as TestHttpClient from "./TestHttpClient.ts"
13
11
  import { effectFn } from "./testing.ts"
14
12
 
@@ -31,13 +29,7 @@ const SampleRoutes = [
31
29
  },
32
30
  ] as const
33
31
 
34
- const SampleRouteManifest: Router.RouterManifest = {
35
- routes: SampleRoutes,
36
- }
37
-
38
- const routerLayer = Router.layerPromise(async () => SampleRouteManifest)
39
-
40
- const effect = effectFn(routerLayer)
32
+ const effect = effectFn()
41
33
 
42
34
  t.it("HttpRouter Requirement and Error types infers", () =>
43
35
  effect(function*() {
@@ -53,7 +45,7 @@ t.it("HttpRouter Requirement and Error types infers", () =>
53
45
 
54
46
  t.it("HTTP methods", () =>
55
47
  effect(function*() {
56
- const allMethodsRoute: Router.LazyRoute = {
48
+ const allMethodsRoute: FileRouter.LazyRoute = {
57
49
  path: "/",
58
50
  load: async () => ({
59
51
  default: Route
@@ -111,40 +103,11 @@ t.it("router handles requests correctly", () =>
111
103
  .toBe("User created")
112
104
  }))
113
105
 
114
- t.it("middleware falls back to original app on 404", () =>
115
- effect(function*() {
116
- const middleware = FileHttpRouter.middleware()
117
- const fallbackApp = Effect.succeed(HttpServerResponse.text("fallback"))
118
- const middlewareApp = middleware(fallbackApp)
119
-
120
- const client = TestHttpClient.make(middlewareApp)
121
-
122
- const existingRouteResponse = yield* client.get("/users")
123
-
124
- t
125
- .expect(existingRouteResponse.status)
126
- .toBe(200)
127
-
128
- t
129
- .expect(yield* existingRouteResponse.text)
130
- .toBe("Users list")
131
-
132
- const notFoundResponse = yield* client.get("/nonexistent")
133
-
134
- t
135
- .expect(notFoundResponse.status)
136
- .toBe(200)
137
-
138
- t
139
- .expect(yield* notFoundResponse.text)
140
- .toBe("fallback")
141
- }))
142
-
143
106
  t.it(
144
107
  "handles routes with special characters (tilde and hyphen)",
145
108
  () =>
146
109
  effect(function*() {
147
- const specialCharRoutes: Router.LazyRoute[] = [
110
+ const specialCharRoutes: FileRouter.LazyRoute[] = [
148
111
  {
149
112
  path: "/api-v1",
150
113
  load: async () => ({
@@ -204,7 +167,7 @@ t.it(
204
167
  "layer routes can wrap inner routes using next()",
205
168
  () =>
206
169
  effect(function*() {
207
- const routeWithLayer: Router.LazyRoute = {
170
+ const routeWithLayer: FileRouter.LazyRoute = {
208
171
  path: "/page",
209
172
  load: async () => ({
210
173
  default: Route.html(Effect.succeed("<h1>Page Content</h1>")),
@@ -236,7 +199,7 @@ t.it(
236
199
 
237
200
  t.it("nested layers compose correctly with next()", () =>
238
201
  effect(function*() {
239
- const routeWithNestedLayers: Router.LazyRoute = {
202
+ const routeWithNestedLayers: FileRouter.LazyRoute = {
240
203
  path: "/nested",
241
204
  load: async () => ({
242
205
  default: Route.html(Effect.succeed("content")),
@@ -192,18 +192,3 @@ export function make<
192
192
  return router as HttpRouterFromServerRoutes<Routes>
193
193
  })
194
194
  }
195
-
196
- export function middleware() {
197
- return HttpMiddleware.make((app) =>
198
- Effect.gen(function*() {
199
- const routerContext = yield* Router.Router
200
- const router = yield* make(
201
- routerContext.routes as ReadonlyArray<Router.LazyRoute>,
202
- )
203
- const res = yield* router.pipe(
204
- Effect.catchTag("RouteNotFound", () => app),
205
- )
206
- return res
207
- })
208
- )
209
- }
package/src/FileRouter.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { PlatformError } from "@effect/platform/Error"
2
2
  import * as FileSystem from "@effect/platform/FileSystem"
3
3
  import * as Array from "effect/Array"
4
+ import * as Context from "effect/Context"
4
5
  import * as Effect from "effect/Effect"
5
6
  import * as Function from "effect/Function"
6
7
  import * as Layer from "effect/Layer"
@@ -11,17 +12,33 @@ import * as NUrl from "node:url"
11
12
  import * as FileRouterCodegen from "./FileRouterCodegen.ts"
12
13
  import * as FileRouterPattern from "./FileRouterPattern.ts"
13
14
  import * as FileSystemExtra from "./FileSystemExtra.ts"
15
+ import * as Route from "./Route.ts"
14
16
  import * as Router from "./Router.ts"
15
17
 
18
+ export type RouteModule = {
19
+ default: Route.RouteSet.Default
20
+ }
21
+
22
+ export type LazyRoute = {
23
+ path: `/${string}`
24
+ load: () => Promise<RouteModule>
25
+ layers?: ReadonlyArray<() => Promise<unknown>>
26
+ }
27
+
28
+ type Manifest = {
29
+ routes: readonly LazyRoute[]
30
+ }
31
+
32
+ export class FileRouter extends Context.Tag("effect-start/FileRouter")<
33
+ FileRouter,
34
+ Manifest
35
+ >() {}
36
+
16
37
  export type GroupSegment<Name extends string = string> =
17
38
  FileRouterPattern.GroupSegment<Name>
18
39
 
19
40
  export type Segment = FileRouterPattern.Segment
20
41
 
21
- export type RouteManifest = {
22
- routes: readonly Router.LazyRoute[]
23
- }
24
-
25
42
  export type RouteHandle = {
26
43
  handle: "route" | "layer"
27
44
  // eg. `about/route.tsx`, `users/[userId]/route.tsx`, `(admin)/users/route.tsx`
@@ -119,8 +136,8 @@ export function parseRoute(
119
136
  /**
120
137
  * Generates a manifest file that references all routes.
121
138
  */
122
- export function layerManifest(options: {
123
- load: () => Promise<unknown>
139
+ export function layer(options: {
140
+ load: () => Promise<Manifest>
124
141
  path: string
125
142
  }) {
126
143
  let manifestPath = options.path
@@ -135,44 +152,69 @@ export function layerManifest(options: {
135
152
  const manifestFilename = NPath.basename(manifestPath)
136
153
  const resolvedManifestPath = NPath.resolve(routesPath, manifestFilename)
137
154
 
138
- return Layer.scopedDiscard(
139
- Effect.gen(function*() {
140
- yield* FileRouterCodegen.update(routesPath, manifestFilename)
141
-
142
- const stream = Function.pipe(
143
- FileSystemExtra.watchSource({
144
- path: routesPath,
145
- filter: (e) => !e.path.includes("node_modules"),
146
- }),
147
- Stream.onError((e) => Effect.logError(e)),
148
- )
149
-
150
- yield* Function.pipe(
151
- stream,
152
- // filter out edits to gen file
153
- Stream.filter(e => e.path !== resolvedManifestPath),
154
- Stream.runForEach(() =>
155
- FileRouterCodegen.update(routesPath, manifestFilename)
156
- ),
157
- Effect.fork,
158
- )
159
- }),
160
- )
161
- }
162
-
163
- export function layer(options: {
164
- load: () => Promise<Router.RouterManifest>
165
- path: string
166
- }) {
167
155
  return Layer.provide(
168
156
  Layer.effect(
169
- Router.Router,
157
+ FileRouter,
170
158
  Effect.promise(() => options.load()),
171
159
  ),
172
- layerManifest(options),
160
+ Layer.scopedDiscard(
161
+ Effect.gen(function*() {
162
+ yield* FileRouterCodegen.update(routesPath, manifestFilename)
163
+
164
+ const stream = Function.pipe(
165
+ FileSystemExtra.watchSource({
166
+ path: routesPath,
167
+ filter: (e) => !e.path.includes("node_modules"),
168
+ }),
169
+ Stream.onError((e) => Effect.logError(e)),
170
+ )
171
+
172
+ yield* Function.pipe(
173
+ stream,
174
+ // filter out edits to gen file
175
+ Stream.filter(e => e.path !== resolvedManifestPath),
176
+ Stream.runForEach(() =>
177
+ FileRouterCodegen.update(routesPath, manifestFilename)
178
+ ),
179
+ Effect.fork,
180
+ )
181
+ }),
182
+ ),
173
183
  )
174
184
  }
175
185
 
186
+ export function fromManifest(
187
+ manifest: Manifest,
188
+ ): Effect.Effect<Router.Router.Any> {
189
+ return Effect.gen(function*() {
190
+ const loadedEntries = yield* Effect.forEach(
191
+ manifest.routes,
192
+ (lazyRoute) =>
193
+ Effect.gen(function*() {
194
+ const routeModule = yield* Effect.promise(() => lazyRoute.load())
195
+ const layerModules = lazyRoute.layers
196
+ ? yield* Effect.forEach(
197
+ lazyRoute.layers,
198
+ (loadLayer) => Effect.promise(() => loadLayer()),
199
+ )
200
+ : []
201
+
202
+ const layers = layerModules
203
+ .map((m: any) => m.default)
204
+ .filter(Route.isRouteLayer)
205
+
206
+ return {
207
+ path: lazyRoute.path,
208
+ route: routeModule.default,
209
+ layers,
210
+ }
211
+ }),
212
+ )
213
+
214
+ return Router.make(loadedEntries, [])
215
+ })
216
+ }
217
+
176
218
  export function walkRoutesDirectory(
177
219
  dir: string,
178
220
  ): Effect.Effect<