flamefront 0.0.0 → 0.1.0-alpha.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.md +110 -0
- package/README.md +643 -0
- package/bin/ff-loader.mjs +18 -0
- package/bin/ff.js +6 -0
- package/package.json +63 -10
- package/src/babel.ts +22 -0
- package/src/cli.ts +83 -0
- package/src/fragment-client.ts +231 -0
- package/src/fragment-protocol.ts +39 -0
- package/src/fragment.ts +260 -0
- package/src/index.ts +648 -0
- package/src/lifecycle.ts +486 -0
- package/src/octane-client-core.ts +134 -0
- package/src/octane-client.ts +42 -0
- package/src/octane-router-document.ts +5 -0
- package/src/octane.ts +654 -0
- package/src/remix-route-data.ts +68 -0
- package/src/remix-router-core.ts +106 -0
- package/src/remix-router.ts +111 -0
- package/src/remove-exports.ts +148 -0
- package/src/route-data-client.ts +224 -0
- package/src/route-prefetch.ts +72 -0
- package/src/server.ts +240 -0
- package/src/srvx.ts +314 -0
- package/src/static-fragment-artifacts.ts +86 -0
- package/src/virtual-remix-routes.d.ts +20 -0
- package/src/vite.ts +693 -0
- package/readme.md +0 -1
package/src/index.ts
ADDED
|
@@ -0,0 +1,648 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createMultiMatcher,
|
|
3
|
+
type Match,
|
|
4
|
+
type MultiMatcher,
|
|
5
|
+
} from "@remix-run/route-pattern/match"
|
|
6
|
+
import type { HydrationInteractionEvents } from "octane/hydration"
|
|
7
|
+
import { createRouteDataClient } from "./route-data-client.ts"
|
|
8
|
+
import { stripFlamefrontProtocolParams } from "./fragment-protocol.ts"
|
|
9
|
+
|
|
10
|
+
export type RenderMode = "client" | "server" | "static"
|
|
11
|
+
|
|
12
|
+
export interface RoutingOptions {
|
|
13
|
+
/** URL pathname prefix shared by app matching and generated routes. */
|
|
14
|
+
readonly basename?: string
|
|
15
|
+
/** Route-data endpoint pathname shared by browser loaders and the server. */
|
|
16
|
+
readonly dataPath?: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface NormalizedRoutingOptions {
|
|
20
|
+
readonly basename: string
|
|
21
|
+
readonly dataPath: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface IdleHydration {
|
|
25
|
+
readonly when: "idle"
|
|
26
|
+
readonly timeout?: number
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface VisibleHydration {
|
|
30
|
+
readonly when: "visible"
|
|
31
|
+
readonly rootMargin?: string
|
|
32
|
+
readonly threshold?: number | readonly number[]
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface InteractionHydration {
|
|
36
|
+
readonly when: "interaction"
|
|
37
|
+
readonly events?: HydrationInteractionEvents
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface MediaHydration {
|
|
41
|
+
readonly when: "media"
|
|
42
|
+
readonly query: string
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export type GeneratedHydration =
|
|
46
|
+
IdleHydration | VisibleHydration | InteractionHydration | MediaHydration
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* `full` hydrates with the shell, `deferred` leaves boundaries to the route,
|
|
50
|
+
* `none` keeps server HTML inert, and an object generates one route boundary.
|
|
51
|
+
*/
|
|
52
|
+
export type HydrationMode = "full" | "deferred" | "none" | GeneratedHydration
|
|
53
|
+
|
|
54
|
+
export type RouteNavigationStrategy = "router" | "fragment"
|
|
55
|
+
|
|
56
|
+
export type RouteBoundaryKind = "shell" | "layout" | "route"
|
|
57
|
+
|
|
58
|
+
/** Metadata emitted on generated router nodes for route-aware navigation. */
|
|
59
|
+
export interface GeneratedRouteMetadata {
|
|
60
|
+
readonly id: string
|
|
61
|
+
/** Stable token used by the static-fragment boundary pass. */
|
|
62
|
+
readonly boundary: string
|
|
63
|
+
readonly kind: RouteBoundaryKind
|
|
64
|
+
readonly entry: string
|
|
65
|
+
readonly parent?: string
|
|
66
|
+
readonly path?: string
|
|
67
|
+
readonly render?: RenderMode
|
|
68
|
+
readonly navigation: RouteNavigationStrategy
|
|
69
|
+
readonly hydration?: HydrationMode
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface RouteOptions {
|
|
73
|
+
readonly render?: RenderMode
|
|
74
|
+
readonly hydration?: HydrationMode
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface RouteDefinition extends RouteOptions {
|
|
78
|
+
readonly path: string
|
|
79
|
+
/** Octane/Vite project-root module ID, such as `/src/Home.tsrx`. */
|
|
80
|
+
readonly entry: string
|
|
81
|
+
readonly render: RenderMode
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface LayoutDefinition<
|
|
85
|
+
Children extends readonly RouteConfig[] = readonly RouteConfig[],
|
|
86
|
+
> {
|
|
87
|
+
readonly kind: "layout"
|
|
88
|
+
/** Octane/Vite project-root module ID for the pathless layout component. */
|
|
89
|
+
readonly entry: string
|
|
90
|
+
readonly children: Children
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export type RouteConfig = RouteDefinition | LayoutDefinition
|
|
94
|
+
|
|
95
|
+
export interface MatchRouteOptions {
|
|
96
|
+
readonly render?: RenderMode
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface LoadRouteOptions {
|
|
100
|
+
readonly signal?: AbortSignal
|
|
101
|
+
readonly reload?: boolean
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export interface AppDefinition<T extends RouteDefinition = RouteDefinition> {
|
|
105
|
+
/** Octane/Vite project-root module ID for the persistent app shell. */
|
|
106
|
+
readonly shell: string
|
|
107
|
+
readonly routes: readonly T[]
|
|
108
|
+
readonly routeTree: readonly RouteConfig[]
|
|
109
|
+
readonly routing: NormalizedRoutingOptions
|
|
110
|
+
readonly match: (
|
|
111
|
+
url: string | URL,
|
|
112
|
+
options?: MatchRouteOptions,
|
|
113
|
+
) => Match<string, T> | null
|
|
114
|
+
/** Load route data using the route's live or static data source. */
|
|
115
|
+
readonly load: <Data = unknown>(
|
|
116
|
+
url: string | URL,
|
|
117
|
+
options?: LoadRouteOptions,
|
|
118
|
+
) => Promise<Data>
|
|
119
|
+
/** Warm the same cache used by generated client route loaders. */
|
|
120
|
+
readonly prefetch: (
|
|
121
|
+
url: string | URL,
|
|
122
|
+
options?: LoadRouteOptions,
|
|
123
|
+
) => Promise<void>
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const defaultRoutingOptions: NormalizedRoutingOptions = Object.freeze({
|
|
127
|
+
basename: "/",
|
|
128
|
+
dataPath: "/__flamefront/data",
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
const renderModes: ReadonlySet<unknown> = new Set<RenderMode>([
|
|
132
|
+
"client",
|
|
133
|
+
"server",
|
|
134
|
+
"static",
|
|
135
|
+
])
|
|
136
|
+
const hydrationModes: ReadonlySet<unknown> = new Set([
|
|
137
|
+
"full",
|
|
138
|
+
"deferred",
|
|
139
|
+
"none",
|
|
140
|
+
])
|
|
141
|
+
const interactionEvents: ReadonlySet<string> = new Set([
|
|
142
|
+
"auxclick",
|
|
143
|
+
"beforeinput",
|
|
144
|
+
"click",
|
|
145
|
+
"compositionend",
|
|
146
|
+
"compositionstart",
|
|
147
|
+
"compositionupdate",
|
|
148
|
+
"contextmenu",
|
|
149
|
+
"dblclick",
|
|
150
|
+
"focusin",
|
|
151
|
+
"input",
|
|
152
|
+
"keydown",
|
|
153
|
+
"keyup",
|
|
154
|
+
"mousedown",
|
|
155
|
+
"mouseenter",
|
|
156
|
+
"mouseover",
|
|
157
|
+
"mouseup",
|
|
158
|
+
"pointerdown",
|
|
159
|
+
"pointerenter",
|
|
160
|
+
"pointerover",
|
|
161
|
+
"pointerup",
|
|
162
|
+
"touchend",
|
|
163
|
+
"touchstart",
|
|
164
|
+
])
|
|
165
|
+
const matcherCache = new WeakMap<
|
|
166
|
+
readonly RouteDefinition[],
|
|
167
|
+
Map<RenderMode | undefined, MultiMatcher<RouteDefinition>>
|
|
168
|
+
>()
|
|
169
|
+
|
|
170
|
+
function normalizeRoutingPath(
|
|
171
|
+
value: unknown,
|
|
172
|
+
name: string,
|
|
173
|
+
fallback: string,
|
|
174
|
+
): string {
|
|
175
|
+
const path = value ?? fallback
|
|
176
|
+
|
|
177
|
+
if (typeof path !== "string" || path.length === 0) {
|
|
178
|
+
throw new TypeError(
|
|
179
|
+
`flamefront routing ${name} must be a non-empty string.`,
|
|
180
|
+
)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (!path.startsWith("/")) {
|
|
184
|
+
throw new TypeError(`flamefront routing ${name} must start with '/'.`)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (path.includes("?") || path.includes("#")) {
|
|
188
|
+
throw new TypeError(
|
|
189
|
+
`flamefront routing ${name} must be a pathname without a query or hash.`,
|
|
190
|
+
)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return path.replace(/\/+$/, "") || "/"
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function normalizeRoutingOptions(
|
|
197
|
+
options: RoutingOptions | undefined = undefined,
|
|
198
|
+
): NormalizedRoutingOptions {
|
|
199
|
+
if (options !== undefined && (!options || typeof options !== "object")) {
|
|
200
|
+
throw new TypeError("flamefront routing options must be an object.")
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return Object.freeze({
|
|
204
|
+
basename: normalizeRoutingPath(
|
|
205
|
+
options?.basename,
|
|
206
|
+
"basename",
|
|
207
|
+
defaultRoutingOptions.basename,
|
|
208
|
+
),
|
|
209
|
+
dataPath: normalizeRoutingPath(
|
|
210
|
+
options?.dataPath,
|
|
211
|
+
"dataPath",
|
|
212
|
+
defaultRoutingOptions.dataPath,
|
|
213
|
+
),
|
|
214
|
+
})
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Remove the normalized app basename from a request pathname. */
|
|
218
|
+
export function stripBasename(
|
|
219
|
+
pathname: string,
|
|
220
|
+
basename: string,
|
|
221
|
+
): string | null {
|
|
222
|
+
if (basename === "/") {
|
|
223
|
+
return pathname
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (pathname === basename) {
|
|
227
|
+
return "/"
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (!pathname.startsWith(`${basename}/`)) {
|
|
231
|
+
return null
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return pathname.slice(basename.length) || "/"
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Prefix an app-relative route path with the normalized app basename. */
|
|
238
|
+
export function joinBasename(basename: string, pathname: string): string {
|
|
239
|
+
if (basename === "/") {
|
|
240
|
+
return pathname || "/"
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (pathname === "/") {
|
|
244
|
+
return basename
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return `${basename}${pathname.startsWith("/") ? pathname : `/${pathname}`}`
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function assertString(value: unknown, name: string): asserts value is string {
|
|
251
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
252
|
+
throw new TypeError(`flamefront ${name} must be a non-empty string.`)
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function assertOnlyKeys(
|
|
257
|
+
value: Record<string, unknown>,
|
|
258
|
+
keys: readonly string[],
|
|
259
|
+
location: string,
|
|
260
|
+
): void {
|
|
261
|
+
const allowed = new Set(keys)
|
|
262
|
+
const unexpected = Object.keys(value).find((key) => !allowed.has(key))
|
|
263
|
+
|
|
264
|
+
if (unexpected) {
|
|
265
|
+
throw new TypeError(
|
|
266
|
+
`flamefront route ${location} hydration has an unexpected ${JSON.stringify(unexpected)} option.`,
|
|
267
|
+
)
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function assertThreshold(value: unknown, location: string): void {
|
|
272
|
+
const thresholds = Array.isArray(value) ? value : [value]
|
|
273
|
+
|
|
274
|
+
if (
|
|
275
|
+
thresholds.length === 0 ||
|
|
276
|
+
thresholds.some(
|
|
277
|
+
(threshold) =>
|
|
278
|
+
typeof threshold !== "number" ||
|
|
279
|
+
!Number.isFinite(threshold) ||
|
|
280
|
+
threshold < 0 ||
|
|
281
|
+
threshold > 1,
|
|
282
|
+
)
|
|
283
|
+
) {
|
|
284
|
+
throw new TypeError(
|
|
285
|
+
`flamefront route ${location} hydration threshold must contain numbers from 0 through 1.`,
|
|
286
|
+
)
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function validateGeneratedHydration(
|
|
291
|
+
hydration: Record<string, unknown>,
|
|
292
|
+
location: string,
|
|
293
|
+
): void {
|
|
294
|
+
switch (hydration.when) {
|
|
295
|
+
case "idle":
|
|
296
|
+
assertOnlyKeys(hydration, ["when", "timeout"], location)
|
|
297
|
+
if (
|
|
298
|
+
hydration.timeout !== undefined &&
|
|
299
|
+
(typeof hydration.timeout !== "number" ||
|
|
300
|
+
!Number.isFinite(hydration.timeout) ||
|
|
301
|
+
hydration.timeout < 0)
|
|
302
|
+
) {
|
|
303
|
+
throw new TypeError(
|
|
304
|
+
`flamefront route ${location} hydration timeout must be a non-negative number.`,
|
|
305
|
+
)
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
return
|
|
309
|
+
case "visible":
|
|
310
|
+
assertOnlyKeys(hydration, ["when", "rootMargin", "threshold"], location)
|
|
311
|
+
if (hydration.rootMargin !== undefined) {
|
|
312
|
+
assertString(
|
|
313
|
+
hydration.rootMargin,
|
|
314
|
+
`route ${location} hydration rootMargin`,
|
|
315
|
+
)
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
if (hydration.threshold !== undefined) {
|
|
319
|
+
assertThreshold(hydration.threshold, location)
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
return
|
|
323
|
+
case "interaction": {
|
|
324
|
+
assertOnlyKeys(hydration, ["when", "events"], location)
|
|
325
|
+
if (hydration.events === undefined) {
|
|
326
|
+
return
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const events = Array.isArray(hydration.events)
|
|
330
|
+
? hydration.events
|
|
331
|
+
: [hydration.events]
|
|
332
|
+
|
|
333
|
+
if (
|
|
334
|
+
events.length === 0 ||
|
|
335
|
+
events.some(
|
|
336
|
+
(event) => typeof event !== "string" || !interactionEvents.has(event),
|
|
337
|
+
)
|
|
338
|
+
) {
|
|
339
|
+
throw new TypeError(
|
|
340
|
+
`flamefront route ${location} hydration events must be supported Octane interaction events.`,
|
|
341
|
+
)
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
return
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
case "media":
|
|
348
|
+
assertOnlyKeys(hydration, ["when", "query"], location)
|
|
349
|
+
assertString(hydration.query, `route ${location} hydration query`)
|
|
350
|
+
return
|
|
351
|
+
default:
|
|
352
|
+
throw new TypeError(
|
|
353
|
+
`flamefront route ${location} hydration trigger must be 'idle', 'visible', 'interaction', or 'media'.`,
|
|
354
|
+
)
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function validateHydration(
|
|
359
|
+
routeDefinition: RouteDefinition,
|
|
360
|
+
location: string,
|
|
361
|
+
): void {
|
|
362
|
+
const { hydration, render } = routeDefinition
|
|
363
|
+
|
|
364
|
+
if (hydration === undefined) {
|
|
365
|
+
return
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
if (
|
|
369
|
+
typeof hydration === "object" &&
|
|
370
|
+
hydration !== null &&
|
|
371
|
+
!Array.isArray(hydration)
|
|
372
|
+
) {
|
|
373
|
+
validateGeneratedHydration(
|
|
374
|
+
hydration as unknown as Record<string, unknown>,
|
|
375
|
+
location,
|
|
376
|
+
)
|
|
377
|
+
if (render !== "server" && render !== "static") {
|
|
378
|
+
throw new TypeError(
|
|
379
|
+
`flamefront route ${location} generated hydration requires render: 'server' or 'static'.`,
|
|
380
|
+
)
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
return
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
if (!hydrationModes.has(hydration)) {
|
|
387
|
+
throw new TypeError(
|
|
388
|
+
`flamefront route ${location} hydration must be 'full', 'deferred', 'none', or a trigger object.`,
|
|
389
|
+
)
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
if (render === "client" && hydration !== "full") {
|
|
393
|
+
throw new TypeError(
|
|
394
|
+
`flamefront route ${location} client hydration can only be 'full'.`,
|
|
395
|
+
)
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function freezeHydration(
|
|
400
|
+
hydration: HydrationMode | undefined,
|
|
401
|
+
): HydrationMode | undefined {
|
|
402
|
+
if (typeof hydration !== "object" || hydration === null) {
|
|
403
|
+
return hydration
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
if (hydration.when === "visible" && Array.isArray(hydration.threshold)) {
|
|
407
|
+
return Object.freeze({
|
|
408
|
+
...hydration,
|
|
409
|
+
threshold: Object.freeze([...hydration.threshold]),
|
|
410
|
+
})
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
if (hydration.when === "interaction" && Array.isArray(hydration.events)) {
|
|
414
|
+
return Object.freeze({
|
|
415
|
+
...hydration,
|
|
416
|
+
events: Object.freeze([...hydration.events]),
|
|
417
|
+
})
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
return Object.freeze({ ...hydration })
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function validateRoute(
|
|
424
|
+
routeDefinition: RouteDefinition,
|
|
425
|
+
location: string,
|
|
426
|
+
): void {
|
|
427
|
+
if (!routeDefinition || typeof routeDefinition !== "object") {
|
|
428
|
+
throw new TypeError(`flamefront route ${location} must be an object.`)
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
assertString(routeDefinition.path, `route ${location} path`)
|
|
432
|
+
if (!routeDefinition.path.startsWith("/")) {
|
|
433
|
+
throw new TypeError(
|
|
434
|
+
`flamefront route ${location} path must start with '/'.`,
|
|
435
|
+
)
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
assertString(routeDefinition.entry, `route ${location} entry`)
|
|
439
|
+
|
|
440
|
+
if (!renderModes.has(routeDefinition.render)) {
|
|
441
|
+
throw new TypeError(
|
|
442
|
+
`flamefront route ${location} render must be 'client', 'server', or 'static'.`,
|
|
443
|
+
)
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
validateHydration(routeDefinition, location)
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/** Define one explicit route without relying on a filesystem convention. */
|
|
450
|
+
export function route(
|
|
451
|
+
path: string,
|
|
452
|
+
entry: string,
|
|
453
|
+
options: RouteOptions = {},
|
|
454
|
+
): RouteDefinition {
|
|
455
|
+
const definition: RouteDefinition = {
|
|
456
|
+
path,
|
|
457
|
+
entry,
|
|
458
|
+
...options,
|
|
459
|
+
hydration: freezeHydration(options.hydration),
|
|
460
|
+
render: options.render ?? "server",
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
validateRoute(definition, "1")
|
|
464
|
+
return Object.freeze(definition)
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/** Group routes beneath a shared pathless layout without adding a URL segment. */
|
|
468
|
+
export function layout<const Children extends readonly RouteConfig[]>(
|
|
469
|
+
entry: string,
|
|
470
|
+
children: Children,
|
|
471
|
+
): LayoutDefinition<Children> {
|
|
472
|
+
assertString(entry, "layout entry")
|
|
473
|
+
if (!Array.isArray(children)) {
|
|
474
|
+
throw new TypeError("flamefront layout children must be an array.")
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
return Object.freeze({
|
|
478
|
+
kind: "layout" as const,
|
|
479
|
+
entry,
|
|
480
|
+
children: Object.freeze([...children]) as unknown as Children,
|
|
481
|
+
})
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function isLayoutDefinition(config: RouteConfig): config is LayoutDefinition {
|
|
485
|
+
return "kind" in config && config.kind === "layout"
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function normalizeRouteTree(
|
|
489
|
+
configs: readonly RouteConfig[],
|
|
490
|
+
seenPaths: Set<string>,
|
|
491
|
+
location = "",
|
|
492
|
+
): { tree: readonly RouteConfig[]; routes: readonly RouteDefinition[] } {
|
|
493
|
+
const routes: RouteDefinition[] = []
|
|
494
|
+
const tree = configs.map((config, index): RouteConfig => {
|
|
495
|
+
const configLocation = location
|
|
496
|
+
? `${location}.${index + 1}`
|
|
497
|
+
: `${index + 1}`
|
|
498
|
+
|
|
499
|
+
if (!config || typeof config !== "object") {
|
|
500
|
+
throw new TypeError(
|
|
501
|
+
`flamefront route ${configLocation} must be an object.`,
|
|
502
|
+
)
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
if (isLayoutDefinition(config)) {
|
|
506
|
+
assertString(config.entry, `layout ${configLocation} entry`)
|
|
507
|
+
if (!Array.isArray(config.children)) {
|
|
508
|
+
throw new TypeError(
|
|
509
|
+
`flamefront layout ${configLocation} children must be an array.`,
|
|
510
|
+
)
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
const normalized = normalizeRouteTree(
|
|
514
|
+
config.children,
|
|
515
|
+
seenPaths,
|
|
516
|
+
configLocation,
|
|
517
|
+
)
|
|
518
|
+
|
|
519
|
+
routes.push(...normalized.routes)
|
|
520
|
+
return Object.freeze({
|
|
521
|
+
kind: "layout" as const,
|
|
522
|
+
entry: config.entry,
|
|
523
|
+
children: normalized.tree,
|
|
524
|
+
})
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
validateRoute(config, configLocation)
|
|
528
|
+
if (seenPaths.has(config.path)) {
|
|
529
|
+
throw new TypeError(`flamefront route path is duplicated: ${config.path}`)
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
seenPaths.add(config.path)
|
|
533
|
+
const normalizedRoute = Object.freeze({
|
|
534
|
+
...config,
|
|
535
|
+
hydration: freezeHydration(config.hydration),
|
|
536
|
+
})
|
|
537
|
+
|
|
538
|
+
routes.push(normalizedRoute)
|
|
539
|
+
return normalizedRoute
|
|
540
|
+
})
|
|
541
|
+
|
|
542
|
+
return { tree: Object.freeze(tree), routes: Object.freeze(routes) }
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
function createRouteMatcher<T extends RouteDefinition>(
|
|
546
|
+
routes: readonly T[],
|
|
547
|
+
render?: RenderMode,
|
|
548
|
+
): MultiMatcher<T> {
|
|
549
|
+
const matcher = createMultiMatcher<T>()
|
|
550
|
+
|
|
551
|
+
for (const routeDefinition of routes) {
|
|
552
|
+
if (render === undefined || routeDefinition.render === render) {
|
|
553
|
+
matcher.add(routeDefinition.path, routeDefinition)
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
return matcher
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function matchRoutes<T extends RouteDefinition>(
|
|
561
|
+
routes: readonly T[],
|
|
562
|
+
url: string | URL,
|
|
563
|
+
options: MatchRouteOptions = {},
|
|
564
|
+
basename = "/",
|
|
565
|
+
): Match<string, T> | null {
|
|
566
|
+
let matchers = matcherCache.get(routes)
|
|
567
|
+
|
|
568
|
+
if (!matchers) {
|
|
569
|
+
matchers = new Map()
|
|
570
|
+
matcherCache.set(routes, matchers)
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
let matcher = matchers.get(options.render) as MultiMatcher<T> | undefined
|
|
574
|
+
|
|
575
|
+
if (!matcher) {
|
|
576
|
+
matcher = createRouteMatcher(routes, options.render)
|
|
577
|
+
matchers.set(options.render, matcher as MultiMatcher<RouteDefinition>)
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
const normalizedUrl = stripFlamefrontProtocolParams(url)
|
|
581
|
+
const appPathname = stripBasename(normalizedUrl.pathname, basename)
|
|
582
|
+
|
|
583
|
+
if (appPathname === null) {
|
|
584
|
+
return null
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
normalizedUrl.pathname = appPathname
|
|
588
|
+
if (normalizedUrl.pathname.length > 1) {
|
|
589
|
+
normalizedUrl.pathname = normalizedUrl.pathname.replace(/\/+$/, "")
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
return matcher.match(normalizedUrl)
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/** Normalize and validate the application's explicit route graph. */
|
|
596
|
+
export function defineApp<
|
|
597
|
+
const T extends {
|
|
598
|
+
readonly shell: string
|
|
599
|
+
readonly routes: readonly RouteConfig[]
|
|
600
|
+
readonly routing?: RoutingOptions
|
|
601
|
+
},
|
|
602
|
+
>(options: T): Omit<T, "routes" | "routing"> & AppDefinition {
|
|
603
|
+
if (
|
|
604
|
+
!options ||
|
|
605
|
+
typeof options !== "object" ||
|
|
606
|
+
!Array.isArray(options.routes)
|
|
607
|
+
) {
|
|
608
|
+
throw new TypeError("flamefront defineApp() requires a routes array.")
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
assertString(options.shell, "app shell entry")
|
|
612
|
+
|
|
613
|
+
const normalized = normalizeRouteTree(options.routes, new Set())
|
|
614
|
+
const frozenRoutes = normalized.routes
|
|
615
|
+
const routing = normalizeRoutingOptions(options.routing)
|
|
616
|
+
const routeDataClient = createRouteDataClient(routing)
|
|
617
|
+
const load = <Data = unknown>(
|
|
618
|
+
url: string | URL,
|
|
619
|
+
loadOptions: LoadRouteOptions = {},
|
|
620
|
+
) => {
|
|
621
|
+
const match = matchRoutes(frozenRoutes, url, {}, routing.basename)
|
|
622
|
+
const source = match?.data.render === "static" ? "static" : "live"
|
|
623
|
+
|
|
624
|
+
return routeDataClient.load<Data>(url, source, loadOptions)
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
const app = Object.freeze({
|
|
628
|
+
...options,
|
|
629
|
+
routes: frozenRoutes,
|
|
630
|
+
routeTree: normalized.tree,
|
|
631
|
+
routing,
|
|
632
|
+
match: (url: string | URL, matchOptions?: MatchRouteOptions) =>
|
|
633
|
+
matchRoutes(frozenRoutes, url, matchOptions, routing.basename),
|
|
634
|
+
load,
|
|
635
|
+
prefetch: async (url: string | URL, loadOptions?: LoadRouteOptions) => {
|
|
636
|
+
await load(url, loadOptions)
|
|
637
|
+
},
|
|
638
|
+
}) as Omit<T, "routes" | "routing"> & AppDefinition
|
|
639
|
+
|
|
640
|
+
matcherCache.set(
|
|
641
|
+
frozenRoutes,
|
|
642
|
+
new Map([[undefined, createRouteMatcher(frozenRoutes)]]) as Map<
|
|
643
|
+
RenderMode | undefined,
|
|
644
|
+
MultiMatcher<RouteDefinition>
|
|
645
|
+
>,
|
|
646
|
+
)
|
|
647
|
+
return app
|
|
648
|
+
}
|