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
@@ -0,0 +1,59 @@
1
+ import * as RouterPattern from "./RouterPattern.ts"
2
+
3
+ export type GroupSegment<
4
+ Name extends string = string,
5
+ > = {
6
+ _tag: "GroupSegment"
7
+ name: Name
8
+ }
9
+
10
+ export type Segment =
11
+ | RouterPattern.Segment
12
+ | GroupSegment
13
+
14
+ export function parse(pattern: string): Segment[] {
15
+ const trimmedPath = pattern.replace(/(^\/)|(\/$)/g, "")
16
+
17
+ if (trimmedPath === "") {
18
+ return []
19
+ }
20
+
21
+ const segmentStrings = trimmedPath
22
+ .split("/")
23
+ .filter(s => s !== "")
24
+
25
+ if (segmentStrings.length === 0) {
26
+ return []
27
+ }
28
+
29
+ const segments: (Segment | null)[] = segmentStrings.map(
30
+ (s): Segment | null => {
31
+ // (group) - Groups (FileRouter-specific)
32
+ const groupMatch = s.match(/^\((\w+)\)$/)
33
+ if (groupMatch) {
34
+ return { _tag: "GroupSegment", name: groupMatch[1] }
35
+ }
36
+
37
+ // Delegate to RouterPattern for all other segment types
38
+ return RouterPattern.parseSegment(s)
39
+ },
40
+ )
41
+
42
+ if (segments.some((seg) => seg === null)) {
43
+ throw new Error(
44
+ `Invalid path segment in "${pattern}": contains invalid characters or format`,
45
+ )
46
+ }
47
+
48
+ return segments as Segment[]
49
+ }
50
+
51
+ export function formatSegment(seg: Segment): string {
52
+ if (seg._tag === "GroupSegment") return `(${seg.name})`
53
+ return RouterPattern.formatSegment(seg)
54
+ }
55
+
56
+ export function format(segments: Segment[]): `/${string}` {
57
+ const joined = segments.map(formatSegment).join("/")
58
+ return (joined ? `/${joined}` : "/") as `/${string}`
59
+ }
@@ -1,132 +1,93 @@
1
1
  import * as t from "bun:test"
2
2
  import * as FileRouter from "./FileRouter.ts"
3
3
 
