effect-start 0.9.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 (67) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +109 -0
  3. package/package.json +57 -0
  4. package/src/Bundle.ts +167 -0
  5. package/src/BundleFiles.ts +174 -0
  6. package/src/BundleHttp.test.ts +160 -0
  7. package/src/BundleHttp.ts +259 -0
  8. package/src/Commander.test.ts +1378 -0
  9. package/src/Commander.ts +672 -0
  10. package/src/Datastar.test.ts +267 -0
  11. package/src/Datastar.ts +68 -0
  12. package/src/Effect_HttpRouter.test.ts +570 -0
  13. package/src/EncryptedCookies.test.ts +427 -0
  14. package/src/EncryptedCookies.ts +451 -0
  15. package/src/FileHttpRouter.test.ts +207 -0
  16. package/src/FileHttpRouter.ts +122 -0
  17. package/src/FileRouter.ts +405 -0
  18. package/src/FileRouterCodegen.test.ts +598 -0
  19. package/src/FileRouterCodegen.ts +251 -0
  20. package/src/FileRouter_files.test.ts +64 -0
  21. package/src/FileRouter_path.test.ts +132 -0
  22. package/src/FileRouter_tree.test.ts +126 -0
  23. package/src/FileSystemExtra.ts +102 -0
  24. package/src/HttpAppExtra.ts +127 -0
  25. package/src/Hyper.ts +194 -0
  26. package/src/HyperHtml.test.ts +90 -0
  27. package/src/HyperHtml.ts +139 -0
  28. package/src/HyperNode.ts +37 -0
  29. package/src/JsModule.test.ts +14 -0
  30. package/src/JsModule.ts +116 -0
  31. package/src/PublicDirectory.test.ts +280 -0
  32. package/src/PublicDirectory.ts +108 -0
  33. package/src/Route.test.ts +873 -0
  34. package/src/Route.ts +992 -0
  35. package/src/Router.ts +80 -0
  36. package/src/SseHttpResponse.ts +55 -0
  37. package/src/Start.ts +133 -0
  38. package/src/StartApp.ts +43 -0
  39. package/src/StartHttp.ts +42 -0
  40. package/src/StreamExtra.ts +146 -0
  41. package/src/TestHttpClient.test.ts +54 -0
  42. package/src/TestHttpClient.ts +100 -0
  43. package/src/bun/BunBundle.test.ts +277 -0
  44. package/src/bun/BunBundle.ts +309 -0
  45. package/src/bun/BunBundle_imports.test.ts +50 -0
  46. package/src/bun/BunFullstackServer.ts +45 -0
  47. package/src/bun/BunFullstackServer_httpServer.ts +541 -0
  48. package/src/bun/BunImportTrackerPlugin.test.ts +77 -0
  49. package/src/bun/BunImportTrackerPlugin.ts +97 -0
  50. package/src/bun/BunTailwindPlugin.test.ts +335 -0
  51. package/src/bun/BunTailwindPlugin.ts +322 -0
  52. package/src/bun/BunVirtualFilesPlugin.ts +59 -0
  53. package/src/bun/index.ts +4 -0
  54. package/src/client/Overlay.ts +34 -0
  55. package/src/client/ScrollState.ts +120 -0
  56. package/src/client/index.ts +101 -0
  57. package/src/index.ts +24 -0
  58. package/src/jsx-datastar.d.ts +63 -0
  59. package/src/jsx-runtime.ts +23 -0
  60. package/src/jsx.d.ts +4402 -0
  61. package/src/testing.ts +55 -0
  62. package/src/x/cloudflare/CloudflareTunnel.ts +110 -0
  63. package/src/x/cloudflare/index.ts +1 -0
  64. package/src/x/datastar/Datastar.test.ts +267 -0
  65. package/src/x/datastar/Datastar.ts +68 -0
  66. package/src/x/datastar/index.ts +4 -0
  67. package/src/x/datastar/jsx-datastar.d.ts +63 -0
