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