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.
- package/package.json +12 -13
- package/src/BundleHttp.test.ts +1 -1
- package/src/Commander.test.ts +15 -15
- package/src/Commander.ts +58 -88
- package/src/EncryptedCookies.test.ts +4 -4
- package/src/FileHttpRouter.test.ts +81 -12
- package/src/FileHttpRouter.ts +115 -26
- package/src/FileRouter.ts +60 -162
- package/src/FileRouterCodegen.test.ts +250 -64
- package/src/FileRouterCodegen.ts +13 -56
- package/src/FileRouterPattern.test.ts +116 -0
- package/src/FileRouterPattern.ts +59 -0
- package/src/FileRouter_path.test.ts +63 -102
- package/src/FileSystemExtra.test.ts +226 -0
- package/src/FileSystemExtra.ts +24 -60
- package/src/HttpUtils.test.ts +68 -0
- package/src/HttpUtils.ts +15 -0
- package/src/HyperHtml.ts +24 -5
- package/src/JsModule.test.ts +1 -1
- package/src/NodeFileSystem.ts +764 -0
- package/src/Random.ts +59 -0
- package/src/Route.test.ts +471 -0
- package/src/Route.ts +298 -153
- package/src/RouteRender.ts +38 -0
- package/src/Router.ts +11 -33
- package/src/RouterPattern.test.ts +629 -0
- package/src/RouterPattern.ts +391 -0
- package/src/Start.ts +14 -52
- package/src/bun/BunBundle.test.ts +0 -3
- package/src/bun/BunHttpServer.ts +246 -0
- package/src/bun/BunHttpServer_web.ts +384 -0
- package/src/bun/BunRoute.test.ts +341 -0
- package/src/bun/BunRoute.ts +326 -0
- package/src/bun/BunRoute_bundles.test.ts +218 -0
- package/src/bun/BunRuntime.ts +33 -0
- package/src/bun/BunTailwindPlugin.test.ts +1 -1
- package/src/bun/_empty.html +1 -0
- package/src/bun/index.ts +2 -1
- package/src/testing.ts +12 -3
- package/src/Datastar.test.ts +0 -267
- package/src/Datastar.ts +0 -68
- package/src/bun/BunFullstackServer.ts +0 -45
- package/src/bun/BunFullstackServer_httpServer.ts +0 -541
- 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
|
|
5
|
-
t.expect(FileRouter.
|
|
6
|
-
t.expect(FileRouter.
|
|
4
|
+
t.it("empty path", () => {
|
|
5
|
+
t.expect(FileRouter.parse("")).toEqual([])
|
|
6
|
+
t.expect(FileRouter.parse("/")).toEqual([])
|
|
7
7
|
})
|
|
8
8
|
|
|
9
|
-
t.it("
|
|
10
|
-
t.expect(FileRouter.
|
|
11
|
-
|
|
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(
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
t.expect(FileRouter.
|
|
22
|
-
|
|
23
|
-
{
|
|
24
|
-
{
|
|
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("
|
|
37
|
-
t.expect(FileRouter.
|
|
38
|
-
{
|
|
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.
|
|
41
|
-
|
|
42
|
-
{
|
|
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.
|
|
46
|
-
{
|
|
47
|
-
{ rest: "path" },
|
|
32
|
+
t.expect(FileRouter.parse("layer.tsx")).toEqual([
|
|
33
|
+
{ _tag: "LiteralSegment", value: "layer.tsx" },
|
|
48
34
|
])
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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("
|
|
65
|
-
|
|
66
|
-
t.expect(
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
74
|
-
t.expect(
|
|
75
|
-
t.expect(
|
|
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("
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
{
|
|
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("
|
|
104
|
-
|
|
105
|
-
t.expect(
|
|
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
|
-
|
|
109
|
-
t.expect(
|
|
110
|
-
t.expect(
|
|
111
|
-
|
|
112
|
-
{
|
|
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("
|
|
117
|
-
t.expect(FileRouter.
|
|
118
|
-
|
|
119
|
-
|
|
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.
|
|
125
|
-
{
|
|
126
|
-
{
|
|
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
|
+
})
|
package/src/FileSystemExtra.ts
CHANGED
|
@@ -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?:
|
|
25
|
+
opts?: {
|
|
39
26
|
path?: string
|
|
27
|
+
recursive?: boolean
|
|
40
28
|
filter?: (event: WatchEvent) => boolean
|
|
41
29
|
},
|
|
42
|
-
): Stream.Stream<WatchEvent, Error.
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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.
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
+
})
|