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
|
@@ -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,111 @@
|
|
|
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 { prefetchStaticFragment } 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
|
+
staticFragment:
|
|
76
|
+
resources.staticFragment ??
|
|
77
|
+
((url, _route, options) => prefetchStaticFragment(url, routing, options)),
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Prefetch route resources selected by the matched Flamefront route. */
|
|
82
|
+
export async function prefetchRoute<
|
|
83
|
+
Route extends RouteDefinition = RouteDefinition,
|
|
84
|
+
>(
|
|
85
|
+
app: Pick<AppDefinition<Route>, "match" | "prefetch">,
|
|
86
|
+
url: string | URL,
|
|
87
|
+
options?: LoadRouteOptions,
|
|
88
|
+
resources?: RoutePrefetchResources<Route>,
|
|
89
|
+
): Promise<void> {
|
|
90
|
+
await prefetchRouteResources(
|
|
91
|
+
app,
|
|
92
|
+
preloadGeneratedRoute,
|
|
93
|
+
url,
|
|
94
|
+
options,
|
|
95
|
+
withDefaultPrefetchResources(resources),
|
|
96
|
+
)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Create the callback used by `createClientRouter({ prefetch })`. */
|
|
100
|
+
export function createRoutePrefetcher<
|
|
101
|
+
Route extends RouteDefinition = RouteDefinition,
|
|
102
|
+
>(
|
|
103
|
+
app: Pick<AppDefinition<Route>, "match" | "prefetch">,
|
|
104
|
+
resources?: RoutePrefetchResources<Route>,
|
|
105
|
+
): RoutePrefetchCallback {
|
|
106
|
+
return createRoutePrefetchCallback(
|
|
107
|
+
app,
|
|
108
|
+
preloadGeneratedRoute,
|
|
109
|
+
withDefaultPrefetchResources(resources),
|
|
110
|
+
)
|
|
111
|
+
}
|
|
@@ -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,224 @@
|
|
|
1
|
+
import { stripFlamefrontProtocolParams } from "./fragment-protocol.ts"
|
|
2
|
+
|
|
3
|
+
export type RouteDataSource = "live" | "static"
|
|
4
|
+
|
|
5
|
+
export interface RouteDataRoutingOptions {
|
|
6
|
+
readonly basename?: string
|
|
7
|
+
readonly dataPath?: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface RouteDataLoadOptions {
|
|
11
|
+
readonly signal?: AbortSignal
|
|
12
|
+
readonly reload?: boolean
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface RouteDataClient {
|
|
16
|
+
readonly load: <Data = unknown>(
|
|
17
|
+
url: string | URL,
|
|
18
|
+
source: RouteDataSource,
|
|
19
|
+
options?: RouteDataLoadOptions,
|
|
20
|
+
) => Promise<Data>
|
|
21
|
+
readonly prefetch: (
|
|
22
|
+
url: string | URL,
|
|
23
|
+
source: RouteDataSource,
|
|
24
|
+
options?: RouteDataLoadOptions,
|
|
25
|
+
) => Promise<void>
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const defaultRouting = Object.freeze({
|
|
29
|
+
basename: "/",
|
|
30
|
+
dataPath: "/__flamefront/data",
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Browser route-data clients share one cache per routing configuration so
|
|
35
|
+
* app.prefetch() and generated router loaders can consume the same promise.
|
|
36
|
+
* Server callers receive an isolated client to avoid a process-wide browser
|
|
37
|
+
* cache; server rendering invokes route loaders directly instead.
|
|
38
|
+
*/
|
|
39
|
+
const browserClients = new Map<string, RouteDataClient>()
|
|
40
|
+
|
|
41
|
+
function normalizePath(value: string | undefined, fallback: string): string {
|
|
42
|
+
return value?.replace(/\/+$/, "") || fallback
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function normalizeRouting(options: RouteDataRoutingOptions): {
|
|
46
|
+
readonly basename: string
|
|
47
|
+
readonly dataPath: string
|
|
48
|
+
} {
|
|
49
|
+
return {
|
|
50
|
+
basename: normalizePath(options.basename, defaultRouting.basename),
|
|
51
|
+
dataPath: normalizePath(options.dataPath, defaultRouting.dataPath),
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function resolveRouteUrl(url: string | URL): URL {
|
|
56
|
+
const browserOrigin =
|
|
57
|
+
typeof location === "undefined" ? undefined : location.origin
|
|
58
|
+
|
|
59
|
+
if (!browserOrigin && typeof url === "string" && !URL.canParse(url)) {
|
|
60
|
+
throw new TypeError(
|
|
61
|
+
"flamefront route data requires an absolute URL outside the browser.",
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return new URL(url, browserOrigin)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function stripBasename(pathname: string, basename: string): string | null {
|
|
69
|
+
if (basename === "/") {
|
|
70
|
+
return pathname
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (pathname === basename) {
|
|
74
|
+
return "/"
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (!pathname.startsWith(`${basename}/`)) {
|
|
78
|
+
return null
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return pathname.slice(basename.length) || "/"
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function joinBasename(basename: string, pathname: string): string {
|
|
85
|
+
if (basename === "/") {
|
|
86
|
+
return pathname || "/"
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (pathname === "/") {
|
|
90
|
+
return basename
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return `${basename}${pathname.startsWith("/") ? pathname : `/${pathname}`}`
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function staticRouteDataPath(routeUrl: URL, basename: string): string {
|
|
97
|
+
const appPathname =
|
|
98
|
+
stripBasename(routeUrl.pathname, basename) ?? routeUrl.pathname
|
|
99
|
+
const pathname = joinBasename(basename, appPathname).replace(/\/+$/, "")
|
|
100
|
+
|
|
101
|
+
return pathname === "" ? "/index.data.json" : `${pathname}/index.data.json`
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function cacheKey(routeUrl: URL, source: RouteDataSource): string {
|
|
105
|
+
return `${source}:${routeUrl.origin}${routeUrl.pathname}${routeUrl.search}`
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function abortable<Data>(
|
|
109
|
+
pending: Promise<Data>,
|
|
110
|
+
signal: AbortSignal | undefined,
|
|
111
|
+
): Promise<Data> {
|
|
112
|
+
if (!signal) {
|
|
113
|
+
return pending
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (signal.aborted) {
|
|
117
|
+
return Promise.reject(signal.reason)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return new Promise<Data>((resolve, reject) => {
|
|
121
|
+
const onAbort = () => reject(signal.reason)
|
|
122
|
+
|
|
123
|
+
signal.addEventListener("abort", onAbort, { once: true })
|
|
124
|
+
pending.then(
|
|
125
|
+
(value) => {
|
|
126
|
+
signal.removeEventListener("abort", onAbort)
|
|
127
|
+
resolve(value)
|
|
128
|
+
},
|
|
129
|
+
(error) => {
|
|
130
|
+
signal.removeEventListener("abort", onAbort)
|
|
131
|
+
reject(error)
|
|
132
|
+
},
|
|
133
|
+
)
|
|
134
|
+
})
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function createIsolatedRouteDataClient(routing: {
|
|
138
|
+
readonly basename: string
|
|
139
|
+
readonly dataPath: string
|
|
140
|
+
}): RouteDataClient {
|
|
141
|
+
const cache = new Map<string, Promise<unknown>>()
|
|
142
|
+
|
|
143
|
+
const load = <Data = unknown>(
|
|
144
|
+
url: string | URL,
|
|
145
|
+
source: RouteDataSource,
|
|
146
|
+
options: RouteDataLoadOptions = {},
|
|
147
|
+
): Promise<Data> => {
|
|
148
|
+
const routeUrl = stripFlamefrontProtocolParams(resolveRouteUrl(url))
|
|
149
|
+
const key = cacheKey(routeUrl, source)
|
|
150
|
+
|
|
151
|
+
if (options.reload) {
|
|
152
|
+
cache.delete(key)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const cached = cache.get(key)
|
|
156
|
+
|
|
157
|
+
if (cached) {
|
|
158
|
+
return abortable(cached as Promise<Data>, options.signal)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const endpoint =
|
|
162
|
+
source === "static"
|
|
163
|
+
? new URL(
|
|
164
|
+
staticRouteDataPath(routeUrl, routing.basename),
|
|
165
|
+
routeUrl.origin,
|
|
166
|
+
)
|
|
167
|
+
: new URL(routing.dataPath, routeUrl.origin)
|
|
168
|
+
|
|
169
|
+
if (source === "live") {
|
|
170
|
+
endpoint.searchParams.set("url", routeUrl.href)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const pending = globalThis
|
|
174
|
+
.fetch(endpoint, options.signal ? { signal: options.signal } : undefined)
|
|
175
|
+
.then(async (response) => {
|
|
176
|
+
if (!response.ok) {
|
|
177
|
+
const label = source === "static" ? "static route data" : "loader"
|
|
178
|
+
|
|
179
|
+
throw new Error(
|
|
180
|
+
`flamefront ${label} request failed with ${response.status}.`,
|
|
181
|
+
)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return response.json() as Promise<Data>
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
cache.set(key, pending)
|
|
188
|
+
void pending.catch(() => {
|
|
189
|
+
if (cache.get(key) === pending) {
|
|
190
|
+
cache.delete(key)
|
|
191
|
+
}
|
|
192
|
+
})
|
|
193
|
+
return abortable(pending, options.signal)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return {
|
|
197
|
+
load,
|
|
198
|
+
prefetch: async (url, source, options) => {
|
|
199
|
+
await load(url, source, options)
|
|
200
|
+
},
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export function createRouteDataClient(
|
|
205
|
+
options: RouteDataRoutingOptions = {},
|
|
206
|
+
): RouteDataClient {
|
|
207
|
+
const routing = normalizeRouting(options)
|
|
208
|
+
|
|
209
|
+
if (typeof window === "undefined") {
|
|
210
|
+
return createIsolatedRouteDataClient(routing)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const key = `${routing.basename}\u0000${routing.dataPath}`
|
|
214
|
+
const existing = browserClients.get(key)
|
|
215
|
+
|
|
216
|
+
if (existing) {
|
|
217
|
+
return existing
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const client = createIsolatedRouteDataClient(routing)
|
|
221
|
+
|
|
222
|
+
browserClients.set(key, client)
|
|
223
|
+
return client
|
|
224
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AppDefinition,
|
|
3
|
+
LoadRouteOptions,
|
|
4
|
+
RouteDefinition,
|
|
5
|
+
} from "./index.ts"
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Resources that a route-aware prefetcher can warm without taking over
|
|
9
|
+
* navigation. The framework adapter supplies the static fragment transport;
|
|
10
|
+
* callers can replace it when they own the transport.
|
|
11
|
+
*/
|
|
12
|
+
export interface RoutePrefetchResources<
|
|
13
|
+
Route extends RouteDefinition = RouteDefinition,
|
|
14
|
+
> {
|
|
15
|
+
readonly staticFragment?: (
|
|
16
|
+
url: string | URL,
|
|
17
|
+
route: Route,
|
|
18
|
+
options?: LoadRouteOptions,
|
|
19
|
+
) => void | Promise<void>
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type RouteModulePreloader = (entry: string) => void | Promise<void>
|
|
23
|
+
|
|
24
|
+
export type RoutePrefetchCallback = (to: string) => void | Promise<void>
|
|
25
|
+
|
|
26
|
+
type RoutePrefetchApp<Route extends RouteDefinition> = Pick<
|
|
27
|
+
AppDefinition<Route>,
|
|
28
|
+
"match" | "prefetch"
|
|
29
|
+
>
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Warm the resources used by a matched route. Live routes share route data
|
|
33
|
+
* and client-module caches. Static routes hand off to the fragment resource
|
|
34
|
+
* seam and never import their route module as a rendering path.
|
|
35
|
+
*/
|
|
36
|
+
export async function prefetchRouteResources<
|
|
37
|
+
Route extends RouteDefinition = RouteDefinition,
|
|
38
|
+
>(
|
|
39
|
+
app: RoutePrefetchApp<Route>,
|
|
40
|
+
preloadRoute: RouteModulePreloader,
|
|
41
|
+
url: string | URL,
|
|
42
|
+
options: LoadRouteOptions = {},
|
|
43
|
+
resources: RoutePrefetchResources<Route> = {},
|
|
44
|
+
): Promise<void> {
|
|
45
|
+
const match = app.match(url)
|
|
46
|
+
|
|
47
|
+
if (!match) {
|
|
48
|
+
return
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (match.data.render === "static") {
|
|
52
|
+
await resources.staticFragment?.(url, match.data, options)
|
|
53
|
+
return
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
await Promise.all([
|
|
57
|
+
app.prefetch(url, options),
|
|
58
|
+
preloadRoute(match.data.entry),
|
|
59
|
+
])
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Create the callback accepted by the generic browser router. */
|
|
63
|
+
export function createRoutePrefetchCallback<
|
|
64
|
+
Route extends RouteDefinition = RouteDefinition,
|
|
65
|
+
>(
|
|
66
|
+
app: RoutePrefetchApp<Route>,
|
|
67
|
+
preloadRoute: RouteModulePreloader,
|
|
68
|
+
resources: RoutePrefetchResources<Route> = {},
|
|
69
|
+
): RoutePrefetchCallback {
|
|
70
|
+
return (to) =>
|
|
71
|
+
prefetchRouteResources(app, preloadRoute, to, undefined, resources)
|
|
72
|
+
}
|