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/LICENSE.md +110 -0
- package/README.md +643 -0
- package/bin/ff-loader.mjs +18 -0
- package/bin/ff.js +6 -0
- package/package.json +63 -10
- package/src/babel.ts +22 -0
- package/src/cli.ts +83 -0
- package/src/fragment-client.ts +231 -0
- package/src/fragment-protocol.ts +39 -0
- package/src/fragment.ts +260 -0
- package/src/index.ts +648 -0
- package/src/lifecycle.ts +486 -0
- package/src/octane-client-core.ts +134 -0
- package/src/octane-client.ts +42 -0
- package/src/octane-router-document.ts +5 -0
- package/src/octane.ts +654 -0
- package/src/remix-route-data.ts +68 -0
- package/src/remix-router-core.ts +106 -0
- package/src/remix-router.ts +111 -0
- package/src/remove-exports.ts +148 -0
- package/src/route-data-client.ts +224 -0
- package/src/route-prefetch.ts +72 -0
- package/src/server.ts +240 -0
- package/src/srvx.ts +314 -0
- package/src/static-fragment-artifacts.ts +86 -0
- package/src/virtual-remix-routes.d.ts +20 -0
- package/src/vite.ts +693 -0
- package/readme.md +0 -1
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
|
+
}
|