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.
- package/LICENSE.md +110 -0
- package/README.md +58 -0
- package/bin/ff-loader.mjs +18 -0
- package/bin/ff.js +6 -0
- package/package.json +84 -8
- package/src/babel.ts +22 -0
- package/src/cli.ts +98 -0
- package/src/entry.ts +49 -0
- package/src/fetch.ts +280 -0
- package/src/fragment-client.ts +271 -0
- package/src/fragment-hydration-client.tsx +81 -0
- package/src/fragment-protocol.ts +39 -0
- package/src/fragment.tsx +656 -0
- package/src/glob.ts +294 -0
- package/src/identifier-prefix.ts +5 -0
- package/src/index.ts +1168 -0
- package/src/lifecycle.ts +487 -0
- package/src/octane-client-core.ts +141 -0
- package/src/octane-client.ts +48 -0
- package/src/octane-compiler.d.ts +19 -0
- package/src/octane-default-renderer.tsx +124 -0
- package/src/octane-router-document.ts +22 -0
- package/src/octane.tsx +661 -0
- package/src/output.ts +48 -0
- package/src/remix-route-data.ts +76 -0
- package/src/remix-router-core.ts +106 -0
- package/src/remix-router.ts +117 -0
- package/src/remove-exports.ts +148 -0
- package/src/route-data-client.ts +234 -0
- package/src/route-prefetch.ts +79 -0
- package/src/server.ts +338 -0
- package/src/srvx.ts +174 -0
- package/src/static-fragment-artifacts.ts +86 -0
- package/src/typegen.ts +207 -0
- package/src/virtual-remix-routes.d.ts +35 -0
- package/src/vite.ts +1030 -0
- package/readme.md +0 -1
package/src/lifecycle.ts
ADDED
|
@@ -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
|
+
}
|