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/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
+ }