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,122 @@
|
|
|
1
|
+
// @ts-nocheck
|
|
2
|
+
import * as HttpApp from "@effect/platform/HttpApp"
|
|
3
|
+
import * as HttpMiddleware from "@effect/platform/HttpMiddleware"
|
|
4
|
+
import * as HttpRouter from "@effect/platform/HttpRouter"
|
|
5
|
+
import * as HttpServerRequest from "@effect/platform/HttpServerRequest"
|
|
6
|
+
import * as Effect from "effect/Effect"
|
|
7
|
+
import * as Function from "effect/Function"
|
|
8
|
+
import * as Router from "./Router.ts"
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Combines Effect error channel from a record of effects.
|
|
12
|
+
*/
|
|
13
|
+
type RecordEffectError<A> = A extends Record<string, any> ? Exclude<
|
|
14
|
+
{
|
|
15
|
+
[K in keyof A]: A[K] extends Effect.Effect<any, infer E, any> ? E
|
|
16
|
+
: never
|
|
17
|
+
}[keyof A],
|
|
18
|
+
undefined
|
|
19
|
+
>
|
|
20
|
+
: never
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Combines Effect requirement channel from a record of effects.
|
|
24
|
+
*/
|
|
25
|
+
type RecordEffectRequirements<A> = A extends Record<string, any> ? Exclude<
|
|
26
|
+
{
|
|
27
|
+
[K in keyof A]: A[K] extends Effect.Effect<any, any, infer R> ? R
|
|
28
|
+
: never
|
|
29
|
+
}[keyof A],
|
|
30
|
+
undefined
|
|
31
|
+
>
|
|
32
|
+
: never
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Infers the HttpRouter type from an array of ServerRoutes
|
|
36
|
+
*/
|
|
37
|
+
export type HttpRouterFromServerRoutes<
|
|
38
|
+
Routes extends ReadonlyArray<Router.ServerRoute>,
|
|
39
|
+
> = HttpRouter.HttpRouter<
|
|
40
|
+
(Routes extends ReadonlyArray<infer Route>
|
|
41
|
+
? Route extends Router.ServerRoute
|
|
42
|
+
? RecordEffectError<Awaited<ReturnType<Route["load"]>>>
|
|
43
|
+
: never
|
|
44
|
+
: never),
|
|
45
|
+
Exclude<
|
|
46
|
+
Routes extends ReadonlyArray<infer Route>
|
|
47
|
+
? Route extends Router.ServerRoute
|
|
48
|
+
? RecordEffectRequirements<Awaited<ReturnType<Route["load"]>>>
|
|
49
|
+
: never
|
|
50
|
+
: never,
|
|
51
|
+
// exclude HttpServerRequest since HttpRouter already has it
|
|
52
|
+
HttpServerRequest.HttpServerRequest
|
|
53
|
+
>
|
|
54
|
+
>
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Converts file-based route path format to HttpRouter path format.
|
|
58
|
+
* Examples:
|
|
59
|
+
* /movies/[id] -> /movies/:id
|
|
60
|
+
* /docs/[[...slug]] -> /docs/*
|
|
61
|
+
* /api/[...path] -> /api/*
|
|
62
|
+
*/
|
|
63
|
+
function convertPathFormat(path: string): string {
|
|
64
|
+
return path
|
|
65
|
+
// Convert required params: [id] -> :id
|
|
66
|
+
.replace(/\[([^\]\.]+)\]/g, ":$1")
|
|
67
|
+
// Convert optional rest params: [[...slug]] -> *
|
|
68
|
+
.replace(/\[\[\.\.\.([^\]]+)\]\]/g, "*")
|
|
69
|
+
// Convert required rest params: [...path] -> *
|
|
70
|
+
.replace(/\[\.\.\.([^\]]+)\]/g, "*")
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Makes a HttpRouter from file-based routes.
|
|
75
|
+
*/
|
|
76
|
+
export function make<Routes extends Router.ServerRoutes>(
|
|
77
|
+
routes: Routes,
|
|
78
|
+
): Effect.Effect<HttpRouterFromServerRoutes<Routes>> {
|
|
79
|
+
return Effect.gen(function*() {
|
|
80
|
+
const modules = yield* Effect.forEach(
|
|
81
|
+
routes,
|
|
82
|
+
(route) =>
|
|
83
|
+
Function.pipe(
|
|
84
|
+
Effect.tryPromise(() => route.load()),
|
|
85
|
+
Effect.orDie,
|
|
86
|
+
Effect.map((module) => ({ path: route.path, module })),
|
|
87
|
+
),
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
let router: HttpRouter.HttpRouter<any, any> = HttpRouter.empty
|
|
91
|
+
|
|
92
|
+
for (const { path, module } of modules) {
|
|
93
|
+
const routeSet = module.default
|
|
94
|
+
const httpRouterPath = convertPathFormat(path)
|
|
95
|
+
|
|
96
|
+
for (const route of routeSet.set) {
|
|
97
|
+
router = HttpRouter.route(route.method)(
|
|
98
|
+
httpRouterPath,
|
|
99
|
+
route.handler as any,
|
|
100
|
+
)(
|
|
101
|
+
router,
|
|
102
|
+
)
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return router as HttpRouterFromServerRoutes<Routes>
|
|
107
|
+
})
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function middleware() {
|
|
111
|
+
return HttpMiddleware.make((app) =>
|
|
112
|
+
Effect.gen(function*() {
|
|
113
|
+
const routerContext = yield* Router.Router
|
|
114
|
+
const router = routerContext.httpRouter
|
|
115
|
+
const res = yield* router.pipe(
|
|
116
|
+
Effect.catchTag("RouteNotFound", () => app),
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
return res
|
|
120
|
+
})
|
|
121
|
+
)
|
|
122
|
+
}
|
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
import type { PlatformError } from "@effect/platform/Error"
|
|
2
|
+
import * as FileSystem from "@effect/platform/FileSystem"
|
|
3
|
+
import * as Array from "effect/Array"
|
|
4
|
+
import * as Effect from "effect/Effect"
|
|
5
|
+
import * as Function from "effect/Function"
|
|
6
|
+
import * as Layer from "effect/Layer"
|
|
7
|
+
import * as Record from "effect/Record"
|
|
8
|
+
import * as Stream from "effect/Stream"
|
|
9
|
+
import * as NPath from "node:path"
|
|
10
|
+
import * as NUrl from "node:url"
|
|
11
|
+
import * as FileRouterCodegen from "./FileRouterCodegen.ts"
|
|
12
|
+
import * as FileSystemExtra from "./FileSystemExtra.ts"
|
|
13
|
+
import { ServerModule } from "./Router.ts"
|
|
14
|
+
|
|
15
|
+
type LiteralSegment = {
|
|
16
|
+
literal: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
type GroupSegment = {
|
|
20
|
+
group: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
type ParamSegment = {
|
|
24
|
+
param: string
|
|
25
|
+
optional?: true
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
type RestSegment = {
|
|
29
|
+
rest: string
|
|
30
|
+
optional?: true
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
type HandleSegment = {
|
|
34
|
+
handle: "route" | "layer"
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type Extension = "tsx" | "jsx" | "ts" | "js"
|
|
38
|
+
|
|
39
|
+
export type Segment =
|
|
40
|
+
| LiteralSegment
|
|
41
|
+
| GroupSegment
|
|
42
|
+
| ParamSegment
|
|
43
|
+
| RestSegment
|
|
44
|
+
| HandleSegment
|
|
45
|
+
|
|
46
|
+
export function isSegmentEqual(a: Segment, b: Segment): boolean {
|
|
47
|
+
if ("literal" in a && "literal" in b) return a.literal === b.literal
|
|
48
|
+
if ("group" in a && "group" in b) return a.group === b.group
|
|
49
|
+
if ("param" in a && "param" in b) return a.param === b.param
|
|
50
|
+
if ("rest" in a && "rest" in b) return a.rest === b.rest
|
|
51
|
+
if ("handle" in a && "handle" in b) return a.handle === b.handle
|
|
52
|
+
return false
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export type RouteModule = {
|
|
56
|
+
path: `/${string}`
|
|
57
|
+
segments: readonly Segment[]
|
|
58
|
+
load: () => Promise<ServerModule>
|
|
59
|
+
layers?: ReadonlyArray<() => Promise<unknown>>
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export type RouteManifest = {
|
|
63
|
+
Modules: readonly RouteModule[]
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export type RouteHandle = {
|
|
67
|
+
handle: "route" | "layer"
|
|
68
|
+
modulePath: string // eg. `about/route.tsx`, `users/[userId]/route.tsx`, `(admin)/users/route.tsx`
|
|
69
|
+
routePath: `/${string}` // eg. `/about`, `/users/[userId]`, `/users` (groups stripped)
|
|
70
|
+
segments: Segment[]
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Routes are sorted by depth, layers are first,
|
|
75
|
+
* rest parameters are put at the end for each segment.
|
|
76
|
+
* - layer.tsx
|
|
77
|
+
* - users/route.tsx
|
|
78
|
+
* - users/[userId]/route.tsx
|
|
79
|
+
* - [[...rest]]/route.tsx
|
|
80
|
+
*/
|
|
81
|
+
export type OrderedRouteHandles = RouteHandle[]
|
|
82
|
+
|
|
83
|
+
const ROUTE_PATH_REGEX = /^\/?(.*\/?)((route|layer))\.(jsx?|tsx?)$/
|
|
84
|
+
|
|
85
|
+
type RoutePathMatch = [
|
|
86
|
+
path: string,
|
|
87
|
+
kind: string,
|
|
88
|
+
kind: string,
|
|
89
|
+
ext: string,
|
|
90
|
+
]
|
|
91
|
+
|
|
92
|
+
export function segmentPath(path: string): Segment[] {
|
|
93
|
+
const trimmedPath = path.replace(/(^\/)|(\/$)/g, "") // trim leading/trailing slashes
|
|
94
|
+
|
|
95
|
+
if (trimmedPath === "") {
|
|
96
|
+
return [] // Handles "" and "/"
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const segmentStrings = trimmedPath
|
|
100
|
+
.split("/")
|
|
101
|
+
.filter(s => s !== "") // Remove empty segments from multiple slashes, e.g. "foo//bar"
|
|
102
|
+
|
|
103
|
+
if (segmentStrings.length === 0) {
|
|
104
|
+
return []
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const segments: (Segment | null)[] = segmentStrings.map(
|
|
108
|
+
(s): Segment | null => {
|
|
109
|
+
// Check if it's a handle (route.ts, layer.tsx, etc.)
|
|
110
|
+
const [, handle] = s.match(/^(route|layer)\.(tsx?|jsx?)$/)
|
|
111
|
+
?? []
|
|
112
|
+
|
|
113
|
+
if (handle) {
|
|
114
|
+
// @ts-expect-error regexp group ain't typed
|
|
115
|
+
return { handle }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// (group) - Groups
|
|
119
|
+
const groupMatch = s.match(/^\((\w+)\)$/)
|
|
120
|
+
if (groupMatch) {
|
|
121
|
+
return { group: groupMatch[1] }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// [[...rest]] - Optional rest parameter
|
|
125
|
+
const optionalRestMatch = s.match(/^\[\[\.\.\.(\w+)\]\]$/)
|
|
126
|
+
if (optionalRestMatch) {
|
|
127
|
+
return {
|
|
128
|
+
rest: optionalRestMatch[1],
|
|
129
|
+
optional: true,
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// [...rest] - Required rest parameter
|
|
134
|
+
const requiredRestMatch = s.match(/^\[\.\.\.(\w+)\]$/)
|
|
135
|
+
if (requiredRestMatch) {
|
|
136
|
+
return { rest: requiredRestMatch[1] }
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// [param] - Dynamic parameter
|
|
140
|
+
const paramMatch = s.match(/^\[(\w+)\]$/)
|
|
141
|
+
if (paramMatch) {
|
|
142
|
+
return { param: paramMatch[1] }
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Literal segment
|
|
146
|
+
if (/^[A-Za-z0-9._~-]+$/.test(s)) {
|
|
147
|
+
return { literal: s }
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return null
|
|
151
|
+
},
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
if (segments.some((seg) => seg === null)) {
|
|
155
|
+
throw new Error(
|
|
156
|
+
`Invalid path segment in "${path}": contains invalid characters or format`,
|
|
157
|
+
)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return segments as Segment[]
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function segmentToText(seg: Segment): string {
|
|
164
|
+
if ("literal" in seg) return seg.literal
|
|
165
|
+
if ("group" in seg) return `(${seg.group})`
|
|
166
|
+
if ("param" in seg) return `[${seg.param}]`
|
|
167
|
+
if ("rest" in seg) {
|
|
168
|
+
return seg.optional ? `[[...${seg.rest}]]` : `[...${seg.rest}]`
|
|
169
|
+
}
|
|
170
|
+
if ("handle" in seg) return seg.handle
|
|
171
|
+
return ""
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function parseRoute(
|
|
175
|
+
path: string,
|
|
176
|
+
): RouteHandle {
|
|
177
|
+
const segs = segmentPath(path)
|
|
178
|
+
|
|
179
|
+
const handle = segs.at(-1)
|
|
180
|
+
|
|
181
|
+
if (!handle || !("handle" in handle)) {
|
|
182
|
+
throw new Error(
|
|
183
|
+
`Invalid route path "${path}": must end with a valid handle (route or layer)`,
|
|
184
|
+
)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Validate Route constraints: rest segments must be the last segment before the handle
|
|
188
|
+
const pathSegments = segs.slice(0, -1) // All segments except the handle
|
|
189
|
+
const restIndex = pathSegments.findIndex(seg => "rest" in seg)
|
|
190
|
+
|
|
191
|
+
if (restIndex !== -1) {
|
|
192
|
+
// If there's a rest, it must be the last path segment
|
|
193
|
+
if (restIndex !== pathSegments.length - 1) {
|
|
194
|
+
throw new Error(
|
|
195
|
+
`Invalid route path "${path}": rest segment ([...rest] or [[...rest]]) must be the last path segment before the handle`,
|
|
196
|
+
)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Validate that all segments before the rest are literal, param, or group
|
|
200
|
+
for (let i = 0; i < restIndex; i++) {
|
|
201
|
+
const seg = pathSegments[i]
|
|
202
|
+
if (!("literal" in seg) && !("param" in seg) && !("group" in seg)) {
|
|
203
|
+
throw new Error(
|
|
204
|
+
`Invalid route path "${path}": segments before rest must be literal, param, or group segments`,
|
|
205
|
+
)
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
} else {
|
|
209
|
+
// No rest: validate that all path segments are literal, param, or group
|
|
210
|
+
for (const seg of pathSegments) {
|
|
211
|
+
if (!("literal" in seg) && !("param" in seg) && !("group" in seg)) {
|
|
212
|
+
throw new Error(
|
|
213
|
+
`Invalid route path "${path}": path segments must be literal, param, or group segments`,
|
|
214
|
+
)
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Construct routePath from path segments (excluding handle and groups)
|
|
220
|
+
// Groups like (admin) are stripped from the URL path
|
|
221
|
+
const routePathSegments = pathSegments
|
|
222
|
+
.filter(seg => !("group" in seg))
|
|
223
|
+
.map(segmentToText)
|
|
224
|
+
|
|
225
|
+
const routePath = (routePathSegments.length > 0
|
|
226
|
+
? `/${routePathSegments.join("/")}`
|
|
227
|
+
: "/") as `/${string}`
|
|
228
|
+
|
|
229
|
+
return {
|
|
230
|
+
handle: handle.handle,
|
|
231
|
+
modulePath: path,
|
|
232
|
+
routePath,
|
|
233
|
+
segments: segs,
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Generates a file that references all routes.
|
|
239
|
+
*/
|
|
240
|
+
export function layer(options: {
|
|
241
|
+
load: () => Promise<unknown>
|
|
242
|
+
path: string
|
|
243
|
+
}) {
|
|
244
|
+
let manifestPath = options.path
|
|
245
|
+
|
|
246
|
+
// handle use of import.meta.resolve
|
|
247
|
+
if (manifestPath.startsWith("file://")) {
|
|
248
|
+
manifestPath = NUrl.fileURLToPath(manifestPath)
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const routesPath = NPath.dirname(manifestPath)
|
|
252
|
+
const manifestFilename = NPath.basename(manifestPath)
|
|
253
|
+
const resolvedManifestPath = NPath.resolve(routesPath, manifestFilename)
|
|
254
|
+
|
|
255
|
+
return Layer.scopedDiscard(
|
|
256
|
+
Effect.gen(function*() {
|
|
257
|
+
yield* FileRouterCodegen.update(routesPath, manifestFilename)
|
|
258
|
+
|
|
259
|
+
const stream = Function.pipe(
|
|
260
|
+
FileSystemExtra.watchSource({
|
|
261
|
+
path: routesPath,
|
|
262
|
+
filter: (e) => !e.path.includes("node_modules"),
|
|
263
|
+
}),
|
|
264
|
+
Stream.onError((e) => Effect.logError(e)),
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
yield* Function.pipe(
|
|
268
|
+
stream,
|
|
269
|
+
// filter out edits to gen file
|
|
270
|
+
Stream.filter(e => e.path !== resolvedManifestPath),
|
|
271
|
+
Stream.runForEach(() =>
|
|
272
|
+
FileRouterCodegen.update(routesPath, manifestFilename)
|
|
273
|
+
),
|
|
274
|
+
Effect.fork,
|
|
275
|
+
)
|
|
276
|
+
}),
|
|
277
|
+
)
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export function walkRoutesDirectory(
|
|
281
|
+
dir: string,
|
|
282
|
+
): Effect.Effect<
|
|
283
|
+
OrderedRouteHandles,
|
|
284
|
+
PlatformError,
|
|
285
|
+
FileSystem.FileSystem
|
|
286
|
+
> {
|
|
287
|
+
return Effect.gen(function*() {
|
|
288
|
+
const fs = yield* FileSystem.FileSystem
|
|
289
|
+
const files = yield* fs.readDirectory(dir, { recursive: true })
|
|
290
|
+
|
|
291
|
+
return getRouteHandlesFromPaths(files)
|
|
292
|
+
})
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Given a list of paths, return a list of route handles.
|
|
297
|
+
*/
|
|
298
|
+
export function getRouteHandlesFromPaths(
|
|
299
|
+
paths: string[],
|
|
300
|
+
): OrderedRouteHandles {
|
|
301
|
+
const handles = paths
|
|
302
|
+
.map(f => f.match(ROUTE_PATH_REGEX) as RoutePathMatch)
|
|
303
|
+
.filter(Boolean)
|
|
304
|
+
.map(v => {
|
|
305
|
+
const path = v[0]
|
|
306
|
+
try {
|
|
307
|
+
return parseRoute(path)
|
|
308
|
+
} catch {
|
|
309
|
+
return null
|
|
310
|
+
}
|
|
311
|
+
})
|
|
312
|
+
.filter((route): route is RouteHandle => route !== null)
|
|
313
|
+
.toSorted((a, b) => {
|
|
314
|
+
const aDepth = a.segments.length
|
|
315
|
+
const bDepth = b.segments.length
|
|
316
|
+
const aHasRest = a.segments.some(seg => "rest" in seg)
|
|
317
|
+
const bHasRest = b.segments.some(seg => "rest" in seg)
|
|
318
|
+
|
|
319
|
+
return (
|
|
320
|
+
// rest is a dominant factor (routes with rest come last)
|
|
321
|
+
(+aHasRest - +bHasRest) * 1000
|
|
322
|
+
// depth is reversed for rest
|
|
323
|
+
+ (aDepth - bDepth) * (1 - 2 * +aHasRest)
|
|
324
|
+
// lexicographic comparison as tiebreaker
|
|
325
|
+
+ a.modulePath.localeCompare(b.modulePath) * 0.001
|
|
326
|
+
)
|
|
327
|
+
})
|
|
328
|
+
|
|
329
|
+
// Detect conflicting routes at the same path
|
|
330
|
+
const routesByPath = new Map<string, RouteHandle[]>()
|
|
331
|
+
for (const handle of handles) {
|
|
332
|
+
const existing = routesByPath.get(handle.routePath) || []
|
|
333
|
+
existing.push(handle)
|
|
334
|
+
routesByPath.set(handle.routePath, existing)
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
for (const [path, pathHandles] of routesByPath) {
|
|
338
|
+
const routeHandles = pathHandles.filter(h => h.handle === "route")
|
|
339
|
+
|
|
340
|
+
if (routeHandles.length > 1) {
|
|
341
|
+
const modulePaths = routeHandles.map(h => h.modulePath).join(", ")
|
|
342
|
+
throw new Error(
|
|
343
|
+
`Conflicting routes detected at path ${path}: ${modulePaths}`,
|
|
344
|
+
)
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
return handles
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
type RouteTree = {
|
|
352
|
+
path: `/${string}`
|
|
353
|
+
handles: RouteHandle[]
|
|
354
|
+
children?: RouteTree[]
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
export function treeFromRouteHandles(
|
|
358
|
+
handles: RouteHandle[],
|
|
359
|
+
): RouteTree {
|
|
360
|
+
const handlesByPath = Array.groupBy(handles, handle => handle.routePath)
|
|
361
|
+
const paths = Record.keys(handlesByPath)
|
|
362
|
+
const root: RouteTree = {
|
|
363
|
+
path: "/",
|
|
364
|
+
handles: handlesByPath["/"] || [],
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const nodeMap = new Map<string, RouteTree>([["/", root]])
|
|
368
|
+
|
|
369
|
+
for (const absolutePath of paths) {
|
|
370
|
+
if (absolutePath === "/") continue
|
|
371
|
+
|
|
372
|
+
// Find parent path
|
|
373
|
+
const segments = absolutePath.split("/").filter(Boolean)
|
|
374
|
+
const parentPath = segments.length === 1
|
|
375
|
+
? "/"
|
|
376
|
+
: "/" + segments.slice(0, -1).join("/")
|
|
377
|
+
|
|
378
|
+
const parent = nodeMap.get(parentPath)
|
|
379
|
+
if (!parent) {
|
|
380
|
+
continue // Skip orphaned paths
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// Create node with relative path
|
|
384
|
+
const relativePath = parent.path === "/"
|
|
385
|
+
? absolutePath
|
|
386
|
+
: absolutePath.slice(parentPath.length)
|
|
387
|
+
|
|
388
|
+
const node: RouteTree = {
|
|
389
|
+
path: relativePath as `/${string}`,
|
|
390
|
+
handles: handlesByPath[absolutePath]!,
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// Add to parent
|
|
394
|
+
if (!parent.children) {
|
|
395
|
+
parent.children = []
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
parent.children.push(node)
|
|
399
|
+
|
|
400
|
+
// Store for future children
|
|
401
|
+
nodeMap.set(absolutePath, node)
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
return root
|
|
405
|
+
}
|