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