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,79 @@
1
+ import type {
2
+ LoadRouteOptions,
3
+ MatchRouteOptions,
4
+ RouteDefinition,
5
+ RouteMatchForUrl,
6
+ } from "./index.ts"
7
+
8
+ /**
9
+ * Resources that a route-aware prefetcher can warm without taking over
10
+ * navigation. The framework adapter supplies the route fragment transport;
11
+ * callers can replace it when they own the transport.
12
+ */
13
+ export interface RoutePrefetchResources<
14
+ Route extends RouteDefinition = RouteDefinition,
15
+ > {
16
+ readonly routeFragment?: (
17
+ url: string | URL,
18
+ route: Route,
19
+ options?: LoadRouteOptions,
20
+ ) => void | Promise<void>
21
+ }
22
+
23
+ export type RouteModulePreloader = (entry: string) => void | Promise<void>
24
+
25
+ export type RoutePrefetchCallback = (to: string) => void | Promise<void>
26
+
27
+ type RoutePrefetchApp<Route extends RouteDefinition> = {
28
+ readonly match: (
29
+ url: string | URL,
30
+ options?: MatchRouteOptions,
31
+ ) => RouteMatchForUrl<Route, string> | null
32
+ readonly prefetch: (
33
+ url: string | URL,
34
+ options?: LoadRouteOptions,
35
+ ) => Promise<void>
36
+ }
37
+
38
+ /**
39
+ * Warm the resources used by a matched route. Client routes share route data
40
+ * and module caches. Server and static routes use the fragment resource and
41
+ * never import their route module as a browser rendering path.
42
+ */
43
+ export async function prefetchRouteResources<
44
+ Route extends RouteDefinition = RouteDefinition,
45
+ >(
46
+ app: RoutePrefetchApp<Route>,
47
+ preloadRoute: RouteModulePreloader,
48
+ url: string | URL,
49
+ options: LoadRouteOptions = {},
50
+ resources: RoutePrefetchResources<Route> = {},
51
+ ): Promise<void> {
52
+ const match = app.match(url)
53
+
54
+ if (!match) {
55
+ return
56
+ }
57
+
58
+ if (match.data.render !== "client") {
59
+ await resources.routeFragment?.(url, match.data, options)
60
+ return
61
+ }
62
+
63
+ await Promise.all([
64
+ app.prefetch(url, options),
65
+ preloadRoute(match.data.entry),
66
+ ])
67
+ }
68
+
69
+ /** Create the callback accepted by the generic browser router. */
70
+ export function createRoutePrefetchCallback<
71
+ Route extends RouteDefinition = RouteDefinition,
72
+ >(
73
+ app: RoutePrefetchApp<Route>,
74
+ preloadRoute: RouteModulePreloader,
75
+ resources: RoutePrefetchResources<Route> = {},
76
+ ): RoutePrefetchCallback {
77
+ return (to) =>
78
+ prefetchRouteResources(app, preloadRoute, to, undefined, resources)
79
+ }
package/src/server.ts ADDED
@@ -0,0 +1,338 @@
1
+ import {
2
+ type AppDefinition,
3
+ type MatchRouteOptions,
4
+ type RouteLoaderFor,
5
+ type RenderMode,
6
+ type RouteLoaderData,
7
+ type RouteMatchForUrl,
8
+ type RouteParams,
9
+ type RouteDefinition,
10
+ } from "./index.ts"
11
+ import { stripFlamefrontProtocolRequest } from "./fragment-protocol.ts"
12
+
13
+ type LoaderPath<ContextOrPath, PathOrContext> =
14
+ ContextOrPath extends `/${string}`
15
+ ? ContextOrPath
16
+ : PathOrContext extends `/${string}`
17
+ ? PathOrContext
18
+ : string
19
+
20
+ type LoaderContext<ContextOrPath, PathOrContext> =
21
+ ContextOrPath extends `/${string}`
22
+ ? PathOrContext extends `/${string}`
23
+ ? unknown
24
+ : PathOrContext
25
+ : ContextOrPath
26
+
27
+ export interface LoaderArgs<ContextOrPath = unknown, PathOrContext = unknown> {
28
+ readonly request: Request
29
+ readonly params: Readonly<
30
+ RouteParams<LoaderPath<ContextOrPath, PathOrContext>>
31
+ >
32
+ readonly context: LoaderContext<ContextOrPath, PathOrContext>
33
+ }
34
+
35
+ export type Loader<
36
+ Data = unknown,
37
+ Context = unknown,
38
+ Path extends string = string,
39
+ > = (args: LoaderArgs<Context, Path>) => Data | Promise<Data>
40
+
41
+ export interface RouteModule<
42
+ Data = unknown,
43
+ Context = unknown,
44
+ Path extends string = string,
45
+ > {
46
+ readonly default: unknown
47
+ readonly loader?: Loader<Data, Context, Path>
48
+ }
49
+
50
+ export type DocumentMode = "shell" | RenderMode
51
+ export type RequestPurpose = "data" | "document"
52
+
53
+ /** Inputs for constructing one request-scoped value for route work. */
54
+ export interface RequestContextArgs<
55
+ Route extends RouteDefinition = RouteDefinition,
56
+ > {
57
+ readonly request: Request
58
+ readonly route: Route | null
59
+ readonly params: Readonly<RouteParams<Route["path"]>>
60
+ readonly purpose: RequestPurpose
61
+ readonly mode?: DocumentMode
62
+ }
63
+
64
+ export type RequestContextFactory<
65
+ Context = unknown,
66
+ Route extends RouteDefinition = RouteDefinition,
67
+ > = (args: RequestContextArgs<Route>) => Context | Promise<Context>
68
+
69
+ /** Import a generated or application-provided route module by its entry ID. */
70
+ export type RouteImporter<
71
+ Data = unknown,
72
+ Context = unknown,
73
+ Path extends string = string,
74
+ > = (entry: string) => Promise<RouteModule<Data, Context, Path>>
75
+
76
+ export interface RenderedDocument {
77
+ readonly html: string
78
+ readonly routeData?: unknown
79
+ readonly status?: number
80
+ readonly headers?: HeadersInit
81
+ }
82
+
83
+ export type RenderDocumentResult = string | RenderedDocument
84
+
85
+ export interface LoadedRoute<
86
+ Data = unknown,
87
+ Context = unknown,
88
+ Route extends RouteDefinition = RouteDefinition,
89
+ > {
90
+ readonly route: Route
91
+ readonly module: RouteModule<Data, Context>
92
+ readonly loaderData: Data | undefined
93
+ }
94
+
95
+ /** A loaded route whose data follows the generated route-module map. */
96
+ export type LoadedRouteFor<
97
+ Route extends RouteDefinition,
98
+ Context = unknown,
99
+ > = Route extends RouteDefinition
100
+ ? RouteLoaderFor<Route["path"]> extends (
101
+ ...args: infer _Args
102
+ ) => infer _Result
103
+ ? Omit<
104
+ LoadedRoute<RouteLoaderData<Route["path"]>, Context, Route>,
105
+ "loaderData"
106
+ > & {
107
+ readonly loaderData: RouteLoaderData<Route["path"]>
108
+ }
109
+ : LoadedRoute<unknown, Context, Route>
110
+ : never
111
+
112
+ /** Route-module shape selected from one authored route definition. */
113
+ export type RouteModuleForRoute<
114
+ Route extends RouteDefinition,
115
+ Context = unknown,
116
+ > = Route extends RouteDefinition
117
+ ? RouteLoaderFor<Route["path"]> extends (
118
+ ...args: infer _Args
119
+ ) => infer _Result
120
+ ? RouteModule<RouteLoaderData<Route["path"]>, Context, Route["path"]>
121
+ : RouteModule<unknown, Context, Route["path"]>
122
+ : never
123
+
124
+ /** Importer shape for applications that own a typed route-module boundary. */
125
+ export type RouteImporterFor<
126
+ Route extends RouteDefinition,
127
+ Context = unknown,
128
+ > = (entry: Route["entry"]) => Promise<RouteModuleForRoute<Route, Context>>
129
+
130
+ export interface RouteRuntimeContextOptions {
131
+ readonly purpose: RequestPurpose
132
+ readonly mode?: DocumentMode
133
+ }
134
+
135
+ export interface RouteLoadOptions<Context = unknown> {
136
+ readonly context?: Context
137
+ readonly mode?: DocumentMode
138
+ }
139
+
140
+ export interface RouteRuntime<
141
+ Context = unknown,
142
+ Route extends RouteDefinition = RouteDefinition,
143
+ > {
144
+ readonly app: AppDefinition<Route>
145
+ readonly importRoute: RouteImporter<unknown, Context>
146
+ readonly match: (
147
+ url: string | URL,
148
+ options?: MatchRouteOptions,
149
+ ) => RouteMatchForUrl<Route, string> | null
150
+ readonly createRequestContext: (
151
+ request: Request,
152
+ options: RouteRuntimeContextOptions,
153
+ ) => Promise<Context | undefined>
154
+ readonly loadRoute: (
155
+ request: Request,
156
+ options?: RouteLoadOptions<Context>,
157
+ ) => Promise<LoadedRouteFor<Route, Context> | null>
158
+ readonly loadRouteData: (request: Request) => Promise<Response>
159
+ }
160
+
161
+ export interface RouteRuntimeOptions<
162
+ Context = unknown,
163
+ Route extends RouteDefinition = RouteDefinition,
164
+ > {
165
+ readonly app: AppDefinition<Route>
166
+ readonly importRoute: RouteImporter<unknown, Context>
167
+ /** Build request context for data requests and document router queries. */
168
+ readonly requestContext?: RequestContextFactory<Context, Route>
169
+ }
170
+
171
+ async function loadMatchedRoute<
172
+ Data = unknown,
173
+ Context = unknown,
174
+ Route extends RouteDefinition = RouteDefinition,
175
+ >(
176
+ match: RouteMatchForUrl<Route, string>,
177
+ request: Request,
178
+ importRoute: RouteImporter<Data, Context>,
179
+ context?: Context,
180
+ ): Promise<LoadedRoute<Data, Context, Route>> {
181
+ const routeModule = await importRoute(match.data.entry)
182
+ const loaderData = routeModule.loader
183
+ ? await routeModule.loader({
184
+ request,
185
+ params: match.params as LoaderArgs<Context, string>["params"],
186
+ context: context as LoaderContext<Context, string>,
187
+ })
188
+ : undefined
189
+
190
+ return {
191
+ route: match.data,
192
+ module: routeModule,
193
+ loaderData,
194
+ }
195
+ }
196
+
197
+ export function createRouteRuntime<
198
+ Context = unknown,
199
+ Route extends RouteDefinition = RouteDefinition,
200
+ >(
201
+ options: Omit<RouteRuntimeOptions<Context, Route>, "importRoute"> & {
202
+ readonly importRoute: RouteImporterFor<Route, Context>
203
+ },
204
+ ): RouteRuntime<Context, Route>
205
+
206
+ export function createRouteRuntime<
207
+ Context = unknown,
208
+ Route extends RouteDefinition = RouteDefinition,
209
+ >(options: RouteRuntimeOptions<Context, Route>): RouteRuntime<Context, Route> {
210
+ const createRequestContext = async (
211
+ request: Request,
212
+ contextOptions: RouteRuntimeContextOptions,
213
+ match = options.app.match(request.url),
214
+ ): Promise<Context | undefined> => {
215
+ if (!options.requestContext) {
216
+ return undefined
217
+ }
218
+
219
+ return options.requestContext({
220
+ request,
221
+ route: match?.data ?? null,
222
+ params: (match?.params ?? {}) as Readonly<RouteParams<Route["path"]>>,
223
+ purpose: contextOptions.purpose,
224
+ ...(contextOptions.mode === undefined
225
+ ? {}
226
+ : { mode: contextOptions.mode }),
227
+ })
228
+ }
229
+
230
+ const loadRouteForRequest = async (
231
+ request: Request,
232
+ loadOptions: RouteLoadOptions<Context> = {},
233
+ ): Promise<LoadedRouteFor<Route, Context> | null> => {
234
+ const sanitizedRequest = stripFlamefrontProtocolRequest(request)
235
+ const match = options.app.match(sanitizedRequest.url)
236
+
237
+ if (!match) {
238
+ return null
239
+ }
240
+
241
+ const context =
242
+ "context" in loadOptions
243
+ ? loadOptions.context
244
+ : await createRequestContext(
245
+ sanitizedRequest,
246
+ { purpose: "data", mode: loadOptions.mode },
247
+ match,
248
+ )
249
+
250
+ return loadMatchedRoute(
251
+ match,
252
+ sanitizedRequest,
253
+ options.importRoute,
254
+ context,
255
+ ) as Promise<LoadedRouteFor<Route, Context>>
256
+ }
257
+
258
+ const loadRouteData = async (request: Request): Promise<Response> => {
259
+ const routeUrl = new URL(request.url).searchParams.get("url")
260
+
261
+ if (!routeUrl) {
262
+ return new Response("Missing route URL.", { status: 400 })
263
+ }
264
+
265
+ const loaded = await loadRouteForRequest(
266
+ stripFlamefrontProtocolRequest(
267
+ new Request(routeUrl, {
268
+ method: "GET",
269
+ headers: request.headers,
270
+ signal: request.signal,
271
+ }),
272
+ ),
273
+ )
274
+
275
+ if (!loaded) {
276
+ return new Response("Not found.", { status: 404 })
277
+ }
278
+
279
+ return Response.json(loaded.loaderData ?? null)
280
+ }
281
+
282
+ return {
283
+ app: options.app,
284
+ importRoute: options.importRoute,
285
+ match: options.app.match,
286
+ createRequestContext: (request, contextOptions) =>
287
+ createRequestContext(request, contextOptions),
288
+ loadRoute: loadRouteForRequest,
289
+ loadRouteData,
290
+ }
291
+ }
292
+
293
+ export function loadRoute<
294
+ _Data = unknown,
295
+ Context = unknown,
296
+ Route extends RouteDefinition = RouteDefinition,
297
+ >(
298
+ app: AppDefinition<Route>,
299
+ request: Request,
300
+ importRoute: RouteImporterFor<Route, Context>,
301
+ context?: Context,
302
+ ): Promise<LoadedRouteFor<Route, Context> | null>
303
+
304
+ export function loadRoute<
305
+ Data = unknown,
306
+ Context = unknown,
307
+ Route extends RouteDefinition = RouteDefinition,
308
+ >(
309
+ app: AppDefinition<Route>,
310
+ request: Request,
311
+ importRoute: RouteImporter<Data, Context>,
312
+ context?: Context,
313
+ ): Promise<LoadedRoute<Data, Context, Route> | null>
314
+
315
+ export async function loadRoute<
316
+ Data = unknown,
317
+ Context = unknown,
318
+ Route extends RouteDefinition = RouteDefinition,
319
+ >(
320
+ app: AppDefinition<Route>,
321
+ request: Request,
322
+ importRoute: RouteImporter<Data, Context>,
323
+ context?: Context,
324
+ ): Promise<LoadedRouteFor<Route, Context> | null> {
325
+ const sanitizedRequest = stripFlamefrontProtocolRequest(request)
326
+ const match = app.match(sanitizedRequest.url)
327
+
328
+ if (!match) {
329
+ return null
330
+ }
331
+
332
+ return loadMatchedRoute(
333
+ match,
334
+ sanitizedRequest,
335
+ importRoute,
336
+ context,
337
+ ) as Promise<LoadedRouteFor<Route, Context>>
338
+ }
package/src/srvx.ts ADDED
@@ -0,0 +1,174 @@
1
+ import { readFile } from "node:fs/promises"
2
+ import { fileURLToPath } from "node:url"
3
+ import { resolve } from "node:path"
4
+ import { staticMiddleware } from "srvx/static"
5
+ import type { ServerMiddleware, ServerOptions } from "srvx"
6
+ import {
7
+ stripBasename,
8
+ type AppDefinition,
9
+ type RouteDefinition,
10
+ } from "./index.ts"
11
+ import { isRouteFragmentRequest } from "./fragment-protocol.ts"
12
+ import {
13
+ createFetchServerEntry,
14
+ type ResponseHeadersHook,
15
+ type ServerDocuments,
16
+ type ServerEntryLifecycle,
17
+ type TemplateLoader,
18
+ } from "./fetch.ts"
19
+ import { staticRouteFragmentDataFile } from "./static-fragment-artifacts.ts"
20
+
21
+ export type {
22
+ FetchMiddleware,
23
+ FetchServerAssets,
24
+ FetchServerEntryOptions,
25
+ FlamefrontFetchServerEntry,
26
+ ResponseHeaders,
27
+ ResponseHeadersContext,
28
+ ResponseHeadersHook,
29
+ ServerDocuments,
30
+ StaticFragmentContext,
31
+ StaticFragmentLoader,
32
+ TemplateContext,
33
+ TemplateLoader,
34
+ } from "./fetch.ts"
35
+
36
+ export type SrvxMiddleware = ServerMiddleware
37
+
38
+ /** Client asset location and the optional replacement for template lookup. */
39
+ export interface ServerAssets<Route extends RouteDefinition = RouteDefinition> {
40
+ readonly clientDirectory: string | URL
41
+ readonly loadTemplate?: TemplateLoader<Route>
42
+ }
43
+
44
+ export type { ServerEntryLifecycle }
45
+
46
+ /** The single default-export value consumed by Flamefront's lifecycle. */
47
+ export type FlamefrontServerEntry = ServerOptions & ServerEntryLifecycle
48
+
49
+ /** Inputs for composing the transport around an Octane document service. */
50
+ export interface SrvxServerEntryOptions<
51
+ Route extends RouteDefinition = RouteDefinition,
52
+ > {
53
+ readonly app: AppDefinition<Route>
54
+ readonly documents: ServerDocuments
55
+ readonly assets: ServerAssets<Route>
56
+ /** Applied outermost first, in declaration order, around framework transport. */
57
+ readonly middleware?: readonly SrvxMiddleware[]
58
+ readonly headers?: ResponseHeadersHook<Route>
59
+ }
60
+
61
+ function asPath(directory: string | URL): string {
62
+ return directory instanceof URL ? fileURLToPath(directory) : directory
63
+ }
64
+
65
+ async function loadDefaultTemplate(clientDirectory: string): Promise<string> {
66
+ let lastError: unknown
67
+ const candidates = [
68
+ resolve(clientDirectory, "..", "server", "index.html"),
69
+ resolve(clientDirectory, "index.html"),
70
+ resolve(clientDirectory, "..", "index.html"),
71
+ ]
72
+
73
+ for (const filename of candidates) {
74
+ try {
75
+ return await readFile(filename, "utf8")
76
+ } catch (error) {
77
+ lastError = error
78
+ }
79
+ }
80
+
81
+ throw lastError
82
+ }
83
+
84
+ function staticRequest(request: Request, basename: string): Request {
85
+ if (
86
+ basename === "/" ||
87
+ (request.method !== "GET" && request.method !== "HEAD")
88
+ ) {
89
+ return request
90
+ }
91
+
92
+ const url = new URL(request.url)
93
+ const pathname = stripBasename(url.pathname, basename)
94
+
95
+ if (!pathname || pathname === url.pathname) {
96
+ return request
97
+ }
98
+
99
+ url.pathname = pathname
100
+ return new Request(url, request)
101
+ }
102
+
103
+ /**
104
+ * Compose the srvx transport and the mode-aware document/data lifecycle into
105
+ * the one default server entry consumed by Flamefront's lifecycle.
106
+ */
107
+ export function createSrvxServerEntry<
108
+ Route extends RouteDefinition = RouteDefinition,
109
+ >(options: SrvxServerEntryOptions<Route>): FlamefrontServerEntry {
110
+ const clientDirectory = asPath(options.assets.clientDirectory)
111
+ const loadTemplate =
112
+ options.assets.loadTemplate ?? (() => loadDefaultTemplate(clientDirectory))
113
+ const serveClientFile = staticMiddleware({ dir: clientDirectory })
114
+
115
+ const frameworkMiddleware: SrvxMiddleware = (request, next) => {
116
+ const url = new URL(request.url)
117
+
118
+ if (isRouteFragmentRequest(url)) {
119
+ return next()
120
+ }
121
+
122
+ if (url.pathname === options.app.routing.dataPath) {
123
+ return next()
124
+ }
125
+
126
+ const match = options.app.match(url)
127
+
128
+ if (url.pathname === options.app.routing.basename && !match) {
129
+ return next()
130
+ }
131
+
132
+ if (match?.data.render === "client" || match?.data.render === "server") {
133
+ return next()
134
+ }
135
+
136
+ return serveClientFile(
137
+ staticRequest(request, options.app.routing.basename),
138
+ next,
139
+ )
140
+ }
141
+
142
+ const fetchEntry = createFetchServerEntry({
143
+ app: options.app,
144
+ documents: options.documents,
145
+ assets: {
146
+ loadTemplate,
147
+ loadStaticFragment: async ({ route }) => {
148
+ try {
149
+ return JSON.parse(
150
+ await readFile(
151
+ staticRouteFragmentDataFile(clientDirectory, route),
152
+ "utf8",
153
+ ),
154
+ )
155
+ } catch (error) {
156
+ if ((error as { code?: string }).code !== "ENOENT") {
157
+ throw error
158
+ }
159
+
160
+ return undefined
161
+ }
162
+ },
163
+ },
164
+ headers: options.headers,
165
+ })
166
+
167
+ return {
168
+ fetch: fetchEntry.fetch,
169
+ middleware: [...(options.middleware ?? []), frameworkMiddleware],
170
+ renderDocument: fetchEntry.renderDocument,
171
+ loadRouteData: fetchEntry.loadRouteData,
172
+ renderFragment: fetchEntry.renderFragment,
173
+ }
174
+ }
@@ -0,0 +1,86 @@
1
+ import { relative, resolve, sep } from "node:path"
2
+ import type { RouteDefinition } from "./index.ts"
3
+
4
+ function isWithin(directory: string, filePath: string): boolean {
5
+ const pathFromDirectory = relative(directory, filePath)
6
+
7
+ return (
8
+ pathFromDirectory === "" ||
9
+ (pathFromDirectory !== ".." &&
10
+ !pathFromDirectory.startsWith(`..${sep}`) &&
11
+ !pathFromDirectory.startsWith(sep))
12
+ )
13
+ }
14
+
15
+ function staticRoutePath(
16
+ clientDirectory: string,
17
+ route: RouteDefinition,
18
+ ): string {
19
+ if (/[:*]/.test(route.path)) {
20
+ throw new Error(
21
+ `Cannot prerender parameterized static route ${JSON.stringify(route.path)} without concrete paths.`,
22
+ )
23
+ }
24
+
25
+ const segments = route.path
26
+ .split("/")
27
+ .filter(Boolean)
28
+ .map((segment) => decodeURIComponent(segment))
29
+
30
+ if (
31
+ segments.some(
32
+ (segment) => segment === "." || segment === ".." || segment.includes("/"),
33
+ )
34
+ ) {
35
+ throw new Error(
36
+ `Cannot write unsafe static route path ${JSON.stringify(route.path)}.`,
37
+ )
38
+ }
39
+
40
+ const filePath = resolve(clientDirectory, ...segments, "index.html")
41
+
42
+ if (!isWithin(clientDirectory, filePath)) {
43
+ throw new Error(
44
+ `Cannot write static route outside the client build: ${JSON.stringify(route.path)}.`,
45
+ )
46
+ }
47
+
48
+ return filePath
49
+ }
50
+
51
+ export function staticRouteFile(
52
+ clientDirectory: string,
53
+ route: RouteDefinition,
54
+ ): string {
55
+ return staticRoutePath(clientDirectory, route)
56
+ }
57
+
58
+ export function staticRouteDataFile(
59
+ clientDirectory: string,
60
+ route: RouteDefinition,
61
+ ): string {
62
+ return staticRoutePath(clientDirectory, route).replace(
63
+ /\.html$/,
64
+ ".data.json",
65
+ )
66
+ }
67
+
68
+ export function staticRouteFragmentFile(
69
+ clientDirectory: string,
70
+ route: RouteDefinition,
71
+ ): string {
72
+ return staticRoutePath(clientDirectory, route).replace(
73
+ /\.html$/,
74
+ ".fragment.html",
75
+ )
76
+ }
77
+
78
+ export function staticRouteFragmentDataFile(
79
+ clientDirectory: string,
80
+ route: RouteDefinition,
81
+ ): string {
82
+ return staticRoutePath(clientDirectory, route).replace(
83
+ /\.html$/,
84
+ ".fragment.json",
85
+ )
86
+ }