4
- t.it("empty path as null", () => {
5
- t.expect(FileRouter.segmentPath("")).toEqual([])
6
- t.expect(FileRouter.segmentPath("/")).toEqual([])
4
+ t.it("empty path", () => {
5
+ t.expect(FileRouter.parse("")).toEqual([])
6
+ t.expect(FileRouter.parse("/")).toEqual([])
7
7
  })
8
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" },
9
+ t.it("groups", () => {
10
+ t.expect(FileRouter.parse("(admin)")).toEqual([
11
+ { _tag: "GroupSegment", name: "admin" },
16
12
  ])
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" },
13
+ t.expect(FileRouter.parse("/(admin)/users")).toEqual([
14
+ { _tag: "GroupSegment", name: "admin" },
15
+ { _tag: "LiteralSegment", value: "users" },
16
+ ])
17
+ t.expect(FileRouter.parse("(auth)/login/(step1)")).toEqual([
18
+ { _tag: "GroupSegment", name: "auth" },
19
+ { _tag: "LiteralSegment", value: "login" },
20
+ { _tag: "GroupSegment", name: "step1" },
25
21
  ])
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
22
  })
35
23
 
36
- t.it("rest parameters", () => {
37
- t.expect(FileRouter.segmentPath("[[...rest]]")).toEqual([
38
- { rest: "rest", optional: true },
24
+ t.it("handle files parsed as Literal", () => {
25
+ t.expect(FileRouter.parse("route.ts")).toEqual([
26
+ { _tag: "LiteralSegment", value: "route.ts" },
39
27
  ])
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 },
28
+ t.expect(FileRouter.parse("/api/route.js")).toEqual([
29
+ { _tag: "LiteralSegment", value: "api" },
30
+ { _tag: "LiteralSegment", value: "route.js" },
44
31
  ])
45
- t.expect(FileRouter.segmentPath("/api/[...path]")).toEqual([
46
- { literal: "api" },
47
- { rest: "path" },
32
+ t.expect(FileRouter.parse("layer.tsx")).toEqual([
33
+ { _tag: "LiteralSegment", value: "layer.tsx" },
48
34
  ])
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" },
35
+ t.expect(FileRouter.parse("/blog/layer.jsx")).toEqual([
36
+ { _tag: "LiteralSegment", value: "blog" },
37
+ { _tag: "LiteralSegment", value: "layer.jsx" },
61
38
  ])
62
39
  })
63
40
 
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" },
41
+ t.it("parseRoute extracts handle from Literal", () => {
42
+ const route = FileRouter.parseRoute("users/route.tsx")
43
+ t.expect(route.handle).toBe("route")
44
+ t.expect(route.routePath).toBe("/users")
45
+ t.expect(route.segments).toEqual([
46
+ { _tag: "LiteralSegment", value: "users" },
69
47
  ])
70
- t.expect(FileRouter.segmentPath("route.tsx")).toEqual([{ handle: "route" }])
71
- })
72
48
 
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
- ])
49
+ const layer = FileRouter.parseRoute("api/layer.ts")
50
+ t.expect(layer.handle).toBe("layer")
51
+ t.expect(layer.routePath).toBe("/api")
81
52
  })
82
53
 
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" },
54
+ t.it("parseRoute with groups", () => {
55
+ const route = FileRouter.parseRoute("(admin)/users/route.tsx")
56
+ t.expect(route.handle).toBe("route")
57
+ t.expect(route.routePath).toBe("/users")
58
+ t.expect(route.segments).toEqual([
59
+ { _tag: "GroupSegment", name: "admin" },
60
+ { _tag: "LiteralSegment", value: "users" },
100
61
  ])
101
62
  })
102
63
 
103
- t.it("invalid paths", () => {
104
- t.expect(() => FileRouter.segmentPath("$...")).toThrow()
105
- t.expect(() => FileRouter.segmentPath("invalid%char")).toThrow()
106
- })
64
+ t.it("parseRoute with params and rest", () => {
65
+ const route = FileRouter.parseRoute("users/[userId]/posts/route.tsx")
66
+ t.expect(route.handle).toBe("route")
67
+ t.expect(route.routePath).toBe("/users/[userId]/posts")
68
+ t.expect(route.segments).toEqual([
69
+ { _tag: "LiteralSegment", value: "users" },
70
+ { _tag: "ParamSegment", name: "userId" },
71
+ { _tag: "LiteralSegment", value: "posts" },
72
+ ])
107
73
 
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 },
74
+ const rest = FileRouter.parseRoute("api/[[...path]]/route.ts")
75
+ t.expect(rest.handle).toBe("route")
76
+ t.expect(rest.segments).toEqual([
77
+ { _tag: "LiteralSegment", value: "api" },
78
+ { _tag: "RestSegment", name: "path", optional: true },
113
79
  ])
114
80
  })
115
81
 
116
- t.it("extractRoute - users/route.ts", () => {
117
- t.expect(FileRouter.segmentPath("users/route.ts")).toEqual([
118
- { literal: "users" },
119
- { handle: "route" },
120
- ])
82
+ t.it("invalid paths", () => {
83
+ t.expect(() => FileRouter.parse("$...")).toThrow()
84
+ t.expect(() => FileRouter.parse("invalid%char")).toThrow()
85
+ t.expect(() => FileRouter.parse("path with spaces")).toThrow()
121
86
  })
122
87
 
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" },
88
+ t.it("segments with extensions (literal with dots)", () => {
89
+ t.expect(FileRouter.parse("events.json/route.ts")).toEqual([
90
+ { _tag: "LiteralSegment", value: "events.json" },
91
+ { _tag: "LiteralSegment", value: "route.ts" },
131
92
  ])
132
93
  })
@@ -0,0 +1,226 @@
1
+ import * as FileSystem from "@effect/platform/FileSystem"
2
+ import * as t from "bun:test"
3
+ import { MemoryFileSystem } from "effect-memfs"
4
+ import * as Chunk from "effect/Chunk"
5
+ import * as Effect from "effect/Effect"
6
+ import * as Fiber from "effect/Fiber"
7
+ import * as Function from "effect/Function"
8
+ import * as Stream from "effect/Stream"
9
+ import * as FileSystemExtra from "./FileSystemExtra.ts"
10
+
11
+ t.describe(`${FileSystemExtra.watchSource.name}`, () => {
12
+ t.it("emits events for file creation", () =>
13
+ Effect
14
+ .gen(function*() {
15
+ const fs = yield* FileSystem.FileSystem
16
+ const watchDir = "/watch-test"
17
+
18
+ const fiber = yield* Function.pipe(
19
+ FileSystemExtra.watchSource({ path: watchDir }),
20
+ Stream.take(1),
21
+ Stream.runCollect,
22
+ Effect.fork,
23
+ )
24
+
25
+ yield* Effect.sleep(1)
26
+
27
+ yield* fs.writeFileString(`${watchDir}/test.ts`, "const x = 1")
28
+
29
+ const events = yield* Fiber.join(fiber)
30
+
31
+ t.expect(Chunk.size(events)).toBeGreaterThan(0)
32
+ const first = Chunk.unsafeGet(events, 0)
33
+ t.expect(first.path).toContain("test.ts")
34
+ t.expect(["rename", "change"]).toContain(first.eventType)
35
+ t.expect(first.filename).toBe("test.ts")
36
+ })
37
+ .pipe(
38
+ Effect.scoped,
39
+ Effect.provide(
40
+ MemoryFileSystem.layerWith({ "/watch-test/.gitkeep": "" }),
41
+ ),
42
+ Effect.runPromise,
43
+ ))
44
+
45
+ t.it(
46
+ "emits change event for file modification",
47
+ () =>
48
+ Effect
49
+ .gen(function*() {
50
+ const fs = yield* FileSystem.FileSystem
51
+ const watchDir = "/watch-mod"
52
+ const filePath = `${watchDir}/file.ts`
53
+
54
+ const fiber = yield* Function.pipe(
55
+ FileSystemExtra.watchSource({ path: watchDir }),
56
+ Stream.take(1),
57
+ Stream.runCollect,
58
+ Effect.fork,
59
+ )
60
+
61
+ yield* Effect.sleep(1)
62
+ yield* fs.writeFileString(filePath, "modified")
63
+
64
+ const events = yield* Fiber.join(fiber)
65
+
66
+ t.expect(Chunk.size(events)).toBeGreaterThan(0)
67
+ t.expect(Chunk.unsafeGet(events, 0).eventType).toBe("change")
68
+ })
69
+ .pipe(
70
+ Effect.scoped,
71
+ Effect.provide(
72
+ MemoryFileSystem.layerWith({ "/watch-mod/file.ts": "initial" }),
73
+ ),
74
+ Effect.runPromise,
75
+ ),
76
+ )
77
+
78
+ t.it("applies custom filter", () =>
79
+ Effect
80
+ .gen(function*() {
81
+ const fs = yield* FileSystem.FileSystem
82
+ const watchDir = "/watch-filter"
83
+
84
+ const fiber = yield* Function.pipe(
85
+ FileSystemExtra.watchSource({
86
+ path: watchDir,
87
+ filter: FileSystemExtra.filterSourceFiles,
88
+ }),
89
+ Stream.take(1),
90
+ Stream.runCollect,
91
+ Effect.fork,
92
+ )
93
+
94
+ yield* Effect.sleep(1)
95
+ yield* fs.writeFileString(`${watchDir}/ignored.txt`, "ignored")
96
+ yield* Effect.sleep(1)
97
+ yield* fs.writeFileString(`${watchDir}/included.ts`, "included")
98
+
99
+ const events = yield* Fiber.join(fiber)
100
+
101
+ t.expect(Chunk.size(events)).toBe(1)
102
+ t.expect(Chunk.unsafeGet(events, 0).path).toContain("included.ts")
103
+ })
104
+ .pipe(
105
+ Effect.scoped,
106
+ Effect.provide(
107
+ MemoryFileSystem.layerWith({ "/watch-filter/.gitkeep": "" }),
108
+ ),
109
+ Effect.runPromise,
110
+ ))
111
+ })
112
+
113
+ t.describe(`${FileSystemExtra.filterSourceFiles.name}`, () => {
114
+ t.it("matches source file extensions", () => {
115
+ t
116
+ .expect(
117
+ FileSystemExtra.filterSourceFiles({
118
+ eventType: "change",
119
+ filename: "x",
120
+ path: "/a/b.ts",
121
+ }),
122
+ )
123
+ .toBe(true)
124
+ t
125
+ .expect(
126
+ FileSystemExtra.filterSourceFiles({
127
+ eventType: "change",
128
+ filename: "x",
129
+ path: "/a/b.tsx",
130
+ }),
131
+ )
132
+ .toBe(true)
133
+ t
134
+ .expect(
135
+ FileSystemExtra.filterSourceFiles({
136
+ eventType: "change",
137
+ filename: "x",
138
+ path: "/a/b.js",
139
+ }),
140
+ )
141
+ .toBe(true)
142
+ t
143
+ .expect(
144
+ FileSystemExtra.filterSourceFiles({
145
+ eventType: "change",
146
+ filename: "x",
147
+ path: "/a/b.jsx",
148
+ }),
149
+ )
150
+ .toBe(true)
151
+ t
152
+ .expect(
153
+ FileSystemExtra.filterSourceFiles({
154
+ eventType: "change",
155
+ filename: "x",
156
+ path: "/a/b.json",
157
+ }),
158
+ )
159
+ .toBe(true)
160
+ t
161
+ .expect(
162
+ FileSystemExtra.filterSourceFiles({
163
+ eventType: "change",
164
+ filename: "x",
165
+ path: "/a/b.css",
166
+ }),
167
+ )
168
+ .toBe(true)
169
+ t
170
+ .expect(
171
+ FileSystemExtra.filterSourceFiles({
172
+ eventType: "change",
173
+ filename: "x",
174
+ path: "/a/b.html",
175
+ }),
176
+ )
177
+ .toBe(true)
178
+ })
179
+
180
+ t.it("rejects non-source files", () => {
181
+ t
182
+ .expect(
183
+ FileSystemExtra.filterSourceFiles({
184
+ eventType: "change",
185
+ filename: "x",
186
+ path: "/a/b.txt",
187
+ }),
188
+ )
189
+ .toBe(false)
190
+ t
191
+ .expect(
192
+ FileSystemExtra.filterSourceFiles({
193
+ eventType: "change",
194
+ filename: "x",
195
+ path: "/a/b.md",
196
+ }),
197
+ )
198
+ .toBe(false)
199
+ })
200
+ })
201
+
202
+ t.describe(`${FileSystemExtra.filterDirectory.name}`, () => {
203
+ t.it("matches directories", () => {
204
+ t
205
+ .expect(
206
+ FileSystemExtra.filterDirectory({
207
+ eventType: "change",
208
+ filename: "x",
209
+ path: "/a/b/",
210
+ }),
211
+ )
212
+ .toBe(true)
213
+ })
214
+
215
+ t.it("rejects files", () => {
216
+ t
217
+ .expect(
218
+ FileSystemExtra.filterDirectory({
219
+ eventType: "change",
220
+ filename: "x",
221
+ path: "/a/b",
222
+ }),
223
+ )
224
+ .toBe(false)
225
+ })
226
+ })
@@ -1,9 +1,8 @@
1
1
  import * as Error from "@effect/platform/Error"
2
+ import * as FileSystem from "@effect/platform/FileSystem"
2
3
  import * as Effect from "effect/Effect"
3
4
  import * as Function from "effect/Function"
4
5
  import * as Stream from "effect/Stream"
5
- import type { WatchOptions } from "node:fs"
6
- import * as NFSP from "node:fs/promises"
7
6
  import * as NPath from "node:path"
8
7
 
9
8
  const SOURCE_FILENAME = /\.(tsx?|jsx?|html?|css|json)$/
@@ -14,69 +13,45 @@ export type WatchEvent = {
14
13
  path: string
15
14
  }
16
15
 
17
- /**
18
- * Filter for source files based on file extension.
19
- */
20
16
  export const filterSourceFiles = (event: WatchEvent): boolean => {
21
17
  return SOURCE_FILENAME.test(event.path)
22
18
  }
23
19
 
24
- /**
25
- * Filter for directories (paths ending with /).
26
- */
27
20
  export const filterDirectory = (event: WatchEvent): boolean => {
28
21
  return event.path.endsWith("/")
29
22
  }
30
23
 
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
24
  export const watchSource = (
38
- opts?: WatchOptions & {
25
+ opts?: {
39
26
  path?: string
27
+ recursive?: boolean
40
28
  filter?: (event: WatchEvent) => boolean
41
29
  },
42
- ): Stream.Stream<WatchEvent, Error.SystemError> => {
30
+ ): Stream.Stream<WatchEvent, Error.PlatformError, FileSystem.FileSystem> => {
43
31
  const baseDir = opts?.path ?? process.cwd()
44
32
  const customFilter = opts?.filter
45
33
 
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,
34
+ return Function.pipe(
35
+ Stream.unwrap(
36
+ Effect.map(
37
+ FileSystem.FileSystem,
38
+ fs => fs.watch(baseDir, { recursive: opts?.recursive ?? true }),
39
+ ),
40
+ ),
65
41
  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
- }))
42
+ Effect.gen(function*() {
43
+ const fs = yield* FileSystem.FileSystem
44
+ const relativePath = NPath.relative(baseDir, e.path)
45
+ const eventType: "change" | "rename" = e._tag === "Update"
46
+ ? "change"
47
+ : "rename"
48
+ const info = yield* Effect.either(fs.stat(e.path))
49
+ const isDir = info._tag === "Right" && info.right.type === "Directory"
50
+ return {
51
+ eventType,
52
+ filename: relativePath,
53
+ path: isDir ? `${e.path}/` : e.path,
54
+ }
80
55
  })
81
56
  ),
82
57
  customFilter ? Stream.filter(customFilter) : Function.identity,
@@ -88,15 +63,4 @@ export const watchSource = (
88
63
  strategy: "enforce",
89
64
  }),
90
65
  )
91
-
92
- return changes
93
66
  }
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
- })
@@ -0,0 +1,68 @@
1
+ import * as HttpServerRequest from "@effect/platform/HttpServerRequest"
2
+ import * as t from "bun:test"
3
+
4
+ import * as HttpUtils from "./HttpUtils.ts"
5
+
6
+ const makeRequest = (url: string, headers: Record<string, string> = {}) =>
7
+ HttpServerRequest.fromWeb(
8
+ new Request(`http://test${url}`, { headers }),
9
+ )
10
+
11
+ t.describe("makeUrlFromRequest", () => {
12
+ t.it("uses Host header for relative URL", () => {
13
+ const request = makeRequest("/api/users", {
14
+ host: "example.com",
15
+ })
16
+ const url = HttpUtils.makeUrlFromRequest(request)
17
+
18
+ t.expect(url.href).toBe("http://example.com/api/users")
19
+ })
20
+
21
+ t.it("uses Origin header when present (takes precedence over Host)", () => {
22
+ const request = makeRequest("/api/users", {
23
+ origin: "https://app.example.com",
24
+ host: "example.com",
25
+ })
26
+ const url = HttpUtils.makeUrlFromRequest(request)
27
+
28
+ t.expect(url.href).toBe("https://app.example.com/api/users")
29
+ })
30
+
31
+ t.it("uses X-Forwarded-Proto for protocol behind reverse proxy", () => {
32
+ const request = makeRequest("/api/users", {
33
+ host: "example.com",
34
+ "x-forwarded-proto": "https",
35
+ })
36
+ const url = HttpUtils.makeUrlFromRequest(request)
37
+
38
+ t.expect(url.href).toBe("https://example.com/api/users")
39
+ })
40
+
41
+ t.it("falls back to http://localhost when no headers", () => {
42
+ const request = makeRequest("/api/users", {})
43
+ const url = HttpUtils.makeUrlFromRequest(request)
44
+
45
+ t.expect(url.href).toBe("http://localhost/api/users")
46
+ })
47
+
48
+ t.it("handles URL with query parameters", () => {
49
+ const request = makeRequest("/search?q=test&page=1", {
50
+ host: "example.com",
51
+ })
52
+ const url = HttpUtils.makeUrlFromRequest(request)
53
+
54
+ t.expect(url.href).toBe("http://example.com/search?q=test&page=1")
55
+ t.expect(url.searchParams.get("q")).toBe("test")
56
+ t.expect(url.searchParams.get("page")).toBe("1")
57
+ })
58
+
59
+ t.it("handles root path", () => {
60
+ const request = makeRequest("/", {
61
+ host: "example.com",
62
+ })
63
+ const url = HttpUtils.makeUrlFromRequest(request)
64
+
65
+ t.expect(url.href).toBe("http://example.com/")
66
+ t.expect(url.pathname).toBe("/")
67
+ })
68
+ })