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,486 @@
1
+ import {
2
+ createServer as createHttpServer,
3
+ type IncomingMessage,
4
+ type ServerResponse,
5
+ } from "node:http"
6
+ import { access, mkdir, readFile, rm, writeFile } from "node:fs/promises"
7
+ import { dirname, relative, resolve } from "node:path"
8
+ import { pathToFileURL } from "node:url"
9
+ import { serve } from "srvx"
10
+ import type { ServerMiddleware } from "srvx"
11
+ import type {
12
+ AppDefinition,
13
+ NormalizedRoutingOptions,
14
+ RouteDefinition,
15
+ } from "./index.ts"
16
+ import { joinBasename } from "./index.ts"
17
+ import type { FlamefrontServerEntry } from "./srvx.ts"
18
+ import type { RenderDocumentResult } from "./server.ts"
19
+ import {
20
+ staticRouteFile,
21
+ staticRouteDataFile,
22
+ staticRouteFragmentFile,
23
+ staticRouteFragmentDataFile,
24
+ } from "./static-fragment-artifacts.ts"
25
+ import {
26
+ staticFragmentProtocol,
27
+ type StaticFragmentArtifact,
28
+ } from "./fragment-client.ts"
29
+
30
+ export {
31
+ staticRouteFile,
32
+ staticRouteDataFile,
33
+ staticRouteFragmentFile,
34
+ staticRouteFragmentDataFile,
35
+ } from "./static-fragment-artifacts.ts"
36
+
37
+ interface AppModule {
38
+ app?: AppDefinition
39
+ default?: AppDefinition
40
+ }
41
+
42
+ interface ServerModule {
43
+ default?: unknown
44
+ }
45
+
46
+ export interface ProjectContext {
47
+ readonly app: AppDefinition
48
+ readonly root: string
49
+ readonly routesFile: string
50
+ }
51
+
52
+ export async function loadProject(
53
+ root = process.cwd(),
54
+ ): Promise<ProjectContext> {
55
+ const routesFile = resolve(root, "src/app.ts")
56
+
57
+ try {
58
+ await access(routesFile)
59
+ } catch {
60
+ throw new Error(
61
+ `Could not find ${routesFile}. Run ff from an app with src/app.ts.`,
62
+ )
63
+ }
64
+
65
+ const url = pathToFileURL(routesFile)
66
+
67
+ url.searchParams.set("ff", String(Date.now()))
68
+ const module = (await import(url.href)) as AppModule
69
+ const app = module.app ?? module.default
70
+
71
+ if (!app || typeof app.shell !== "string" || !Array.isArray(app.routes)) {
72
+ throw new Error(
73
+ `${routesFile} must export an app with a shell and routes array.`,
74
+ )
75
+ }
76
+
77
+ return { app, root, routesFile }
78
+ }
79
+
80
+ function loadDefaultServerEntry(module: ServerModule): FlamefrontServerEntry {
81
+ const entry = module.default
82
+
83
+ if (
84
+ !entry ||
85
+ typeof entry !== "object" ||
86
+ typeof (entry as { fetch?: unknown }).fetch !== "function" ||
87
+ typeof (entry as { renderDocument?: unknown }).renderDocument !==
88
+ "function" ||
89
+ typeof (entry as { loadRouteData?: unknown }).loadRouteData !== "function"
90
+ ) {
91
+ throw new Error(
92
+ "src/entry-server.ts must default-export a Flamefront server entry with fetch, renderDocument, and loadRouteData.",
93
+ )
94
+ }
95
+
96
+ return entry as FlamefrontServerEntry
97
+ }
98
+
99
+ function toRequest(request: IncomingMessage, url: URL): Request {
100
+ const headers = new Headers()
101
+
102
+ for (const [name, value] of Object.entries(request.headers)) {
103
+ if (Array.isArray(value)) {
104
+ for (const item of value) {
105
+ headers.append(name, item)
106
+ }
107
+ } else if (value !== undefined) {
108
+ headers.set(name, value)
109
+ }
110
+ }
111
+
112
+ return new Request(url, {
113
+ method: request.method,
114
+ headers,
115
+ })
116
+ }
117
+
118
+ function send(
119
+ response: ServerResponse,
120
+ status: number,
121
+ body: string | Uint8Array,
122
+ contentType = "text/plain; charset=utf-8",
123
+ ): void {
124
+ response.statusCode = status
125
+ response.setHeader("Content-Type", contentType)
126
+ response.end(response.req.method === "HEAD" ? undefined : body)
127
+ }
128
+
129
+ async function sendFetchResponse(
130
+ response: ServerResponse,
131
+ fetchResponse: Response,
132
+ ): Promise<void> {
133
+ response.statusCode = fetchResponse.status
134
+ for (const [name, value] of fetchResponse.headers) {
135
+ response.setHeader(name, value)
136
+ }
137
+
138
+ response.end(
139
+ response.req.method === "HEAD"
140
+ ? undefined
141
+ : Buffer.from(await fetchResponse.arrayBuffer()),
142
+ )
143
+ }
144
+
145
+ async function loadBuiltServer(root: string): Promise<FlamefrontServerEntry> {
146
+ const serverFile = resolve(root, "dist/server/server.js")
147
+
148
+ try {
149
+ await access(serverFile)
150
+ } catch {
151
+ throw new Error(`Could not find ${serverFile}. Run ff build first.`)
152
+ }
153
+
154
+ return loadDefaultServerEntry(
155
+ (await import(pathToFileURL(serverFile).href)) as ServerModule,
156
+ )
157
+ }
158
+
159
+ function requestUrl(request: IncomingMessage): URL {
160
+ return new URL(
161
+ request.url ?? "/",
162
+ `http://${request.headers.host ?? "localhost"}`,
163
+ )
164
+ }
165
+
166
+ function documentParts(document: RenderDocumentResult): {
167
+ readonly html: string
168
+ readonly status: number
169
+ readonly hasRouteData: boolean
170
+ readonly routeData: unknown
171
+ } {
172
+ if (typeof document === "string") {
173
+ return { html: document, status: 200, hasRouteData: false, routeData: null }
174
+ }
175
+
176
+ return {
177
+ html: document.html,
178
+ status: document.status ?? 200,
179
+ hasRouteData: "routeData" in document,
180
+ routeData: document.routeData,
181
+ }
182
+ }
183
+
184
+ function concreteRoutePath(path: string): string {
185
+ return (
186
+ path
187
+ .split("/")
188
+ .map((segment) => {
189
+ if (segment.startsWith("*")) {
190
+ return "flamefront"
191
+ }
192
+
193
+ if (segment.startsWith(":")) {
194
+ return "flamefront"
195
+ }
196
+
197
+ return segment
198
+ })
199
+ .join("/") || "/"
200
+ )
201
+ }
202
+
203
+ function joinRoutePath(
204
+ routing: Pick<NormalizedRoutingOptions, "basename">,
205
+ path: string,
206
+ ): string {
207
+ return joinBasename(routing.basename, path)
208
+ }
209
+
210
+ async function listen(
211
+ server: ReturnType<typeof createHttpServer>,
212
+ port: number,
213
+ label: string,
214
+ ): Promise<void> {
215
+ await new Promise<void>((resolvePromise, reject) => {
216
+ server.once("error", reject)
217
+ server.listen(port, () => {
218
+ server.off("error", reject)
219
+ console.log(`${label}: http://localhost:${port}`)
220
+ resolvePromise()
221
+ })
222
+ })
223
+ }
224
+
225
+ export async function buildProject(root = process.cwd()): Promise<void> {
226
+ const { app } = await loadProject(root)
227
+ const { build } = await import("vite")
228
+ const dist = resolve(root, "dist")
229
+ const clientDirectory = resolve(dist, "client")
230
+
231
+ await rm(dist, { recursive: true, force: true })
232
+ await build({
233
+ root,
234
+ configFile: resolve(root, "vite.config.ts"),
235
+ build: {
236
+ outDir: clientDirectory,
237
+ manifest: true,
238
+ },
239
+ })
240
+ await build({
241
+ root,
242
+ configFile: resolve(root, "vite.config.ts"),
243
+ ssr: { noExternal: ["srvx"] },
244
+ build: {
245
+ ssr: resolve(root, "src/entry-server.ts"),
246
+ outDir: resolve(dist, "server"),
247
+ rollupOptions: {
248
+ output: { entryFileNames: "server.js" },
249
+ },
250
+ },
251
+ })
252
+
253
+ const clientTemplateFile = resolve(clientDirectory, "index.html")
254
+ const clientTemplate = await readFile(clientTemplateFile, "utf8")
255
+ const serverTemplateFile = resolve(dist, "server/index.html")
256
+
257
+ const serverEntry = await loadBuiltServer(root)
258
+
259
+ await writeFile(serverTemplateFile, clientTemplate)
260
+
261
+ const clientRoute = app.routes.find((route) => route.render === "client")
262
+
263
+ if (clientRoute) {
264
+ const shellPath = joinRoutePath(
265
+ app.routing,
266
+ concreteRoutePath(clientRoute.path),
267
+ )
268
+ const shellRequest = new Request(
269
+ new URL(`${shellPath}?__flamefront_shell=1`, "http://flamefront.build"),
270
+ )
271
+ const shell = documentParts(
272
+ await serverEntry.renderDocument(clientTemplate, shellRequest, {
273
+ mode: "shell",
274
+ }),
275
+ )
276
+
277
+ await writeFile(clientTemplateFile, shell.html)
278
+ }
279
+
280
+ const staticRoutes = app.routes.filter((route) => route.render === "static")
281
+
282
+ if (staticRoutes.length === 0) {
283
+ return
284
+ }
285
+
286
+ await prerenderStaticRoutes(
287
+ root,
288
+ clientDirectory,
289
+ staticRoutes,
290
+ (request) =>
291
+ serverEntry.renderDocument(clientTemplate, request, { mode: "static" }),
292
+ async (request) => {
293
+ const endpoint = new URL(app.routing.dataPath, request.url)
294
+
295
+ endpoint.searchParams.set("url", request.url)
296
+ const response = await serverEntry.loadRouteData(
297
+ new Request(endpoint, {
298
+ headers: request.headers,
299
+ signal: request.signal,
300
+ }),
301
+ )
302
+
303
+ if (!response.ok) {
304
+ throw response
305
+ }
306
+
307
+ return response.json()
308
+ },
309
+ app.routing,
310
+ serverEntry.renderFragment
311
+ ? (request) => serverEntry.renderFragment!(request)
312
+ : undefined,
313
+ )
314
+ }
315
+
316
+ export async function prerenderStaticRoutes(
317
+ root: string,
318
+ clientDirectory: string,
319
+ routes: readonly RouteDefinition[],
320
+ render: (request: Request) => Promise<RenderDocumentResult>,
321
+ loadData?: (request: Request) => Promise<unknown>,
322
+ routing: Pick<NormalizedRoutingOptions, "basename"> = { basename: "/" },
323
+ renderFragment?: (request: Request) => Promise<StaticFragmentArtifact>,
324
+ ): Promise<void> {
325
+ for (const route of routes) {
326
+ const outputFile = staticRouteFile(clientDirectory, route)
327
+ const outputDataFile = staticRouteDataFile(clientDirectory, route)
328
+ const outputFragmentFile = staticRouteFragmentFile(clientDirectory, route)
329
+ const outputFragmentDataFile = staticRouteFragmentDataFile(
330
+ clientDirectory,
331
+ route,
332
+ )
333
+ const request = new Request(
334
+ new URL(joinRoutePath(routing, route.path), "http://flamefront.build"),
335
+ )
336
+ const rendered = documentParts(await render(request))
337
+ const data = rendered.hasRouteData
338
+ ? rendered.routeData
339
+ : loadData
340
+ ? await loadData(request)
341
+ : null
342
+ const fragment = renderFragment
343
+ ? await renderFragment(request)
344
+ : ({
345
+ protocol: staticFragmentProtocol,
346
+ route: route.path,
347
+ boundary: route.entry,
348
+ html: rendered.html,
349
+ routeData: data,
350
+ boundaries: [],
351
+ status: rendered.status,
352
+ } satisfies StaticFragmentArtifact)
353
+
354
+ await mkdir(dirname(outputFile), { recursive: true })
355
+ await writeFile(outputFile, rendered.html)
356
+ await writeFile(outputDataFile, JSON.stringify(data ?? null))
357
+ await writeFile(outputFragmentFile, fragment.html)
358
+ await writeFile(outputFragmentDataFile, JSON.stringify(fragment))
359
+ console.log(`Generated ${relative(root, outputFile)}.`)
360
+ }
361
+ }
362
+
363
+ export async function devProject(
364
+ root = process.cwd(),
365
+ port = Number(process.env.PORT ?? 5173),
366
+ ): Promise<void> {
367
+ const { app } = await loadProject(root)
368
+ const { createServer } = await import("vite")
369
+ const vite = await createServer({
370
+ root,
371
+ appType: "spa",
372
+ server: { middlewareMode: true },
373
+ })
374
+ const server = createHttpServer(async (request, response) => {
375
+ const url = requestUrl(request)
376
+ const match = app.match(url)
377
+
378
+ try {
379
+ if (
380
+ url.pathname === app.routing.dataPath ||
381
+ match ||
382
+ url.pathname === app.routing.basename
383
+ ) {
384
+ const entry = loadDefaultServerEntry(
385
+ (await vite.ssrLoadModule("/src/entry-server.ts")) as ServerModule,
386
+ )
387
+ const entryServer = serve({
388
+ ...entry,
389
+ manual: true,
390
+ silent: true,
391
+ })
392
+
393
+ try {
394
+ await sendFetchResponse(
395
+ response,
396
+ await entryServer.fetch(toRequest(request, url)),
397
+ )
398
+ } finally {
399
+ await entryServer.close()
400
+ }
401
+
402
+ return
403
+ }
404
+ } catch (error) {
405
+ if (error instanceof Response) {
406
+ await sendFetchResponse(response, error)
407
+ return
408
+ }
409
+
410
+ vite.ssrFixStacktrace(error as Error)
411
+ console.error(error)
412
+ send(
413
+ response,
414
+ 500,
415
+ `<pre>${String((error as Error).stack ?? error)}</pre>`,
416
+ "text/html; charset=utf-8",
417
+ )
418
+ return
419
+ }
420
+
421
+ vite.middlewares(request, response, (error?: Error) => {
422
+ if (error) {
423
+ vite.ssrFixStacktrace(error)
424
+ console.error(error)
425
+ if (!response.headersSent) {
426
+ send(
427
+ response,
428
+ 500,
429
+ `<pre>${String(error.stack ?? error)}</pre>`,
430
+ "text/html; charset=utf-8",
431
+ )
432
+ }
433
+
434
+ return
435
+ }
436
+
437
+ if (!response.headersSent) {
438
+ send(response, 404, "Not found")
439
+ }
440
+ })
441
+ })
442
+
443
+ await listen(server, port, "Flamefront dev server")
444
+ const close = async () => {
445
+ server.close()
446
+ await vite.close()
447
+ }
448
+
449
+ process.once("SIGINT", close)
450
+ process.once("SIGTERM", close)
451
+ }
452
+
453
+ export async function previewProject(root = process.cwd()): Promise<void> {
454
+ const port = Number(process.env.PORT ?? 4173)
455
+ const checkToken = process.env.FLAMEFRONT_CHECK_TOKEN
456
+ const serverEntry = await loadBuiltServer(root)
457
+ const checkMiddleware = checkToken
458
+ ? async (_request: Request, next: () => Response | Promise<Response>) => {
459
+ const response = await next()
460
+
461
+ try {
462
+ response.headers.set("X-Flamefront-Check-Token", checkToken)
463
+ return response
464
+ } catch {
465
+ const headers = new Headers(response.headers)
466
+
467
+ headers.set("X-Flamefront-Check-Token", checkToken)
468
+ return new Response(response.body, {
469
+ headers,
470
+ status: response.status,
471
+ statusText: response.statusText,
472
+ })
473
+ }
474
+ }
475
+ : undefined
476
+ const server = serve({
477
+ ...serverEntry,
478
+ port,
479
+ gracefulShutdown: true,
480
+ middleware: [checkMiddleware, ...(serverEntry.middleware ?? [])].filter(
481
+ Boolean,
482
+ ) as ServerMiddleware[],
483
+ })
484
+
485
+ await server.ready()
486
+ }
@@ -0,0 +1,134 @@
1
+ import type { AppDefinition, RouteDefinition } from "./index.ts"
2
+ import type { RouterDocument, RouterDocumentProps } from "./octane.ts"
3
+
4
+ export type OctaneClientApp<Route extends RouteDefinition = RouteDefinition> =
5
+ Pick<AppDefinition<Route>, "match" | "prefetch">
6
+
7
+ export interface OctaneClientRouter {
8
+ readonly state: { readonly initialized: boolean }
9
+ subscribe(
10
+ subscriber: (state: { readonly initialized: boolean }) => void,
11
+ ): () => void
12
+ }
13
+
14
+ export interface StartOctaneClientOptions<
15
+ Route extends RouteDefinition = RouteDefinition,
16
+ Container = Element,
17
+ > {
18
+ readonly app: OctaneClientApp<Route>
19
+ /** Defaults to the current document's `#root` element. */
20
+ readonly root?: Container | null
21
+ /** Use the same override in `createOctaneDocuments` on the server. */
22
+ readonly routerDocument?: RouterDocument
23
+ }
24
+
25
+ export interface StartedOctaneClient<Router, Root> {
26
+ readonly router: Router
27
+ readonly root: Root
28
+ }
29
+
30
+ export interface OctaneClientRuntime<
31
+ Route extends RouteDefinition,
32
+ Router extends OctaneClientRouter,
33
+ Root,
34
+ Container,
35
+ HydrationData,
36
+ Prefetch,
37
+ > {
38
+ readonly pathname: string
39
+ readonly defaultRoot: Container | null
40
+ readonly routerDocument: RouterDocument
41
+ consumeHydrationData(): HydrationData
42
+ createRoutePrefetcher(app: OctaneClientApp<Route>): Prefetch
43
+ createClientRouter(options: {
44
+ readonly hydrationData: HydrationData
45
+ readonly prefetch: Prefetch
46
+ }): Router
47
+ renderRoot(
48
+ container: Container,
49
+ component: RouterDocument,
50
+ props: RouterDocumentProps,
51
+ ): Root
52
+ hydrateRoot(
53
+ container: Container,
54
+ component: RouterDocument,
55
+ props: RouterDocumentProps,
56
+ ): Root
57
+ }
58
+
59
+ async function waitForRouterInitialization(
60
+ router: OctaneClientRouter,
61
+ ): Promise<void> {
62
+ if (router.state.initialized) {
63
+ return
64
+ }
65
+
66
+ await new Promise<void>((resolve) => {
67
+ let unsubscribe: (() => void) | undefined
68
+ const finish = () => {
69
+ unsubscribe?.()
70
+ resolve()
71
+ }
72
+
73
+ unsubscribe = router.subscribe((state) => {
74
+ if (state.initialized) {
75
+ finish()
76
+ }
77
+ })
78
+
79
+ if (router.state.initialized) {
80
+ finish()
81
+ }
82
+ })
83
+ }
84
+
85
+ /** Internal dependency seam used by the browser adapter and focused tests. */
86
+ export async function startOctaneClientWithRuntime<
87
+ Route extends RouteDefinition,
88
+ Router extends OctaneClientRouter,
89
+ Root,
90
+ Container,
91
+ HydrationData,
92
+ Prefetch,
93
+ >(
94
+ options: StartOctaneClientOptions<Route, Container>,
95
+ runtime: OctaneClientRuntime<
96
+ Route,
97
+ Router,
98
+ Root,
99
+ Container,
100
+ HydrationData,
101
+ Prefetch
102
+ >,
103
+ ): Promise<StartedOctaneClient<Router, Root>> {
104
+ const root = options.root ?? runtime.defaultRoot
105
+
106
+ if (!root) {
107
+ throw new Error("Octane route shell is missing #root.")
108
+ }
109
+
110
+ const routeMatch = options.app.match(runtime.pathname)
111
+
112
+ if (!routeMatch) {
113
+ throw new Error(`No Flamefront route matches ${runtime.pathname}.`)
114
+ }
115
+
116
+ const hydrationData = runtime.consumeHydrationData()
117
+ const router = runtime.createClientRouter({
118
+ hydrationData,
119
+ prefetch: runtime.createRoutePrefetcher(options.app),
120
+ })
121
+ const shouldHydrate = routeMatch.data.render !== "client"
122
+
123
+ if (shouldHydrate) {
124
+ await waitForRouterInitialization(router)
125
+ }
126
+
127
+ const routerDocument = options.routerDocument ?? runtime.routerDocument
128
+ const props: RouterDocumentProps = { router, context: undefined }
129
+ const clientRoot = shouldHydrate
130
+ ? runtime.hydrateRoot(root, routerDocument, props)
131
+ : runtime.renderRoot(root, routerDocument, props)
132
+
133
+ return { router, root: clientRoot }
134
+ }
@@ -0,0 +1,42 @@
1
+ import type { DataRouter } from "@octanejs/remix-router"
2
+ import { createRoot, hydrateRoot, type Root } from "octane"
3
+ import type { RouteDefinition } from "./index.ts"
4
+ import {
5
+ startOctaneClientWithRuntime,
6
+ type StartOctaneClientOptions,
7
+ type StartedOctaneClient,
8
+ } from "./octane-client-core.ts"
9
+ import {
10
+ consumeStaticRouterHydrationData,
11
+ createClientRouter,
12
+ createRoutePrefetcher,
13
+ RouterDocument,
14
+ } from "./remix-router.ts"
15
+
16
+ export { RouterDocument }
17
+ export type { StartOctaneClientOptions, StartedOctaneClient }
18
+
19
+ /**
20
+ * Start Flamefront's generated Octane router, mounting client routes and
21
+ * hydrating server or static routes with the same router root used for SSR.
22
+ */
23
+ export function startOctaneClient<Route extends RouteDefinition>(
24
+ options: StartOctaneClientOptions<Route>,
25
+ ): Promise<StartedOctaneClient<DataRouter, Root>> {
26
+ return startOctaneClientWithRuntime(options, {
27
+ pathname: window.location.pathname,
28
+ defaultRoot: document.getElementById("root"),
29
+ routerDocument: RouterDocument,
30
+ consumeHydrationData: consumeStaticRouterHydrationData,
31
+ createRoutePrefetcher,
32
+ createClientRouter,
33
+ renderRoot(root, component, props) {
34
+ const clientRoot = createRoot(root)
35
+
36
+ clientRoot.render(component as never, props)
37
+ return clientRoot
38
+ },
39
+ hydrateRoot: (root, component, props) =>
40
+ hydrateRoot(root, component as never, props),
41
+ })
42
+ }
@@ -0,0 +1,5 @@
1
+ import { RouterProvider } from "@octanejs/remix-router/dom"
2
+ import type { RouterDocument as RouterDocumentComponent } from "./octane.ts"
3
+
4
+ /** Canonical router root shared by Flamefront's Octane server and client. */
5
+ export const RouterDocument = RouterProvider as RouterDocumentComponent