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.
- package/LICENSE +21 -0
- package/README.md +109 -0
- package/package.json +57 -0
- package/src/Bundle.ts +167 -0
- package/src/BundleFiles.ts +174 -0
- package/src/BundleHttp.test.ts +160 -0
- package/src/BundleHttp.ts +259 -0
- package/src/Commander.test.ts +1378 -0
- package/src/Commander.ts +672 -0
- package/src/Datastar.test.ts +267 -0
- package/src/Datastar.ts +68 -0
- package/src/Effect_HttpRouter.test.ts +570 -0
- package/src/EncryptedCookies.test.ts +427 -0
- package/src/EncryptedCookies.ts +451 -0
- package/src/FileHttpRouter.test.ts +207 -0
- package/src/FileHttpRouter.ts +122 -0
- package/src/FileRouter.ts +405 -0
- package/src/FileRouterCodegen.test.ts +598 -0
- package/src/FileRouterCodegen.ts +251 -0
- package/src/FileRouter_files.test.ts +64 -0
- package/src/FileRouter_path.test.ts +132 -0
- package/src/FileRouter_tree.test.ts +126 -0
- package/src/FileSystemExtra.ts +102 -0
- package/src/HttpAppExtra.ts +127 -0
- package/src/Hyper.ts +194 -0
- package/src/HyperHtml.test.ts +90 -0
- package/src/HyperHtml.ts +139 -0
- package/src/HyperNode.ts +37 -0
- package/src/JsModule.test.ts +14 -0
- package/src/JsModule.ts +116 -0
- package/src/PublicDirectory.test.ts +280 -0
- package/src/PublicDirectory.ts +108 -0
- package/src/Route.test.ts +873 -0
- package/src/Route.ts +992 -0
- package/src/Router.ts +80 -0
- package/src/SseHttpResponse.ts +55 -0
- package/src/Start.ts +133 -0
- package/src/StartApp.ts +43 -0
- package/src/StartHttp.ts +42 -0
- package/src/StreamExtra.ts +146 -0
- package/src/TestHttpClient.test.ts +54 -0
- package/src/TestHttpClient.ts +100 -0
- package/src/bun/BunBundle.test.ts +277 -0
- package/src/bun/BunBundle.ts +309 -0
- package/src/bun/BunBundle_imports.test.ts +50 -0
- package/src/bun/BunFullstackServer.ts +45 -0
- package/src/bun/BunFullstackServer_httpServer.ts +541 -0
- package/src/bun/BunImportTrackerPlugin.test.ts +77 -0
- package/src/bun/BunImportTrackerPlugin.ts +97 -0
- package/src/bun/BunTailwindPlugin.test.ts +335 -0
- package/src/bun/BunTailwindPlugin.ts +322 -0
- package/src/bun/BunVirtualFilesPlugin.ts +59 -0
- package/src/bun/index.ts +4 -0
- package/src/client/Overlay.ts +34 -0
- package/src/client/ScrollState.ts +120 -0
- package/src/client/index.ts +101 -0
- package/src/index.ts +24 -0
- package/src/jsx-datastar.d.ts +63 -0
- package/src/jsx-runtime.ts +23 -0
- package/src/jsx.d.ts +4402 -0
- package/src/testing.ts +55 -0
- package/src/x/cloudflare/CloudflareTunnel.ts +110 -0
- package/src/x/cloudflare/index.ts +1 -0
- package/src/x/datastar/Datastar.test.ts +267 -0
- package/src/x/datastar/Datastar.ts +68 -0
- package/src/x/datastar/index.ts +4 -0
- 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
|
+
})
|