flamefront 0.0.0 → 0.1.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 +58 -0
- package/bin/ff-loader.mjs +18 -0
- package/bin/ff.js +6 -0
- package/package.json +84 -8
- package/src/babel.ts +22 -0
- package/src/cli.ts +98 -0
- package/src/entry.ts +49 -0
- package/src/fetch.ts +280 -0
- package/src/fragment-client.ts +271 -0
- package/src/fragment-hydration-client.tsx +81 -0
- package/src/fragment-protocol.ts +39 -0
- package/src/fragment.tsx +656 -0
- package/src/glob.ts +294 -0
- package/src/identifier-prefix.ts +5 -0
- package/src/index.ts +1168 -0
- package/src/lifecycle.ts +487 -0
- package/src/octane-client-core.ts +141 -0
- package/src/octane-client.ts +48 -0
- package/src/octane-compiler.d.ts +19 -0
- package/src/octane-default-renderer.tsx +124 -0
- package/src/octane-router-document.ts +22 -0
- package/src/octane.tsx +661 -0
- package/src/output.ts +48 -0
- package/src/remix-route-data.ts +76 -0
- package/src/remix-router-core.ts +106 -0
- package/src/remix-router.ts +117 -0
- package/src/remove-exports.ts +148 -0
- package/src/route-data-client.ts +234 -0
- package/src/route-prefetch.ts +79 -0
- package/src/server.ts +338 -0
- package/src/srvx.ts +174 -0
- package/src/static-fragment-artifacts.ts +86 -0
- package/src/typegen.ts +207 -0
- package/src/virtual-remix-routes.d.ts +35 -0
- package/src/vite.ts +1030 -0
- package/readme.md +0 -1
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createRouteDataClient,
|
|
3
|
+
type RouteDataLoadOptions,
|
|
4
|
+
type RouteDataRoutingOptions,
|
|
5
|
+
} from "./route-data-client.ts"
|
|
6
|
+
import type { RouteDataForPath } from "./index.ts"
|
|
7
|
+
import {
|
|
8
|
+
loadRouteFragment as loadFragmentArtifact,
|
|
9
|
+
type RouteFragmentCachePolicy,
|
|
10
|
+
type RouteFragmentLoadOptions,
|
|
11
|
+
type RouteFragmentRoutingOptions,
|
|
12
|
+
} from "./fragment-client.ts"
|
|
13
|
+
|
|
14
|
+
export {
|
|
15
|
+
createRouteDataClient,
|
|
16
|
+
type RouteDataClient,
|
|
17
|
+
type RouteDataLoadOptions,
|
|
18
|
+
type RouteDataRoutingOptions,
|
|
19
|
+
type RouteDataSource,
|
|
20
|
+
} from "./route-data-client.ts"
|
|
21
|
+
|
|
22
|
+
export interface ClientLoaderArgs<_Path extends string = string> {
|
|
23
|
+
readonly request: Request
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface RouteDataOptions {
|
|
27
|
+
readonly basename?: string
|
|
28
|
+
readonly dataPath?: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function client(options: RouteDataOptions) {
|
|
32
|
+
return createRouteDataClient(options satisfies RouteDataRoutingOptions)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Load route data through Flamefront's server endpoint during browser navigation. */
|
|
36
|
+
export async function loadRouteData<const Path extends string = string>(
|
|
37
|
+
{ request }: ClientLoaderArgs<Path>,
|
|
38
|
+
options: RouteDataOptions = {},
|
|
39
|
+
): Promise<RouteDataForPath<Path>> {
|
|
40
|
+
const loadOptions: RouteDataLoadOptions = { signal: request.signal }
|
|
41
|
+
|
|
42
|
+
return client(options).load(request.url, "live", loadOptions) as Promise<
|
|
43
|
+
RouteDataForPath<Path>
|
|
44
|
+
>
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Load a build-time static route artifact during browser navigation. */
|
|
48
|
+
export async function loadStaticRouteData<const Path extends string = string>(
|
|
49
|
+
{ request }: ClientLoaderArgs<Path>,
|
|
50
|
+
options: RouteDataOptions = {},
|
|
51
|
+
): Promise<RouteDataForPath<Path>> {
|
|
52
|
+
const loadOptions: RouteDataLoadOptions = { signal: request.signal }
|
|
53
|
+
|
|
54
|
+
return client(options).load(request.url, "static", loadOptions) as Promise<
|
|
55
|
+
RouteDataForPath<Path>
|
|
56
|
+
>
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Load a route fragment and expose only its route data to the router. */
|
|
60
|
+
export async function loadRouteFragment<const Path extends string = string>(
|
|
61
|
+
{ request }: ClientLoaderArgs<Path>,
|
|
62
|
+
options: RouteDataOptions = {},
|
|
63
|
+
policy: RouteFragmentCachePolicy = "static",
|
|
64
|
+
): Promise<RouteDataForPath<Path>> {
|
|
65
|
+
const fragmentOptions: RouteFragmentLoadOptions = {
|
|
66
|
+
policy,
|
|
67
|
+
signal: request.signal,
|
|
68
|
+
}
|
|
69
|
+
const artifact = await loadFragmentArtifact(
|
|
70
|
+
request.url,
|
|
71
|
+
options satisfies RouteFragmentRoutingOptions,
|
|
72
|
+
fragmentOptions,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
return artifact.routeData as RouteDataForPath<Path>
|
|
76
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
export interface RemixStaticContext {
|
|
2
|
+
readonly loaderData: Record<string, unknown>
|
|
3
|
+
readonly actionData: Record<string, unknown> | null
|
|
4
|
+
readonly errors: Record<string, unknown> | null
|
|
5
|
+
readonly statusCode?: number
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface RemixRouterRuntime<
|
|
9
|
+
Route = unknown,
|
|
10
|
+
ClientRouter = unknown,
|
|
11
|
+
ServerRouter = unknown,
|
|
12
|
+
Context extends RemixStaticContext = RemixStaticContext,
|
|
13
|
+
ClientOptions = unknown,
|
|
14
|
+
> {
|
|
15
|
+
createBrowserRouter(routes: Route[], options?: ClientOptions): ClientRouter
|
|
16
|
+
createStaticHandler(
|
|
17
|
+
routes: Route[],
|
|
18
|
+
options?: { basename?: string },
|
|
19
|
+
): {
|
|
20
|
+
readonly dataRoutes: Route[]
|
|
21
|
+
query(
|
|
22
|
+
request: Request,
|
|
23
|
+
options?: { requestContext?: unknown },
|
|
24
|
+
): Promise<Response | Context>
|
|
25
|
+
}
|
|
26
|
+
createStaticRouter(routes: Route[], context: Context): ServerRouter
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface ServerRouterResult<
|
|
30
|
+
Router = unknown,
|
|
31
|
+
Context = RemixStaticContext,
|
|
32
|
+
> {
|
|
33
|
+
readonly context: Context
|
|
34
|
+
readonly hydrationData: {
|
|
35
|
+
readonly loaderData: Record<string, unknown>
|
|
36
|
+
readonly actionData: Record<string, unknown> | null
|
|
37
|
+
readonly errors: Record<string, unknown> | null
|
|
38
|
+
}
|
|
39
|
+
readonly router: Router
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface ServerRouterOptions {
|
|
43
|
+
readonly basename?: string
|
|
44
|
+
readonly requestContext?: unknown
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface RemixRouterAdapterOptions {
|
|
48
|
+
readonly basename?: string
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function createRemixRouterAdapter<
|
|
52
|
+
Route,
|
|
53
|
+
ClientRouter,
|
|
54
|
+
ServerRouter,
|
|
55
|
+
Context extends RemixStaticContext,
|
|
56
|
+
ClientOptions,
|
|
57
|
+
>(
|
|
58
|
+
routeGraph: Route[],
|
|
59
|
+
runtime: RemixRouterRuntime<
|
|
60
|
+
Route,
|
|
61
|
+
ClientRouter,
|
|
62
|
+
ServerRouter,
|
|
63
|
+
Context,
|
|
64
|
+
ClientOptions
|
|
65
|
+
>,
|
|
66
|
+
defaults: RemixRouterAdapterOptions = {},
|
|
67
|
+
) {
|
|
68
|
+
return {
|
|
69
|
+
routes: routeGraph,
|
|
70
|
+
createClientRouter(options?: ClientOptions) {
|
|
71
|
+
if (defaults.basename === undefined) {
|
|
72
|
+
return runtime.createBrowserRouter(routeGraph, options)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return runtime.createBrowserRouter(routeGraph, {
|
|
76
|
+
...(options && typeof options === "object" ? options : {}),
|
|
77
|
+
basename: defaults.basename,
|
|
78
|
+
} as ClientOptions)
|
|
79
|
+
},
|
|
80
|
+
async createServerRouter(
|
|
81
|
+
request: Request,
|
|
82
|
+
options: ServerRouterOptions = {},
|
|
83
|
+
): Promise<Response | ServerRouterResult<ServerRouter, Context>> {
|
|
84
|
+
const handler = runtime.createStaticHandler(routeGraph, {
|
|
85
|
+
basename: options.basename ?? defaults.basename,
|
|
86
|
+
})
|
|
87
|
+
const context = await handler.query(request, {
|
|
88
|
+
requestContext: options.requestContext,
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
if (context instanceof Response) {
|
|
92
|
+
return context
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
context,
|
|
97
|
+
hydrationData: {
|
|
98
|
+
loaderData: context.loaderData,
|
|
99
|
+
actionData: context.actionData,
|
|
100
|
+
errors: context.errors,
|
|
101
|
+
},
|
|
102
|
+
router: runtime.createStaticRouter(handler.dataRoutes, context),
|
|
103
|
+
}
|
|
104
|
+
},
|
|
105
|
+
}
|
|
106
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createRemixRouterAdapter,
|
|
3
|
+
type ServerRouterOptions,
|
|
4
|
+
type ServerRouterResult,
|
|
5
|
+
} from "./remix-router-core.ts"
|
|
6
|
+
import {
|
|
7
|
+
createBrowserRouter,
|
|
8
|
+
createStaticHandler,
|
|
9
|
+
createStaticRouter,
|
|
10
|
+
} from "@octanejs/remix-router"
|
|
11
|
+
import type { HydrationState } from "@octanejs/remix-router"
|
|
12
|
+
import type {
|
|
13
|
+
AppDefinition,
|
|
14
|
+
LoadRouteOptions,
|
|
15
|
+
RouteDefinition,
|
|
16
|
+
} from "./index.ts"
|
|
17
|
+
import {
|
|
18
|
+
createRoutePrefetchCallback,
|
|
19
|
+
prefetchRouteResources,
|
|
20
|
+
type RoutePrefetchCallback,
|
|
21
|
+
type RoutePrefetchResources,
|
|
22
|
+
} from "./route-prefetch.ts"
|
|
23
|
+
import { prefetchRouteFragment } from "./fragment-client.ts"
|
|
24
|
+
import {
|
|
25
|
+
preloadRoute as preloadGeneratedRoute,
|
|
26
|
+
RouterDocument,
|
|
27
|
+
routeMetadata,
|
|
28
|
+
routes,
|
|
29
|
+
routing,
|
|
30
|
+
} from "virtual:flamefront/remix-routes"
|
|
31
|
+
|
|
32
|
+
export { RouterDocument, routeMetadata, routes, routing }
|
|
33
|
+
export { createRemixRouterAdapter }
|
|
34
|
+
export type { ServerRouterOptions, ServerRouterResult }
|
|
35
|
+
export type {
|
|
36
|
+
RoutePrefetchCallback,
|
|
37
|
+
RoutePrefetchResources,
|
|
38
|
+
} from "./route-prefetch.ts"
|
|
39
|
+
|
|
40
|
+
export const staticRouterHydrationScriptId =
|
|
41
|
+
"flamefront-static-router-hydration"
|
|
42
|
+
|
|
43
|
+
/** Read and remove the hydration payload emitted by the server document adapter. */
|
|
44
|
+
export function consumeStaticRouterHydrationData(): HydrationState | undefined {
|
|
45
|
+
const data = (
|
|
46
|
+
window as typeof window & {
|
|
47
|
+
__staticRouterHydrationData?: unknown
|
|
48
|
+
}
|
|
49
|
+
).__staticRouterHydrationData
|
|
50
|
+
|
|
51
|
+
document.getElementById(staticRouterHydrationScriptId)?.remove()
|
|
52
|
+
return data as HydrationState | undefined
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const adapter = createRemixRouterAdapter(
|
|
56
|
+
routes,
|
|
57
|
+
{
|
|
58
|
+
createBrowserRouter,
|
|
59
|
+
createStaticHandler,
|
|
60
|
+
createStaticRouter,
|
|
61
|
+
},
|
|
62
|
+
routing,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
export const createClientRouter = adapter.createClientRouter
|
|
66
|
+
export const createServerRouter = adapter.createServerRouter
|
|
67
|
+
|
|
68
|
+
function withDefaultPrefetchResources<
|
|
69
|
+
Route extends RouteDefinition = RouteDefinition,
|
|
70
|
+
>(
|
|
71
|
+
resources: RoutePrefetchResources<Route> = {},
|
|
72
|
+
): RoutePrefetchResources<Route> {
|
|
73
|
+
return {
|
|
74
|
+
...resources,
|
|
75
|
+
routeFragment:
|
|
76
|
+
resources.routeFragment ??
|
|
77
|
+
((url, route, options) => {
|
|
78
|
+
if (route.render === "client") {
|
|
79
|
+
return
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return prefetchRouteFragment(url, route.render, routing, options)
|
|
83
|
+
}),
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Prefetch route resources selected by the matched Flamefront route. */
|
|
88
|
+
export async function prefetchRoute<
|
|
89
|
+
Route extends RouteDefinition = RouteDefinition,
|
|
90
|
+
>(
|
|
91
|
+
app: Pick<AppDefinition<Route>, "match" | "prefetch">,
|
|
92
|
+
url: string | URL,
|
|
93
|
+
options?: LoadRouteOptions,
|
|
94
|
+
resources?: RoutePrefetchResources<Route>,
|
|
95
|
+
): Promise<void> {
|
|
96
|
+
await prefetchRouteResources(
|
|
97
|
+
app,
|
|
98
|
+
preloadGeneratedRoute,
|
|
99
|
+
url,
|
|
100
|
+
options,
|
|
101
|
+
withDefaultPrefetchResources(resources),
|
|
102
|
+
)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Create the callback used by `createClientRouter({ prefetch })`. */
|
|
106
|
+
export function createRoutePrefetcher<
|
|
107
|
+
Route extends RouteDefinition = RouteDefinition,
|
|
108
|
+
>(
|
|
109
|
+
app: Pick<AppDefinition<Route>, "match" | "prefetch">,
|
|
110
|
+
resources?: RoutePrefetchResources<Route>,
|
|
111
|
+
): RoutePrefetchCallback {
|
|
112
|
+
return createRoutePrefetchCallback(
|
|
113
|
+
app,
|
|
114
|
+
preloadGeneratedRoute,
|
|
115
|
+
withDefaultPrefetchResources(resources),
|
|
116
|
+
)
|
|
117
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { getBindingIdentifiers } from "@babel/types"
|
|
2
|
+
import {
|
|
3
|
+
deadCodeElimination,
|
|
4
|
+
findReferencedIdentifiers,
|
|
5
|
+
} from "babel-dead-code-elimination"
|
|
6
|
+
import type { Babel, NodePath, ParseResult } from "./babel.ts"
|
|
7
|
+
import { traverse } from "./babel.ts"
|
|
8
|
+
|
|
9
|
+
function exportedIdentifierName(
|
|
10
|
+
exported: Babel.Identifier | Babel.StringLiteral,
|
|
11
|
+
): string | undefined {
|
|
12
|
+
return exported.type === "Identifier" ? exported.name : exported.value
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function assertRemovableBinding(
|
|
16
|
+
id: Babel.VariableDeclarator["id"],
|
|
17
|
+
exportsToRemove: ReadonlySet<string>,
|
|
18
|
+
): void {
|
|
19
|
+
if (id.type === "Identifier") {
|
|
20
|
+
return
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const bindingNames = new Set(Object.keys(getBindingIdentifiers(id)))
|
|
24
|
+
|
|
25
|
+
for (const exportName of exportsToRemove) {
|
|
26
|
+
if (bindingNames.has(exportName)) {
|
|
27
|
+
throw new SyntaxError(
|
|
28
|
+
`Flamefront cannot safely remove destructured route export "${exportName}".`,
|
|
29
|
+
)
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Remove selected exports and declarations that became unreachable as a result.
|
|
36
|
+
* This follows React Router's client-route transform: the pre-transform reference
|
|
37
|
+
* set constrains DCE so unrelated, already-unused authored code is preserved.
|
|
38
|
+
*/
|
|
39
|
+
export function removeExports(
|
|
40
|
+
ast: ParseResult<Babel.File>,
|
|
41
|
+
exportNames: readonly string[],
|
|
42
|
+
): boolean {
|
|
43
|
+
const exportsToRemove = new Set(exportNames)
|
|
44
|
+
const referencedBeforeRemoval = findReferencedIdentifiers(ast)
|
|
45
|
+
const pathsToRemove = new Set<NodePath<Babel.Node>>()
|
|
46
|
+
const removedLocalNames = new Set<string>()
|
|
47
|
+
let changed = false
|
|
48
|
+
|
|
49
|
+
traverse(ast, {
|
|
50
|
+
ExportNamedDeclaration(path) {
|
|
51
|
+
if (path.node.specifiers.length > 0) {
|
|
52
|
+
path.node.specifiers = path.node.specifiers.filter((specifier) => {
|
|
53
|
+
if (specifier.type !== "ExportSpecifier") {
|
|
54
|
+
return true
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const exportName = exportedIdentifierName(specifier.exported)
|
|
58
|
+
|
|
59
|
+
if (!exportName || !exportsToRemove.has(exportName)) {
|
|
60
|
+
return true
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
changed = true
|
|
64
|
+
if (specifier.local.type === "Identifier") {
|
|
65
|
+
removedLocalNames.add(specifier.local.name)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return false
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
if (path.node.specifiers.length === 0) {
|
|
72
|
+
pathsToRemove.add(path)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const declaration = path.node.declaration
|
|
77
|
+
|
|
78
|
+
if (declaration?.type === "VariableDeclaration") {
|
|
79
|
+
declaration.declarations = declaration.declarations.filter(
|
|
80
|
+
(declarator) => {
|
|
81
|
+
assertRemovableBinding(declarator.id, exportsToRemove)
|
|
82
|
+
if (
|
|
83
|
+
declarator.id.type !== "Identifier" ||
|
|
84
|
+
!exportsToRemove.has(declarator.id.name)
|
|
85
|
+
) {
|
|
86
|
+
return true
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
changed = true
|
|
90
|
+
removedLocalNames.add(declarator.id.name)
|
|
91
|
+
return false
|
|
92
|
+
},
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
if (declaration.declarations.length === 0) {
|
|
96
|
+
pathsToRemove.add(path)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (
|
|
101
|
+
(declaration?.type === "FunctionDeclaration" ||
|
|
102
|
+
declaration?.type === "ClassDeclaration") &&
|
|
103
|
+
declaration.id &&
|
|
104
|
+
exportsToRemove.has(declaration.id.name)
|
|
105
|
+
) {
|
|
106
|
+
changed = true
|
|
107
|
+
removedLocalNames.add(declaration.id.name)
|
|
108
|
+
pathsToRemove.add(path)
|
|
109
|
+
}
|
|
110
|
+
},
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
if (!changed) {
|
|
114
|
+
return false
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Remove metadata assignments such as `loader.cache = true` with the loader.
|
|
118
|
+
traverse(ast, {
|
|
119
|
+
ExpressionStatement(path) {
|
|
120
|
+
if (!path.parentPath.isProgram()) {
|
|
121
|
+
return
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const expression = path.node.expression
|
|
125
|
+
|
|
126
|
+
if (expression.type !== "AssignmentExpression") {
|
|
127
|
+
return
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const target = expression.left
|
|
131
|
+
|
|
132
|
+
if (
|
|
133
|
+
target.type === "MemberExpression" &&
|
|
134
|
+
target.object.type === "Identifier" &&
|
|
135
|
+
removedLocalNames.has(target.object.name)
|
|
136
|
+
) {
|
|
137
|
+
pathsToRemove.add(path)
|
|
138
|
+
}
|
|
139
|
+
},
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
for (const path of pathsToRemove) {
|
|
143
|
+
path.remove()
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
deadCodeElimination(ast, referencedBeforeRemoval)
|
|
147
|
+
return true
|
|
148
|
+
}
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import { stripFlamefrontProtocolParams } from "./fragment-protocol.ts"
|
|
2
|
+
import type { RouteDataForPath } from "./index.ts"
|
|
3
|
+
|
|
4
|
+
export type RouteDataSource = "live" | "static"
|
|
5
|
+
|
|
6
|
+
export interface RouteDataRoutingOptions {
|
|
7
|
+
readonly basename?: string
|
|
8
|
+
readonly dataPath?: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface RouteDataLoadOptions {
|
|
12
|
+
readonly signal?: AbortSignal
|
|
13
|
+
readonly reload?: boolean
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface RouteDataClient {
|
|
17
|
+
readonly load: {
|
|
18
|
+
<const Url extends string>(
|
|
19
|
+
url: Url,
|
|
20
|
+
source: RouteDataSource,
|
|
21
|
+
options?: RouteDataLoadOptions,
|
|
22
|
+
): Promise<RouteDataForPath<Url>>
|
|
23
|
+
<Data = unknown>(
|
|
24
|
+
url: string | URL,
|
|
25
|
+
source: RouteDataSource,
|
|
26
|
+
options?: RouteDataLoadOptions,
|
|
27
|
+
): Promise<Data>
|
|
28
|
+
}
|
|
29
|
+
readonly prefetch: {
|
|
30
|
+
(
|
|
31
|
+
url: string | URL,
|
|
32
|
+
source: RouteDataSource,
|
|
33
|
+
options?: RouteDataLoadOptions,
|
|
34
|
+
): Promise<void>
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const defaultRouting = Object.freeze({
|
|
39
|
+
basename: "/",
|
|
40
|
+
dataPath: "/__flamefront/data",
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Browser route-data clients share one cache per routing configuration so
|
|
45
|
+
* app.prefetch() and generated router loaders can consume the same promise.
|
|
46
|
+
* Server callers receive an isolated client to avoid a process-wide browser
|
|
47
|
+
* cache; server rendering invokes route loaders directly instead.
|
|
48
|
+
*/
|
|
49
|
+
const browserClients = new Map<string, RouteDataClient>()
|
|
50
|
+
|
|
51
|
+
function normalizePath(value: string | undefined, fallback: string): string {
|
|
52
|
+
return value?.replace(/\/+$/, "") || fallback
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function normalizeRouting(options: RouteDataRoutingOptions): {
|
|
56
|
+
readonly basename: string
|
|
57
|
+
readonly dataPath: string
|
|
58
|
+
} {
|
|
59
|
+
return {
|
|
60
|
+
basename: normalizePath(options.basename, defaultRouting.basename),
|
|
61
|
+
dataPath: normalizePath(options.dataPath, defaultRouting.dataPath),
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function resolveRouteUrl(url: string | URL): URL {
|
|
66
|
+
const browserOrigin =
|
|
67
|
+
typeof location === "undefined" ? undefined : location.origin
|
|
68
|
+
|
|
69
|
+
if (!browserOrigin && typeof url === "string" && !URL.canParse(url)) {
|
|
70
|
+
throw new TypeError(
|
|
71
|
+
"flamefront route data requires an absolute URL outside the browser.",
|
|
72
|
+
)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return new URL(url, browserOrigin)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function stripBasename(pathname: string, basename: string): string | null {
|
|
79
|
+
if (basename === "/") {
|
|
80
|
+
return pathname
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (pathname === basename) {
|
|
84
|
+
return "/"
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (!pathname.startsWith(`${basename}/`)) {
|
|
88
|
+
return null
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return pathname.slice(basename.length) || "/"
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function joinBasename(basename: string, pathname: string): string {
|
|
95
|
+
if (basename === "/") {
|
|
96
|
+
return pathname || "/"
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (pathname === "/") {
|
|
100
|
+
return basename
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return `${basename}${pathname.startsWith("/") ? pathname : `/${pathname}`}`
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function staticRouteDataPath(routeUrl: URL, basename: string): string {
|
|
107
|
+
const appPathname =
|
|
108
|
+
stripBasename(routeUrl.pathname, basename) ?? routeUrl.pathname
|
|
109
|
+
const pathname = joinBasename(basename, appPathname).replace(/\/+$/, "")
|
|
110
|
+
|
|
111
|
+
return pathname === "" ? "/index.data.json" : `${pathname}/index.data.json`
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function cacheKey(routeUrl: URL, source: RouteDataSource): string {
|
|
115
|
+
return `${source}:${routeUrl.origin}${routeUrl.pathname}${routeUrl.search}`
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function abortable<Data>(
|
|
119
|
+
pending: Promise<Data>,
|
|
120
|
+
signal: AbortSignal | undefined,
|
|
121
|
+
): Promise<Data> {
|
|
122
|
+
if (!signal) {
|
|
123
|
+
return pending
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (signal.aborted) {
|
|
127
|
+
return Promise.reject(signal.reason)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return new Promise<Data>((resolve, reject) => {
|
|
131
|
+
const onAbort = () => reject(signal.reason)
|
|
132
|
+
|
|
133
|
+
signal.addEventListener("abort", onAbort, { once: true })
|
|
134
|
+
pending.then(
|
|
135
|
+
(value) => {
|
|
136
|
+
signal.removeEventListener("abort", onAbort)
|
|
137
|
+
resolve(value)
|
|
138
|
+
},
|
|
139
|
+
(error) => {
|
|
140
|
+
signal.removeEventListener("abort", onAbort)
|
|
141
|
+
reject(error)
|
|
142
|
+
},
|
|
143
|
+
)
|
|
144
|
+
})
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function createIsolatedRouteDataClient(routing: {
|
|
148
|
+
readonly basename: string
|
|
149
|
+
readonly dataPath: string
|
|
150
|
+
}): RouteDataClient {
|
|
151
|
+
const cache = new Map<string, Promise<unknown>>()
|
|
152
|
+
|
|
153
|
+
const load = (<Data = unknown>(
|
|
154
|
+
url: string | URL,
|
|
155
|
+
source: RouteDataSource,
|
|
156
|
+
options: RouteDataLoadOptions = {},
|
|
157
|
+
): Promise<Data> => {
|
|
158
|
+
const routeUrl = stripFlamefrontProtocolParams(resolveRouteUrl(url))
|
|
159
|
+
const key = cacheKey(routeUrl, source)
|
|
160
|
+
|
|
161
|
+
if (options.reload) {
|
|
162
|
+
cache.delete(key)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const cached = cache.get(key)
|
|
166
|
+
|
|
167
|
+
if (cached) {
|
|
168
|
+
return abortable(cached as Promise<Data>, options.signal)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const endpoint =
|
|
172
|
+
source === "static"
|
|
173
|
+
? new URL(
|
|
174
|
+
staticRouteDataPath(routeUrl, routing.basename),
|
|
175
|
+
routeUrl.origin,
|
|
176
|
+
)
|
|
177
|
+
: new URL(routing.dataPath, routeUrl.origin)
|
|
178
|
+
|
|
179
|
+
if (source === "live") {
|
|
180
|
+
endpoint.searchParams.set("url", routeUrl.href)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const pending = globalThis
|
|
184
|
+
.fetch(endpoint, options.signal ? { signal: options.signal } : undefined)
|
|
185
|
+
.then(async (response) => {
|
|
186
|
+
if (!response.ok) {
|
|
187
|
+
const label = source === "static" ? "static route data" : "loader"
|
|
188
|
+
|
|
189
|
+
throw new Error(
|
|
190
|
+
`flamefront ${label} request failed with ${response.status}.`,
|
|
191
|
+
)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return response.json() as Promise<Data>
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
cache.set(key, pending)
|
|
198
|
+
void pending.catch(() => {
|
|
199
|
+
if (cache.get(key) === pending) {
|
|
200
|
+
cache.delete(key)
|
|
201
|
+
}
|
|
202
|
+
})
|
|
203
|
+
return abortable(pending, options.signal)
|
|
204
|
+
}) as RouteDataClient["load"]
|
|
205
|
+
|
|
206
|
+
return {
|
|
207
|
+
load,
|
|
208
|
+
prefetch: async (url, source, options) => {
|
|
209
|
+
await load(url, source, options)
|
|
210
|
+
},
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export function createRouteDataClient(
|
|
215
|
+
options: RouteDataRoutingOptions = {},
|
|
216
|
+
): RouteDataClient {
|
|
217
|
+
const routing = normalizeRouting(options)
|
|
218
|
+
|
|
219
|
+
if (typeof window === "undefined") {
|
|
220
|
+
return createIsolatedRouteDataClient(routing)
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const key = `${routing.basename}\u0000${routing.dataPath}`
|
|
224
|
+
const existing = browserClients.get(key)
|
|
225
|
+
|
|
226
|
+
if (existing) {
|
|
227
|
+
return existing
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const client = createIsolatedRouteDataClient(routing)
|
|
231
|
+
|
|
232
|
+
browserClients.set(key, client)
|
|
233
|
+
return client
|
|
234
|
+
}
|