@@ -0,0 +1,251 @@
1
+ import type { PlatformError } from "@effect/platform/Error"
2
+ import * as FileSystem from "@effect/platform/FileSystem"
3
+ import * as Effect from "effect/Effect"
4
+ import * as NPath from "node:path"
5
+ import * as FileRouter from "./FileRouter.ts"
6
+ import * as Route from "./Route.ts"
7
+
8
+ export function validateRouteModule(
9
+ module: unknown,
10
+ ): boolean {
11
+ if (typeof module !== "object" || module === null) {
12
+ return false
13
+ }
14
+ if (!("default" in module)) {
15
+ return false
16
+ }
17
+ return Route.isRouteSet(module.default)
18
+ }
19
+
20
+ /**
21
+ * Validates all route modules in the given route handles.
22
+ */
23
+ export function validateRouteModules(
24
+ routesPath: string,
25
+ handles: FileRouter.OrderedRouteHandles,
26
+ ): Effect.Effect<void, never, never> {
27
+ return Effect.gen(function*() {
28
+ const routeHandles = handles.filter(h => h.handle === "route")
29
+
30
+ for (const handle of routeHandles) {
31
+ const routeModulePath = NPath.resolve(routesPath, handle.modulePath)
32
+ yield* Effect
33
+ .tryPromise({
34
+ try: async () => import(routeModulePath),
35
+ catch: (error) =>
36
+ Effect.logWarning(
37
+ `Failed to validate route module ${routeModulePath}: ${error}`,
38
+ ),
39
+ })
40
+ .pipe(
41
+ Effect.catchAll((logEffect) => logEffect),
42
+ Effect.tap((module) => {
43
+ if (!validateRouteModule(module)) {
44
+ return Effect.logWarning(
45
+ `Route module ${routeModulePath} should export default Route`,
46
+ )
47
+ }
48
+ return Effect.void
49
+ }),
50
+ )
51
+ }
52
+ })
53
+ }
54
+
55
+ /**
56
+ * Converts a segment to RouteModuleSegment format
57
+ */
58
+ function segmentToModuleSegment(segment: FileRouter.Segment): string | null {
59
+ if ("literal" in segment) {
60
+ return `{ literal: "${segment.literal}" }`
61
+ }
62
+ if ("group" in segment) {
63
+ return `{ group: "${segment.group}" }`
64
+ }
65
+ if ("param" in segment) {
66
+ return segment.optional
67
+ ? `{ param: "${segment.param}", optional: true }`
68
+ : `{ param: "${segment.param}" }`
69
+ }
70
+ if ("rest" in segment) {
71
+ return segment.optional
72
+ ? `{ rest: "${segment.rest}", optional: true }`
73
+ : `{ rest: "${segment.rest}" }`
74
+ }
75
+ return null
76
+ }
77
+
78
+ export function generateCode(
79
+ handles: FileRouter.OrderedRouteHandles,
80
+ ): string {
81
+ const routerModuleId = "effect-start"
82
+
83
+ // Group routes by path to find layers
84
+ const routesByPath = new Map<string, {
85
+ route?: FileRouter.RouteHandle
86
+ layers: FileRouter.RouteHandle[]
87
+ }>()
88
+
89
+ for (const handle of handles) {
90
+ const existing = routesByPath.get(handle.routePath) || { layers: [] }
91
+ if (handle.handle === "route") {
92
+ existing.route = handle
93
+ } else if (handle.handle === "layer") {
94
+ existing.layers.push(handle)
95
+ }
96
+ routesByPath.set(handle.routePath, existing)
97
+ }
98
+
99
+ // Generate module definitions
100
+ const modules: string[] = []
101
+
102
+ // Helper to check if layer's path is an ancestor of route's path
103
+ const layerMatchesRoute = (
104
+ layer: FileRouter.RouteHandle,
105
+ route: FileRouter.RouteHandle,
106
+ ): boolean => {
107
+ // Exclude handle segment (last segment) from comparison
108
+ const layerLength = layer.segments.length - 1
109
+ const routeLength = route.segments.length - 1
110
+
111
+ // Layer's segments must be a prefix of route's segments
112
+ if (layerLength > routeLength) {
113
+ return false
114
+ }
115
+
116
+ for (let i = 0; i < layerLength; i++) {
117
+ if (!FileRouter.isSegmentEqual(layer.segments[i], route.segments[i])) {
118
+ return false
119
+ }
120
+ }
121
+
122
+ return true
123
+ }
124
+
125
+ // Find layers for each route by walking up the path hierarchy
126
+ for (const [path, { route }] of routesByPath) {
127
+ if (!route) continue // Skip paths that only have layers
128
+
129
+ // Collect all parent layers that match the route's groups
130
+ const allLayers: FileRouter.RouteHandle[] = []
131
+ let currentPath = path
132
+
133
+ while (true) {
134
+ const pathData = routesByPath.get(currentPath)
135
+ if (pathData?.layers) {
136
+ const matchingLayers = pathData.layers.filter(layer =>
137
+ layerMatchesRoute(layer, route)
138
+ )
139
+ allLayers.unshift(...matchingLayers)
140
+ }
141
+
142
+ if (currentPath === "/") break
143
+
144
+ // Move to parent path
145
+ const parentPath = currentPath.substring(0, currentPath.lastIndexOf("/"))
146
+ currentPath = parentPath || "/"
147
+ }
148
+
149
+ // Generate segments array
150
+ const pathSegments = route.segments.filter(seg => !("handle" in seg))
151
+ const segmentsCode = pathSegments
152
+ .map(segmentToModuleSegment)
153
+ .filter(Boolean)
154
+ .join(",\n ") + (pathSegments.length > 0 ? "," : "")
155
+
156
+ const segmentsArray = segmentsCode
157
+ ? `[\n ${segmentsCode}\n ]`
158
+ : "[]"
159
+
160
+ // Generate layers array
161
+ const layersCode = allLayers.length > 0
162
+ ? `\n layers: [\n ${
163
+ allLayers.map(layer => `() => import("./${layer.modulePath}")`).join(
164
+ ",\n ",
165
+ )
166
+ },\n ],`
167
+ : ""
168
+
169
+ const moduleCode = ` {
170
+ path: "${path}",
171
+ segments: ${segmentsArray},
172
+ load: () => import("./${route.modulePath}"),${layersCode}
173
+ },`
174
+
175
+ modules.push(moduleCode)
176
+ }
177
+
178
+ const header = `/**
179
+ * Auto-generated by effect-start.
180
+ */`
181
+
182
+ const modulesArray = modules.length > 0
183
+ ? `[\n${modules.join("\n")}\n]`
184
+ : "[]"
185
+
186
+ return `${header}
187
+
188
+ import type { Router } from "${routerModuleId}"
189
+
190
+ export const modules = ${modulesArray} as const
191
+ `
192
+ }
193
+
194
+ /**
195
+ * Updates the manifest file only if the generated content differs from the existing file.
196
+ * This prevents infinite loops when watching for file changes.
197
+ */
198
+ export function update(
199
+ routesPath: string,
200
+ manifestPath = "_manifest.ts",
201
+ ): Effect.Effect<void, PlatformError, FileSystem.FileSystem> {
202
+ return Effect.gen(function*() {
203
+ manifestPath = NPath.resolve(routesPath, manifestPath)
204
+
205
+ const fs = yield* FileSystem.FileSystem
206
+ const files = yield* fs.readDirectory(routesPath, { recursive: true })
207
+ const handles = FileRouter.getRouteHandlesFromPaths(files)
208
+
209
+ // Validate route modules
210
+ yield* validateRouteModules(routesPath, handles)
211
+
212
+ const newCode = generateCode(handles)
213
+
214
+ // Check if file exists and content differs
215
+ const existingCode = yield* fs
216
+ .readFileString(manifestPath)
217
+ .pipe(Effect.catchAll(() => Effect.succeed(null)))
218
+
219
+ if (existingCode !== newCode) {
220
+ yield* Effect.logDebug(`Updating file routes manifest: ${manifestPath}`)
221
+ yield* fs.writeFileString(manifestPath, newCode)
222
+ } else {
223
+ yield* Effect.logDebug(`File routes manifest unchanged: ${manifestPath}`)
224
+ }
225
+ })
226
+ }
227
+
228
+ export function dump(
229
+ routesPath: string,
230
+ manifestPath = "_manifest.ts",
231
+ ): Effect.Effect<void, PlatformError, FileSystem.FileSystem> {
232
+ return Effect.gen(function*() {
233
+ manifestPath = NPath.resolve(routesPath, manifestPath)
234
+
235
+ const fs = yield* FileSystem.FileSystem
236
+ const files = yield* fs.readDirectory(routesPath, { recursive: true })
237
+ const handles = FileRouter.getRouteHandlesFromPaths(files)
238
+
239
+ // Validate route modules
240
+ yield* validateRouteModules(routesPath, handles)
241
+
242
+ const code = generateCode(handles)
243
+
244
+ yield* Effect.logDebug(`Generating file routes manifest: ${manifestPath}`)
245
+
246
+ yield* fs.writeFileString(
247
+ manifestPath,
248
+ code,
249
+ )
250
+ })
251
+ }
@@ -0,0 +1,64 @@
1
+ import * as t from "bun:test"
2
+ import { MemoryFileSystem } from "effect-memfs"
3
+ import * as Effect from "effect/Effect"
4
+ import * as FileRouter from "./FileRouter.ts"
5
+ import { effectFn } from "./testing.ts"
6
+
7
+ const Files = {
8
+ "/routes/about/layer.tsx": "",
9
+ "/routes/about/route.tsx": "",
10
+ "/routes/users/route.tsx": "",
11
+ "/routes/users/layer.tsx": "",
12
+ "/routes/users/[userId]/route.tsx": "",
13
+ "/routes/layer.tsx": "",
14
+ }
15
+
16
+ const effect = effectFn()
17
+
18
+ t.it("walks routes", () =>
19
+ effect(function*() {
20
+ const files = yield* FileRouter.walkRoutesDirectory("/routes").pipe(
21
+ Effect.provide(MemoryFileSystem.layerWith(Files)),
22
+ )
23
+
24
+ t
25
+ .expect(
26
+ files.map(v => v.modulePath),
27
+ )
28
+ .toEqual([
29
+ "layer.tsx",
30
+ "about/layer.tsx",
31
+ "about/route.tsx",
32
+ "users/layer.tsx",
33
+ "users/route.tsx",
34
+ "users/[userId]/route.tsx",
35
+ ])
36
+ }))
37
+
38
+ t.it("walks routes with rest", () =>
39
+ effect(function*() {
40
+ const files = yield* FileRouter.walkRoutesDirectory("/routes").pipe(
41
+ Effect.provide(
42
+ MemoryFileSystem.layerWith({
43
+ ...Files,
44
+ "/routes/[[...rest]]/route.tsx": "",
45
+ "/routes/users/[...path]/route.tsx": "",
46
+ }),
47
+ ),
48
+ )
49
+
50
+ t
51
+ .expect(
52
+ files.map(v => v.modulePath),
53
+ )
54
+ .toEqual([
55
+ "layer.tsx",
56
+ "about/layer.tsx",
57
+ "about/route.tsx",
58
+ "users/layer.tsx",
59
+ "users/route.tsx",
60
+ "users/[userId]/route.tsx",
61
+ "users/[...path]/route.tsx",
62
+ "[[...rest]]/route.tsx",
63
+ ])
64
+ }))
@@ -0,0 +1,132 @@
1
+ import * as t from "bun:test"
2
+ import * as FileRouter from "./FileRouter.ts"
3
+
4
+ t.it("empty path as null", () => {
5
+ t.expect(FileRouter.segmentPath("")).toEqual([])
6
+ t.expect(FileRouter.segmentPath("/")).toEqual([])
7
+ })
8
+
9
+ t.it("literal segments", () => {
10
+ t.expect(FileRouter.segmentPath("users")).toEqual([{ literal: "users" }])
11
+ t.expect(FileRouter.segmentPath("/users")).toEqual([{ literal: "users" }])
12
+ t.expect(FileRouter.segmentPath("users/")).toEqual([{ literal: "users" }])
13
+ t.expect(FileRouter.segmentPath("/users/create")).toEqual([
14
+ { literal: "users" },
15
+ { literal: "create" },
16
+ ])
17
+ t.expect(() => FileRouter.segmentPath("path with spaces")).toThrow()
18
+ })
19
+
20
+ t.it("dynamic parameters", () => {
21
+ t.expect(FileRouter.segmentPath("[userId]")).toEqual([{ param: "userId" }])
22
+ t.expect(FileRouter.segmentPath("/users/[userId]")).toEqual([
23
+ { literal: "users" },
24
+ { param: "userId" },
25
+ ])
26
+ t
27
+ .expect(FileRouter.segmentPath("/posts/[postId]/comments/[commentId]"))
28
+ .toEqual([
29
+ { literal: "posts" },
30
+ { param: "postId" },
31
+ { literal: "comments" },
32
+ { param: "commentId" },
33
+ ])
34
+ })
35
+
36
+ t.it("rest parameters", () => {
37
+ t.expect(FileRouter.segmentPath("[[...rest]]")).toEqual([
38
+ { rest: "rest", optional: true },
39
+ ])
40
+ t.expect(FileRouter.segmentPath("[...rest]")).toEqual([{ rest: "rest" }])
41
+ t.expect(FileRouter.segmentPath("/docs/[[...slug]]")).toEqual([
42
+ { literal: "docs" },
43
+ { rest: "slug", optional: true },
44
+ ])
45
+ t.expect(FileRouter.segmentPath("/api/[...path]")).toEqual([
46
+ { literal: "api" },
47
+ { rest: "path" },
48
+ ])
49
+ })
50
+
51
+ t.it("groups", () => {
52
+ t.expect(FileRouter.segmentPath("(admin)")).toEqual([{ group: "admin" }])
53
+ t.expect(FileRouter.segmentPath("/(admin)/users")).toEqual([
54
+ { group: "admin" },
55
+ { literal: "users" },
56
+ ])
57
+ t.expect(FileRouter.segmentPath("(auth)/login/(step1)")).toEqual([
58
+ { group: "auth" },
59
+ { literal: "login" },
60
+ { group: "step1" },
61
+ ])
62
+ })
63
+
64
+ t.it("route handles", () => {
65
+ t.expect(FileRouter.segmentPath("route.ts")).toEqual([{ handle: "route" }])
66
+ t.expect(FileRouter.segmentPath("/api/route.js")).toEqual([
67
+ { literal: "api" },
68
+ { handle: "route" },
69
+ ])
70
+ t.expect(FileRouter.segmentPath("route.tsx")).toEqual([{ handle: "route" }])
71
+ })
72
+
73
+ t.it("layer handles", () => {
74
+ t.expect(FileRouter.segmentPath("layer.tsx")).toEqual([{ handle: "layer" }])
75
+ t.expect(FileRouter.segmentPath("layer.jsx")).toEqual([{ handle: "layer" }])
76
+ t.expect(FileRouter.segmentPath("layer.js")).toEqual([{ handle: "layer" }])
77
+ t.expect(FileRouter.segmentPath("/blog/layer.jsx")).toEqual([
78
+ { literal: "blog" },
79
+ { handle: "layer" },
80
+ ])
81
+ })
82
+
83
+ t.it("complex combinations", () => {
84
+ t.expect(FileRouter.segmentPath("/users/[userId]/posts/route.tsx")).toEqual([
85
+ { literal: "users" },
86
+ { param: "userId" },
87
+ { literal: "posts" },
88
+ { handle: "route" },
89
+ ])
90
+ t.expect(FileRouter.segmentPath("/api/v1/[[...path]]/route.ts")).toEqual([
91
+ { literal: "api" },
92
+ { literal: "v1" },
93
+ { rest: "path", optional: true },
94
+ { handle: "route" },
95
+ ])
96
+ t.expect(FileRouter.segmentPath("(admin)/users/route.tsx")).toEqual([
97
+ { group: "admin" },
98
+ { literal: "users" },
99
+ { handle: "route" },
100
+ ])
101
+ })
102
+
103
+ t.it("invalid paths", () => {
104
+ t.expect(() => FileRouter.segmentPath("$...")).toThrow()
105
+ t.expect(() => FileRouter.segmentPath("invalid%char")).toThrow()
106
+ })
107
+
108
+ t.it("param and rest types", () => {
109
+ t.expect(FileRouter.segmentPath("[a]")).toEqual([{ param: "a" }])
110
+ t.expect(FileRouter.segmentPath("[...rest]")).toEqual([{ rest: "rest" }])
111
+ t.expect(FileRouter.segmentPath("[[...rest]]")).toEqual([
112
+ { rest: "rest", optional: true },
113
+ ])
114
+ })
115
+
116
+ t.it("extractRoute - users/route.ts", () => {
117
+ t.expect(FileRouter.segmentPath("users/route.ts")).toEqual([
118
+ { literal: "users" },
119
+ { handle: "route" },
120
+ ])
121
+ })
122
+
123
+ t.it("segments with extensions", () => {
124
+ t.expect(FileRouter.segmentPath("events.json/route.ts")).toEqual([
125
+ { literal: "events.json" },
126
+ { handle: "route" },
127
+ ])
128
+ t.expect(FileRouter.segmentPath("config.yaml.backup/route.ts")).toEqual([
129
+ { literal: "config.yaml.backup" },
130
+ { handle: "route" },
131
+ ])
132
+ })
@@ -0,0 +1,126 @@
1
+ import * as t from "bun:test"
2
+ import * as FileRouter from "./FileRouter.ts"
3
+
4
+ t.it("tree with root only", () => {
5
+ const handles = [
6
+ "route.tsx",
7
+ "layer.tsx",
8
+ ]
9
+ .map(FileRouter.parseRoute)
10
+ const tree = FileRouter.treeFromRouteHandles(handles)
11
+
12
+ t.expect(tree).toEqual({
13
+ path: "/",
14
+ handles: [
15
+ t.expect.objectContaining({
16
+ handle: "route",
17
+ }),
18
+ t.expect.objectContaining({
19
+ handle: "layer",
20
+ }),
21
+ ],
22
+ })
23
+ })
24
+
25
+ t.it("tree without root", () => {
26
+ const handles = []
27
+ .map(FileRouter.parseRoute)
28
+ const tree = FileRouter.treeFromRouteHandles(handles)
29
+
30
+ t.expect(tree).toEqual({
31
+ path: "/",
32
+ handles: [],
33
+ })
34
+ })
35
+
36
+ t.it("deep tree", () => {
37
+ const handles = [
38
+ "users/route.tsx",
39
+ "users/layer.tsx",
40
+ "users/[userId]/route.tsx",
41
+ "layer.tsx",
42
+ ]
43
+ .map(FileRouter.parseRoute)
44
+ const tree = FileRouter.treeFromRouteHandles(handles)
45
+
46
+ t.expect(tree).toEqual({
47
+ path: "/",
48
+ handles: [
49
+ t.expect.objectContaining({
50
+ handle: "layer",
51
+ }),
52
+ ],
53
+ children: [
54
+ {
55
+ path: "/users",
56
+ handles: [
57
+ t.expect.objectContaining({
58
+ handle: "route",
59
+ }),
60
+ t.expect.objectContaining({
61
+ handle: "layer",
62
+ }),
63
+ ],
64
+ children: [
65
+ {
66
+ path: "/[userId]",
67
+ handles: [
68
+ t.expect.objectContaining({
69
+ handle: "route",
70
+ }),
71
+ ],
72
+ },
73
+ ],
74
+ },
75
+ ],
76
+ })
77
+ })
78
+
79
+ t.it("throws on overlapping routes from groups", () => {
80
+ t
81
+ .expect(() => {
82
+ const handles = [
83
+ "(admin)/users/route.tsx",
84
+ "users/route.tsx",
85
+ ]
86
+ .map(FileRouter.parseRoute)
87
+
88
+ FileRouter.getRouteHandlesFromPaths(
89
+ handles.map(h => h.modulePath),
90
+ )
91
+ })
92
+ .toThrow("Conflicting routes detected at path /users")
93
+ })
94
+
95
+ t.it("throws on overlapping routes with same path", () => {
96
+ t
97
+ .expect(() => {
98
+ const handles = [
99
+ "about/route.tsx",
100
+ "about/route.ts",
101
+ ]
102
+ .map(FileRouter.parseRoute)
103
+
104
+ FileRouter.getRouteHandlesFromPaths(
105
+ handles.map(h => h.modulePath),
106
+ )
107
+ })
108
+ .toThrow("Conflicting routes detected at path /about")
109
+ })
110
+
111
+ t.it("allows route and layer at same path", () => {
112
+ t
113
+ .expect(() => {
114
+ const handles = [
115
+ "users/route.tsx",
116
+ "users/layer.tsx",
117
+ ]
118
+ .map(FileRouter.parseRoute)
119
+
120
+ FileRouter.getRouteHandlesFromPaths(
121
+ handles.map(h => h.modulePath),
122
+ )
123
+ })
124
+ .not
125
+ .toThrow()
126
+ })
@@ -0,0 +1,102 @@
1
+ import * as Error from "@effect/platform/Error"
2
+ import * as Effect from "effect/Effect"
3
+ import * as Function from "effect/Function"
4
+ import * as Stream from "effect/Stream"
5
+ import type { WatchOptions } from "node:fs"
6
+ import * as NFSP from "node:fs/promises"
7
+ import * as NPath from "node:path"
8
+
9
+ const SOURCE_FILENAME = /\.(tsx?|jsx?|html?|css|json)$/
10
+
11
+ export type WatchEvent = {
12
+ eventType: "rename" | "change"
13
+ filename: string
14
+ path: string
15
+ }
16
+
17
+ /**
18
+ * Filter for source files based on file extension.
19
+ */
20
+ export const filterSourceFiles = (event: WatchEvent): boolean => {
21
+ return SOURCE_FILENAME.test(event.path)
22
+ }
23
+
24
+ /**
25
+ * Filter for directories (paths ending with /).
26
+ */
27
+ export const filterDirectory = (event: WatchEvent): boolean => {
28
+ return event.path.endsWith("/")
29
+ }
30
+
31
+ /**
32
+ * `@effect/platform` doesn't support recursive file watching.
33
+ * This function implements that [2025-05-19]
34
+ * Additionally, the filename is resolved to an absolute path.
35
+ * If the path is a directory, it appends / to the path.
36
+ */
37
+ export const watchSource = (
38
+ opts?: WatchOptions & {
39
+ path?: string
40
+ filter?: (event: WatchEvent) => boolean
41
+ },
42
+ ): Stream.Stream<WatchEvent, Error.SystemError> => {
43
+ const baseDir = opts?.path ?? process.cwd()
44
+ const customFilter = opts?.filter
45
+
46
+ let stream: Stream.Stream<NFSP.FileChangeInfo<string>, Error.SystemError>
47
+ try {
48
+ stream = Stream.fromAsyncIterable(
49
+ NFSP.watch(baseDir, {
50
+ persistent: opts?.persistent ?? false,
51
+ recursive: opts?.recursive ?? true,
52
+ encoding: opts?.encoding,
53
+ signal: opts?.signal,
54
+ }),
55
+ error => handleWatchError(error, baseDir),
56
+ )
57
+ } catch (e) {
58
+ const err = handleWatchError(e, baseDir)
59
+
60
+ stream = Stream.fail(err)
61
+ }
62
+
63
+ const changes = Function.pipe(
64
+ stream,
65
+ Stream.mapEffect(e =>
66
+ Effect.promise(() => {
67
+ const resolvedPath = NPath.resolve(baseDir, e.filename!)
68
+ return NFSP
69
+ .stat(resolvedPath)
70
+ .then(stat => ({
71
+ eventType: e.eventType as "rename" | "change",
72
+ filename: e.filename!,
73
+ path: stat.isDirectory() ? `${resolvedPath}/` : resolvedPath,
74
+ }))
75
+ .catch(() => ({
76
+ eventType: e.eventType as "rename" | "change",
77
+ filename: e.filename!,
78
+ path: resolvedPath,
79
+ }))
80
+ })
81
+ ),
82
+ customFilter ? Stream.filter(customFilter) : Function.identity,
83
+ Stream.rechunk(1),
84
+ Stream.throttle({
85
+ units: 1,
86
+ cost: () => 1,
87
+ duration: "400 millis",
88
+ strategy: "enforce",
89
+ }),
90
+ )
91
+
92
+ return changes
93
+ }
94
+
95
+ const handleWatchError = (error: any, path: string) =>
96
+ new Error.SystemError({
97
+ module: "FileSystem",
98
+ reason: "Unknown",
99
+ method: "watch",
100
+ pathOrDescriptor: path,
101
+ cause: error,
102
+ })