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.
@@ -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
+ }