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/src/server.ts ADDED
@@ -0,0 +1,240 @@
1
+ import type { Match } from "@remix-run/route-pattern/match"
2
+ import {
3
+ type AppDefinition,
4
+ type MatchRouteOptions,
5
+ type RenderMode,
6
+ type RouteDefinition,
7
+ } from "./index.ts"
8
+ import { stripFlamefrontProtocolRequest } from "./fragment-protocol.ts"
9
+
10
+ export interface LoaderArgs<Context = unknown> {
11
+ readonly request: Request
12
+ readonly params: Readonly<Record<string, string | undefined>>
13
+ readonly context: Context
14
+ }
15
+
16
+ export type Loader<Data = unknown, Context = unknown> = (
17
+ args: LoaderArgs<Context>,
18
+ ) => Data | Promise<Data>
19
+
20
+ export interface RouteModule<Data = unknown, Context = unknown> {
21
+ readonly default: unknown
22
+ readonly loader?: Loader<Data, Context>
23
+ }
24
+
25
+ export type DocumentMode = "shell" | RenderMode
26
+ export type RequestPurpose = "data" | "document"
27
+
28
+ /** Inputs for constructing one request-scoped value for route work. */
29
+ export interface RequestContextArgs<
30
+ Route extends RouteDefinition = RouteDefinition,
31
+ > {
32
+ readonly request: Request
33
+ readonly route: Route | null
34
+ readonly params: Readonly<Record<string, string | undefined>>
35
+ readonly purpose: RequestPurpose
36
+ readonly mode?: DocumentMode
37
+ }
38
+
39
+ export type RequestContextFactory<
40
+ Context = unknown,
41
+ Route extends RouteDefinition = RouteDefinition,
42
+ > = (args: RequestContextArgs<Route>) => Context | Promise<Context>
43
+
44
+ /** Import a generated or application-provided route module by its entry ID. */
45
+ export type RouteImporter<Data = unknown, Context = unknown> = (
46
+ entry: string,
47
+ ) => Promise<RouteModule<Data, Context>>
48
+
49
+ export interface RenderedDocument {
50
+ readonly html: string
51
+ readonly routeData?: unknown
52
+ readonly status?: number
53
+ readonly headers?: HeadersInit
54
+ }
55
+
56
+ export type RenderDocumentResult = string | RenderedDocument
57
+
58
+ export interface LoadedRoute<
59
+ Data = unknown,
60
+ Context = unknown,
61
+ Route extends RouteDefinition = RouteDefinition,
62
+ > {
63
+ readonly route: Route
64
+ readonly module: RouteModule<Data, Context>
65
+ readonly loaderData: Data | undefined
66
+ }
67
+
68
+ export interface RouteRuntimeContextOptions {
69
+ readonly purpose: RequestPurpose
70
+ readonly mode?: DocumentMode
71
+ }
72
+
73
+ export interface RouteLoadOptions<Context = unknown> {
74
+ readonly context?: Context
75
+ readonly mode?: DocumentMode
76
+ }
77
+
78
+ export interface RouteRuntime<
79
+ Context = unknown,
80
+ Route extends RouteDefinition = RouteDefinition,
81
+ > {
82
+ readonly app: AppDefinition<Route>
83
+ readonly importRoute: RouteImporter<unknown, Context>
84
+ readonly match: (
85
+ url: string | URL,
86
+ options?: MatchRouteOptions,
87
+ ) => Match<string, Route> | null
88
+ readonly createRequestContext: (
89
+ request: Request,
90
+ options: RouteRuntimeContextOptions,
91
+ ) => Promise<Context | undefined>
92
+ readonly loadRoute: (
93
+ request: Request,
94
+ options?: RouteLoadOptions<Context>,
95
+ ) => Promise<LoadedRoute<unknown, Context, Route> | null>
96
+ readonly loadRouteData: (request: Request) => Promise<Response>
97
+ }
98
+
99
+ export interface RouteRuntimeOptions<
100
+ Context = unknown,
101
+ Route extends RouteDefinition = RouteDefinition,
102
+ > {
103
+ readonly app: AppDefinition<Route>
104
+ readonly importRoute: RouteImporter<unknown, Context>
105
+ /** Build request context for data requests and document router queries. */
106
+ readonly requestContext?: RequestContextFactory<Context, Route>
107
+ }
108
+
109
+ async function loadMatchedRoute<
110
+ Data = unknown,
111
+ Context = unknown,
112
+ Route extends RouteDefinition = RouteDefinition,
113
+ >(
114
+ match: Match<string, Route>,
115
+ request: Request,
116
+ importRoute: RouteImporter<Data, Context>,
117
+ context?: Context,
118
+ ): Promise<LoadedRoute<Data, Context, Route>> {
119
+ const routeModule = await importRoute(match.data.entry)
120
+ const loaderData = routeModule.loader
121
+ ? await routeModule.loader({
122
+ request,
123
+ params: match.params,
124
+ context: context as Context,
125
+ })
126
+ : undefined
127
+
128
+ return {
129
+ route: match.data,
130
+ module: routeModule,
131
+ loaderData,
132
+ }
133
+ }
134
+
135
+ export function createRouteRuntime<
136
+ Context = unknown,
137
+ Route extends RouteDefinition = RouteDefinition,
138
+ >(options: RouteRuntimeOptions<Context, Route>): RouteRuntime<Context, Route> {
139
+ const createRequestContext = async (
140
+ request: Request,
141
+ contextOptions: RouteRuntimeContextOptions,
142
+ match = options.app.match(request.url),
143
+ ): Promise<Context | undefined> => {
144
+ if (!options.requestContext) {
145
+ return undefined
146
+ }
147
+
148
+ return options.requestContext({
149
+ request,
150
+ route: match?.data ?? null,
151
+ params: match?.params ?? {},
152
+ purpose: contextOptions.purpose,
153
+ ...(contextOptions.mode === undefined
154
+ ? {}
155
+ : { mode: contextOptions.mode }),
156
+ })
157
+ }
158
+
159
+ const loadRouteForRequest = async (
160
+ request: Request,
161
+ loadOptions: RouteLoadOptions<Context> = {},
162
+ ): Promise<LoadedRoute<unknown, Context, Route> | null> => {
163
+ const sanitizedRequest = stripFlamefrontProtocolRequest(request)
164
+ const match = options.app.match(sanitizedRequest.url)
165
+
166
+ if (!match) {
167
+ return null
168
+ }
169
+
170
+ const context =
171
+ "context" in loadOptions
172
+ ? loadOptions.context
173
+ : await createRequestContext(
174
+ sanitizedRequest,
175
+ { purpose: "data", mode: loadOptions.mode },
176
+ match,
177
+ )
178
+
179
+ return loadMatchedRoute(
180
+ match,
181
+ sanitizedRequest,
182
+ options.importRoute,
183
+ context,
184
+ )
185
+ }
186
+
187
+ const loadRouteData = async (request: Request): Promise<Response> => {
188
+ const routeUrl = new URL(request.url).searchParams.get("url")
189
+
190
+ if (!routeUrl) {
191
+ return new Response("Missing route URL.", { status: 400 })
192
+ }
193
+
194
+ const loaded = await loadRouteForRequest(
195
+ stripFlamefrontProtocolRequest(
196
+ new Request(routeUrl, {
197
+ method: "GET",
198
+ headers: request.headers,
199
+ signal: request.signal,
200
+ }),
201
+ ),
202
+ )
203
+
204
+ if (!loaded) {
205
+ return new Response("Not found.", { status: 404 })
206
+ }
207
+
208
+ return Response.json(loaded.loaderData ?? null)
209
+ }
210
+
211
+ return {
212
+ app: options.app,
213
+ importRoute: options.importRoute,
214
+ match: options.app.match,
215
+ createRequestContext: (request, contextOptions) =>
216
+ createRequestContext(request, contextOptions),
217
+ loadRoute: loadRouteForRequest,
218
+ loadRouteData,
219
+ }
220
+ }
221
+
222
+ export async function loadRoute<
223
+ Data = unknown,
224
+ Context = unknown,
225
+ Route extends RouteDefinition = RouteDefinition,
226
+ >(
227
+ app: AppDefinition<Route>,
228
+ request: Request,
229
+ importRoute: (entry: string) => Promise<RouteModule<Data, Context>>,
230
+ context?: Context,
231
+ ): Promise<LoadedRoute<Data, Context, Route> | null> {
232
+ const sanitizedRequest = stripFlamefrontProtocolRequest(request)
233
+ const match = app.match(sanitizedRequest.url)
234
+
235
+ if (!match) {
236
+ return null
237
+ }
238
+
239
+ return loadMatchedRoute(match, sanitizedRequest, importRoute, context)
240
+ }
package/src/srvx.ts ADDED
@@ -0,0 +1,314 @@
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
+ joinBasename,
8
+ stripBasename,
9
+ type AppDefinition,
10
+ type RouteDefinition,
11
+ } from "./index.ts"
12
+ import type { OctaneDocuments } from "./octane.ts"
13
+ import type { DocumentMode, RenderedDocument } from "./server.ts"
14
+ import {
15
+ isStaticFragmentRequest,
16
+ stripFlamefrontProtocolRequest,
17
+ } from "./fragment-protocol.ts"
18
+ import { staticRouteFragmentDataFile } from "./static-fragment-artifacts.ts"
19
+ import type { StaticFragmentArtifact } from "./fragment-client.ts"
20
+
21
+ export type SrvxMiddleware = ServerMiddleware
22
+
23
+ export interface TemplateContext<
24
+ Route extends RouteDefinition = RouteDefinition,
25
+ > {
26
+ readonly request: Request
27
+ readonly route: Route | null
28
+ readonly mode: DocumentMode
29
+ }
30
+
31
+ export type TemplateLoader<Route extends RouteDefinition = RouteDefinition> = (
32
+ context: TemplateContext<Route>,
33
+ ) => string | Promise<string>
34
+
35
+ /** Client asset location and the optional replacement for template lookup. */
36
+ export interface ServerAssets<Route extends RouteDefinition = RouteDefinition> {
37
+ readonly clientDirectory: string | URL
38
+ readonly loadTemplate?: TemplateLoader<Route>
39
+ }
40
+
41
+ export interface ResponseHeadersContext<
42
+ Route extends RouteDefinition = RouteDefinition,
43
+ > {
44
+ readonly request: Request
45
+ readonly route: Route | null
46
+ readonly mode: DocumentMode
47
+ readonly document: RenderedDocument
48
+ }
49
+
50
+ export type ResponseHeaders = HeadersInit
51
+
52
+ /** Add or override response headers after document rendering. */
53
+ export type ResponseHeadersHook<
54
+ Route extends RouteDefinition = RouteDefinition,
55
+ > = (
56
+ context: ResponseHeadersContext<Route>,
57
+ ) => ResponseHeaders | Promise<ResponseHeaders>
58
+
59
+ /** Lifecycle operations exposed alongside the srvx server options. */
60
+ export interface ServerEntryLifecycle {
61
+ readonly renderDocument: OctaneDocuments["renderDocument"]
62
+ readonly loadRouteData: OctaneDocuments["loadRouteData"]
63
+ readonly renderFragment?: OctaneDocuments["renderFragment"]
64
+ }
65
+
66
+ /** The single default-export value consumed by Flamefront's lifecycle. */
67
+ export type FlamefrontServerEntry = ServerOptions & ServerEntryLifecycle
68
+
69
+ /** Inputs for composing the transport around an Octane document service. */
70
+ export interface SrvxServerEntryOptions<
71
+ Route extends RouteDefinition = RouteDefinition,
72
+ > {
73
+ readonly app: AppDefinition<Route>
74
+ readonly documents: Pick<
75
+ OctaneDocuments,
76
+ "renderDocument" | "loadRouteData"
77
+ > &
78
+ Partial<Pick<OctaneDocuments, "renderFragment">>
79
+ readonly assets: ServerAssets<Route>
80
+ /** Applied outermost first, in declaration order, around framework transport. */
81
+ readonly middleware?: readonly SrvxMiddleware[]
82
+ readonly headers?: ResponseHeadersHook<Route>
83
+ }
84
+
85
+ function asPath(directory: string | URL): string {
86
+ return directory instanceof URL ? fileURLToPath(directory) : directory
87
+ }
88
+
89
+ async function loadDefaultTemplate(clientDirectory: string): Promise<string> {
90
+ let lastError: unknown
91
+ const candidates = [
92
+ resolve(clientDirectory, "..", "server", "index.html"),
93
+ resolve(clientDirectory, "index.html"),
94
+ resolve(clientDirectory, "..", "index.html"),
95
+ ]
96
+
97
+ for (const filename of candidates) {
98
+ try {
99
+ return await readFile(filename, "utf8")
100
+ } catch (error) {
101
+ lastError = error
102
+ }
103
+ }
104
+
105
+ throw lastError
106
+ }
107
+
108
+ function mergeHeaders(target: Headers, source: HeadersInit | undefined): void {
109
+ if (source === undefined) {
110
+ return
111
+ }
112
+
113
+ for (const [name, value] of new Headers(source)) {
114
+ target.set(name, value)
115
+ }
116
+ }
117
+
118
+ function staticRequest(request: Request, basename: string): Request {
119
+ if (
120
+ basename === "/" ||
121
+ (request.method !== "GET" && request.method !== "HEAD")
122
+ ) {
123
+ return request
124
+ }
125
+
126
+ const url = new URL(request.url)
127
+ const pathname = stripBasename(url.pathname, basename)
128
+
129
+ if (!pathname || pathname === url.pathname) {
130
+ return request
131
+ }
132
+
133
+ url.pathname = pathname
134
+ return new Request(url, request)
135
+ }
136
+
137
+ /**
138
+ * Compose the srvx transport and the mode-aware document/data lifecycle into
139
+ * the one default server entry consumed by Flamefront's lifecycle.
140
+ */
141
+ export function createSrvxServerEntry<
142
+ Route extends RouteDefinition = RouteDefinition,
143
+ >(options: SrvxServerEntryOptions<Route>): FlamefrontServerEntry {
144
+ const clientDirectory = asPath(options.assets.clientDirectory)
145
+ const loadTemplate =
146
+ options.assets.loadTemplate ?? (() => loadDefaultTemplate(clientDirectory))
147
+ const serveClientFile = staticMiddleware({ dir: clientDirectory })
148
+ const defaultClientRoute = options.app.routes.find(
149
+ (route) => route.render === "client",
150
+ )
151
+
152
+ const frameworkMiddleware: SrvxMiddleware = (request, next) => {
153
+ const url = new URL(request.url)
154
+
155
+ if (isStaticFragmentRequest(url)) {
156
+ return next()
157
+ }
158
+
159
+ if (url.pathname === options.app.routing.dataPath) {
160
+ return next()
161
+ }
162
+
163
+ const match = options.app.match(url)
164
+
165
+ if (url.pathname === options.app.routing.basename && !match) {
166
+ return next()
167
+ }
168
+
169
+ if (match?.data.render === "client" || match?.data.render === "server") {
170
+ return next()
171
+ }
172
+
173
+ return serveClientFile(
174
+ staticRequest(request, options.app.routing.basename),
175
+ next,
176
+ )
177
+ }
178
+
179
+ const fetch = async (request: Request): Promise<Response> => {
180
+ const url = new URL(request.url)
181
+ const match = options.app.match(url)
182
+
183
+ try {
184
+ if (
185
+ url.pathname === options.app.routing.basename &&
186
+ !match &&
187
+ defaultClientRoute
188
+ ) {
189
+ return new Response(null, {
190
+ status: 302,
191
+ headers: {
192
+ Location: joinBasename(
193
+ options.app.routing.basename,
194
+ defaultClientRoute.path,
195
+ ),
196
+ },
197
+ })
198
+ }
199
+
200
+ if (url.pathname === options.app.routing.dataPath) {
201
+ return options.documents.loadRouteData(request)
202
+ }
203
+
204
+ if (isStaticFragmentRequest(url)) {
205
+ const sanitizedRequest = stripFlamefrontProtocolRequest(request)
206
+ const fragmentMatch = options.app.match(sanitizedRequest.url)
207
+
208
+ if (!fragmentMatch || fragmentMatch.data.render !== "static") {
209
+ return new Response("Not found", { status: 404 })
210
+ }
211
+
212
+ let artifact: StaticFragmentArtifact | undefined
213
+
214
+ try {
215
+ artifact = JSON.parse(
216
+ await readFile(
217
+ staticRouteFragmentDataFile(clientDirectory, fragmentMatch.data),
218
+ "utf8",
219
+ ),
220
+ ) as StaticFragmentArtifact
221
+ } catch (error) {
222
+ if ((error as { code?: string }).code !== "ENOENT") {
223
+ throw error
224
+ }
225
+
226
+ if (!options.documents.renderFragment) {
227
+ return new Response("Not found", { status: 404 })
228
+ }
229
+
230
+ artifact = await options.documents.renderFragment(sanitizedRequest)
231
+ }
232
+
233
+ const responseHeaders = new Headers({
234
+ "Content-Type":
235
+ "application/vnd.flamefront.fragment+json; charset=utf-8",
236
+ })
237
+
238
+ if (options.headers) {
239
+ mergeHeaders(
240
+ responseHeaders,
241
+ await options.headers({
242
+ request: sanitizedRequest,
243
+ route: fragmentMatch.data,
244
+ mode: "static",
245
+ document: {
246
+ html: artifact.html,
247
+ routeData: artifact.routeData,
248
+ status: artifact.status,
249
+ },
250
+ }),
251
+ )
252
+ }
253
+
254
+ return new Response(JSON.stringify(artifact), {
255
+ status: artifact.status ?? 200,
256
+ headers: responseHeaders,
257
+ })
258
+ }
259
+
260
+ if (!match) {
261
+ return new Response("Not found", { status: 404 })
262
+ }
263
+
264
+ const mode: DocumentMode = url.searchParams.has("__flamefront_shell")
265
+ ? "shell"
266
+ : match.data.render
267
+ const template = await loadTemplate({
268
+ request,
269
+ route: match.data,
270
+ mode,
271
+ })
272
+ const document = await options.documents.renderDocument(
273
+ template,
274
+ request,
275
+ { mode },
276
+ )
277
+ const responseHeaders = new Headers({
278
+ "Content-Type": "text/html; charset=utf-8",
279
+ })
280
+
281
+ mergeHeaders(responseHeaders, document.headers)
282
+ if (options.headers) {
283
+ mergeHeaders(
284
+ responseHeaders,
285
+ await options.headers({
286
+ request,
287
+ route: match.data,
288
+ mode,
289
+ document,
290
+ }),
291
+ )
292
+ }
293
+
294
+ return new Response(document.html, {
295
+ status: document.status ?? 200,
296
+ headers: responseHeaders,
297
+ })
298
+ } catch (error) {
299
+ if (error instanceof Response) {
300
+ return error
301
+ }
302
+
303
+ throw error
304
+ }
305
+ }
306
+
307
+ return {
308
+ fetch,
309
+ middleware: [...(options.middleware ?? []), frameworkMiddleware],
310
+ renderDocument: options.documents.renderDocument,
311
+ loadRouteData: options.documents.loadRouteData,
312
+ renderFragment: options.documents.renderFragment,
313
+ }
314
+ }
@@ -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
+ }
@@ -0,0 +1,20 @@
1
+ declare module "virtual:flamefront/remix-routes" {
2
+ import type { RouteObject } from "@octanejs/remix-router"
3
+ import type { RouterDocument as RouterDocumentComponent } from "./octane.ts"
4
+ import type {
5
+ GeneratedRouteMetadata,
6
+ NormalizedRoutingOptions,
7
+ } from "./index.ts"
8
+
9
+ export const RouterDocument: RouterDocumentComponent
10
+ export const routes: RouteObject[]
11
+ export const routing: NormalizedRoutingOptions
12
+ export const routeMetadata: readonly GeneratedRouteMetadata[]
13
+ export function preloadRoute(entry: string): Promise<void>
14
+ }
15
+
16
+ declare module "virtual:flamefront/server-routes" {
17
+ import type { RouteModule } from "./server.ts"
18
+
19
+ export function importRoute(entry: string): Promise<RouteModule>
20
+ }