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
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
LoadRouteOptions,
|
|
3
|
+
MatchRouteOptions,
|
|
4
|
+
RouteDefinition,
|
|
5
|
+
RouteMatchForUrl,
|
|
6
|
+
} from "./index.ts"
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Resources that a route-aware prefetcher can warm without taking over
|
|
10
|
+
* navigation. The framework adapter supplies the route fragment transport;
|
|
11
|
+
* callers can replace it when they own the transport.
|
|
12
|
+
*/
|
|
13
|
+
export interface RoutePrefetchResources<
|
|
14
|
+
Route extends RouteDefinition = RouteDefinition,
|
|
15
|
+
> {
|
|
16
|
+
readonly routeFragment?: (
|
|
17
|
+
url: string | URL,
|
|
18
|
+
route: Route,
|
|
19
|
+
options?: LoadRouteOptions,
|
|
20
|
+
) => void | Promise<void>
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type RouteModulePreloader = (entry: string) => void | Promise<void>
|
|
24
|
+
|
|
25
|
+
export type RoutePrefetchCallback = (to: string) => void | Promise<void>
|
|
26
|
+
|
|
27
|
+
type RoutePrefetchApp<Route extends RouteDefinition> = {
|
|
28
|
+
readonly match: (
|
|
29
|
+
url: string | URL,
|
|
30
|
+
options?: MatchRouteOptions,
|
|
31
|
+
) => RouteMatchForUrl<Route, string> | null
|
|
32
|
+
readonly prefetch: (
|
|
33
|
+
url: string | URL,
|
|
34
|
+
options?: LoadRouteOptions,
|
|
35
|
+
) => Promise<void>
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Warm the resources used by a matched route. Client routes share route data
|
|
40
|
+
* and module caches. Server and static routes use the fragment resource and
|
|
41
|
+
* never import their route module as a browser rendering path.
|
|
42
|
+
*/
|
|
43
|
+
export async function prefetchRouteResources<
|
|
44
|
+
Route extends RouteDefinition = RouteDefinition,
|
|
45
|
+
>(
|
|
46
|
+
app: RoutePrefetchApp<Route>,
|
|
47
|
+
preloadRoute: RouteModulePreloader,
|
|
48
|
+
url: string | URL,
|
|
49
|
+
options: LoadRouteOptions = {},
|
|
50
|
+
resources: RoutePrefetchResources<Route> = {},
|
|
51
|
+
): Promise<void> {
|
|
52
|
+
const match = app.match(url)
|
|
53
|
+
|
|
54
|
+
if (!match) {
|
|
55
|
+
return
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (match.data.render !== "client") {
|
|
59
|
+
await resources.routeFragment?.(url, match.data, options)
|
|
60
|
+
return
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
await Promise.all([
|
|
64
|
+
app.prefetch(url, options),
|
|
65
|
+
preloadRoute(match.data.entry),
|
|
66
|
+
])
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Create the callback accepted by the generic browser router. */
|
|
70
|
+
export function createRoutePrefetchCallback<
|
|
71
|
+
Route extends RouteDefinition = RouteDefinition,
|
|
72
|
+
>(
|
|
73
|
+
app: RoutePrefetchApp<Route>,
|
|
74
|
+
preloadRoute: RouteModulePreloader,
|
|
75
|
+
resources: RoutePrefetchResources<Route> = {},
|
|
76
|
+
): RoutePrefetchCallback {
|
|
77
|
+
return (to) =>
|
|
78
|
+
prefetchRouteResources(app, preloadRoute, to, undefined, resources)
|
|
79
|
+
}
|
package/src/server.ts
ADDED
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type AppDefinition,
|
|
3
|
+
type MatchRouteOptions,
|
|
4
|
+
type RouteLoaderFor,
|
|
5
|
+
type RenderMode,
|
|
6
|
+
type RouteLoaderData,
|
|
7
|
+
type RouteMatchForUrl,
|
|
8
|
+
type RouteParams,
|
|
9
|
+
type RouteDefinition,
|
|
10
|
+
} from "./index.ts"
|
|
11
|
+
import { stripFlamefrontProtocolRequest } from "./fragment-protocol.ts"
|
|
12
|
+
|
|
13
|
+
type LoaderPath<ContextOrPath, PathOrContext> =
|
|
14
|
+
ContextOrPath extends `/${string}`
|
|
15
|
+
? ContextOrPath
|
|
16
|
+
: PathOrContext extends `/${string}`
|
|
17
|
+
? PathOrContext
|
|
18
|
+
: string
|
|
19
|
+
|
|
20
|
+
type LoaderContext<ContextOrPath, PathOrContext> =
|
|
21
|
+
ContextOrPath extends `/${string}`
|
|
22
|
+
? PathOrContext extends `/${string}`
|
|
23
|
+
? unknown
|
|
24
|
+
: PathOrContext
|
|
25
|
+
: ContextOrPath
|
|
26
|
+
|
|
27
|
+
export interface LoaderArgs<ContextOrPath = unknown, PathOrContext = unknown> {
|
|
28
|
+
readonly request: Request
|
|
29
|
+
readonly params: Readonly<
|
|
30
|
+
RouteParams<LoaderPath<ContextOrPath, PathOrContext>>
|
|
31
|
+
>
|
|
32
|
+
readonly context: LoaderContext<ContextOrPath, PathOrContext>
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export type Loader<
|
|
36
|
+
Data = unknown,
|
|
37
|
+
Context = unknown,
|
|
38
|
+
Path extends string = string,
|
|
39
|
+
> = (args: LoaderArgs<Context, Path>) => Data | Promise<Data>
|
|
40
|
+
|
|
41
|
+
export interface RouteModule<
|
|
42
|
+
Data = unknown,
|
|
43
|
+
Context = unknown,
|
|
44
|
+
Path extends string = string,
|
|
45
|
+
> {
|
|
46
|
+
readonly default: unknown
|
|
47
|
+
readonly loader?: Loader<Data, Context, Path>
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export type DocumentMode = "shell" | RenderMode
|
|
51
|
+
export type RequestPurpose = "data" | "document"
|
|
52
|
+
|
|
53
|
+
/** Inputs for constructing one request-scoped value for route work. */
|
|
54
|
+
export interface RequestContextArgs<
|
|
55
|
+
Route extends RouteDefinition = RouteDefinition,
|
|
56
|
+
> {
|
|
57
|
+
readonly request: Request
|
|
58
|
+
readonly route: Route | null
|
|
59
|
+
readonly params: Readonly<RouteParams<Route["path"]>>
|
|
60
|
+
readonly purpose: RequestPurpose
|
|
61
|
+
readonly mode?: DocumentMode
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export type RequestContextFactory<
|
|
65
|
+
Context = unknown,
|
|
66
|
+
Route extends RouteDefinition = RouteDefinition,
|
|
67
|
+
> = (args: RequestContextArgs<Route>) => Context | Promise<Context>
|
|
68
|
+
|
|
69
|
+
/** Import a generated or application-provided route module by its entry ID. */
|
|
70
|
+
export type RouteImporter<
|
|
71
|
+
Data = unknown,
|
|
72
|
+
Context = unknown,
|
|
73
|
+
Path extends string = string,
|
|
74
|
+
> = (entry: string) => Promise<RouteModule<Data, Context, Path>>
|
|
75
|
+
|
|
76
|
+
export interface RenderedDocument {
|
|
77
|
+
readonly html: string
|
|
78
|
+
readonly routeData?: unknown
|
|
79
|
+
readonly status?: number
|
|
80
|
+
readonly headers?: HeadersInit
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export type RenderDocumentResult = string | RenderedDocument
|
|
84
|
+
|
|
85
|
+
export interface LoadedRoute<
|
|
86
|
+
Data = unknown,
|
|
87
|
+
Context = unknown,
|
|
88
|
+
Route extends RouteDefinition = RouteDefinition,
|
|
89
|
+
> {
|
|
90
|
+
readonly route: Route
|
|
91
|
+
readonly module: RouteModule<Data, Context>
|
|
92
|
+
readonly loaderData: Data | undefined
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** A loaded route whose data follows the generated route-module map. */
|
|
96
|
+
export type LoadedRouteFor<
|
|
97
|
+
Route extends RouteDefinition,
|
|
98
|
+
Context = unknown,
|
|
99
|
+
> = Route extends RouteDefinition
|
|
100
|
+
? RouteLoaderFor<Route["path"]> extends (
|
|
101
|
+
...args: infer _Args
|
|
102
|
+
) => infer _Result
|
|
103
|
+
? Omit<
|
|
104
|
+
LoadedRoute<RouteLoaderData<Route["path"]>, Context, Route>,
|
|
105
|
+
"loaderData"
|
|
106
|
+
> & {
|
|
107
|
+
readonly loaderData: RouteLoaderData<Route["path"]>
|
|
108
|
+
}
|
|
109
|
+
: LoadedRoute<unknown, Context, Route>
|
|
110
|
+
: never
|
|
111
|
+
|
|
112
|
+
/** Route-module shape selected from one authored route definition. */
|
|
113
|
+
export type RouteModuleForRoute<
|
|
114
|
+
Route extends RouteDefinition,
|
|
115
|
+
Context = unknown,
|
|
116
|
+
> = Route extends RouteDefinition
|
|
117
|
+
? RouteLoaderFor<Route["path"]> extends (
|
|
118
|
+
...args: infer _Args
|
|
119
|
+
) => infer _Result
|
|
120
|
+
? RouteModule<RouteLoaderData<Route["path"]>, Context, Route["path"]>
|
|
121
|
+
: RouteModule<unknown, Context, Route["path"]>
|
|
122
|
+
: never
|
|
123
|
+
|
|
124
|
+
/** Importer shape for applications that own a typed route-module boundary. */
|
|
125
|
+
export type RouteImporterFor<
|
|
126
|
+
Route extends RouteDefinition,
|
|
127
|
+
Context = unknown,
|
|
128
|
+
> = (entry: Route["entry"]) => Promise<RouteModuleForRoute<Route, Context>>
|
|
129
|
+
|
|
130
|
+
export interface RouteRuntimeContextOptions {
|
|
131
|
+
readonly purpose: RequestPurpose
|
|
132
|
+
readonly mode?: DocumentMode
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export interface RouteLoadOptions<Context = unknown> {
|
|
136
|
+
readonly context?: Context
|
|
137
|
+
readonly mode?: DocumentMode
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export interface RouteRuntime<
|
|
141
|
+
Context = unknown,
|
|
142
|
+
Route extends RouteDefinition = RouteDefinition,
|
|
143
|
+
> {
|
|
144
|
+
readonly app: AppDefinition<Route>
|
|
145
|
+
readonly importRoute: RouteImporter<unknown, Context>
|
|
146
|
+
readonly match: (
|
|
147
|
+
url: string | URL,
|
|
148
|
+
options?: MatchRouteOptions,
|
|
149
|
+
) => RouteMatchForUrl<Route, string> | null
|
|
150
|
+
readonly createRequestContext: (
|
|
151
|
+
request: Request,
|
|
152
|
+
options: RouteRuntimeContextOptions,
|
|
153
|
+
) => Promise<Context | undefined>
|
|
154
|
+
readonly loadRoute: (
|
|
155
|
+
request: Request,
|
|
156
|
+
options?: RouteLoadOptions<Context>,
|
|
157
|
+
) => Promise<LoadedRouteFor<Route, Context> | null>
|
|
158
|
+
readonly loadRouteData: (request: Request) => Promise<Response>
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export interface RouteRuntimeOptions<
|
|
162
|
+
Context = unknown,
|
|
163
|
+
Route extends RouteDefinition = RouteDefinition,
|
|
164
|
+
> {
|
|
165
|
+
readonly app: AppDefinition<Route>
|
|
166
|
+
readonly importRoute: RouteImporter<unknown, Context>
|
|
167
|
+
/** Build request context for data requests and document router queries. */
|
|
168
|
+
readonly requestContext?: RequestContextFactory<Context, Route>
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function loadMatchedRoute<
|
|
172
|
+
Data = unknown,
|
|
173
|
+
Context = unknown,
|
|
174
|
+
Route extends RouteDefinition = RouteDefinition,
|
|
175
|
+
>(
|
|
176
|
+
match: RouteMatchForUrl<Route, string>,
|
|
177
|
+
request: Request,
|
|
178
|
+
importRoute: RouteImporter<Data, Context>,
|
|
179
|
+
context?: Context,
|
|
180
|
+
): Promise<LoadedRoute<Data, Context, Route>> {
|
|
181
|
+
const routeModule = await importRoute(match.data.entry)
|
|
182
|
+
const loaderData = routeModule.loader
|
|
183
|
+
? await routeModule.loader({
|
|
184
|
+
request,
|
|
185
|
+
params: match.params as LoaderArgs<Context, string>["params"],
|
|
186
|
+
context: context as LoaderContext<Context, string>,
|
|
187
|
+
})
|
|
188
|
+
: undefined
|
|
189
|
+
|
|
190
|
+
return {
|
|
191
|
+
route: match.data,
|
|
192
|
+
module: routeModule,
|
|
193
|
+
loaderData,
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export function createRouteRuntime<
|
|
198
|
+
Context = unknown,
|
|
199
|
+
Route extends RouteDefinition = RouteDefinition,
|
|
200
|
+
>(
|
|
201
|
+
options: Omit<RouteRuntimeOptions<Context, Route>, "importRoute"> & {
|
|
202
|
+
readonly importRoute: RouteImporterFor<Route, Context>
|
|
203
|
+
},
|
|
204
|
+
): RouteRuntime<Context, Route>
|
|
205
|
+
|
|
206
|
+
export function createRouteRuntime<
|
|
207
|
+
Context = unknown,
|
|
208
|
+
Route extends RouteDefinition = RouteDefinition,
|
|
209
|
+
>(options: RouteRuntimeOptions<Context, Route>): RouteRuntime<Context, Route> {
|
|
210
|
+
const createRequestContext = async (
|
|
211
|
+
request: Request,
|
|
212
|
+
contextOptions: RouteRuntimeContextOptions,
|
|
213
|
+
match = options.app.match(request.url),
|
|
214
|
+
): Promise<Context | undefined> => {
|
|
215
|
+
if (!options.requestContext) {
|
|
216
|
+
return undefined
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return options.requestContext({
|
|
220
|
+
request,
|
|
221
|
+
route: match?.data ?? null,
|
|
222
|
+
params: (match?.params ?? {}) as Readonly<RouteParams<Route["path"]>>,
|
|
223
|
+
purpose: contextOptions.purpose,
|
|
224
|
+
...(contextOptions.mode === undefined
|
|
225
|
+
? {}
|
|
226
|
+
: { mode: contextOptions.mode }),
|
|
227
|
+
})
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const loadRouteForRequest = async (
|
|
231
|
+
request: Request,
|
|
232
|
+
loadOptions: RouteLoadOptions<Context> = {},
|
|
233
|
+
): Promise<LoadedRouteFor<Route, Context> | null> => {
|
|
234
|
+
const sanitizedRequest = stripFlamefrontProtocolRequest(request)
|
|
235
|
+
const match = options.app.match(sanitizedRequest.url)
|
|
236
|
+
|
|
237
|
+
if (!match) {
|
|
238
|
+
return null
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const context =
|
|
242
|
+
"context" in loadOptions
|
|
243
|
+
? loadOptions.context
|
|
244
|
+
: await createRequestContext(
|
|
245
|
+
sanitizedRequest,
|
|
246
|
+
{ purpose: "data", mode: loadOptions.mode },
|
|
247
|
+
match,
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
return loadMatchedRoute(
|
|
251
|
+
match,
|
|
252
|
+
sanitizedRequest,
|
|
253
|
+
options.importRoute,
|
|
254
|
+
context,
|
|
255
|
+
) as Promise<LoadedRouteFor<Route, Context>>
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const loadRouteData = async (request: Request): Promise<Response> => {
|
|
259
|
+
const routeUrl = new URL(request.url).searchParams.get("url")
|
|
260
|
+
|
|
261
|
+
if (!routeUrl) {
|
|
262
|
+
return new Response("Missing route URL.", { status: 400 })
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const loaded = await loadRouteForRequest(
|
|
266
|
+
stripFlamefrontProtocolRequest(
|
|
267
|
+
new Request(routeUrl, {
|
|
268
|
+
method: "GET",
|
|
269
|
+
headers: request.headers,
|
|
270
|
+
signal: request.signal,
|
|
271
|
+
}),
|
|
272
|
+
),
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
if (!loaded) {
|
|
276
|
+
return new Response("Not found.", { status: 404 })
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
return Response.json(loaded.loaderData ?? null)
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
return {
|
|
283
|
+
app: options.app,
|
|
284
|
+
importRoute: options.importRoute,
|
|
285
|
+
match: options.app.match,
|
|
286
|
+
createRequestContext: (request, contextOptions) =>
|
|
287
|
+
createRequestContext(request, contextOptions),
|
|
288
|
+
loadRoute: loadRouteForRequest,
|
|
289
|
+
loadRouteData,
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
export function loadRoute<
|
|
294
|
+
_Data = unknown,
|
|
295
|
+
Context = unknown,
|
|
296
|
+
Route extends RouteDefinition = RouteDefinition,
|
|
297
|
+
>(
|
|
298
|
+
app: AppDefinition<Route>,
|
|
299
|
+
request: Request,
|
|
300
|
+
importRoute: RouteImporterFor<Route, Context>,
|
|
301
|
+
context?: Context,
|
|
302
|
+
): Promise<LoadedRouteFor<Route, Context> | null>
|
|
303
|
+
|
|
304
|
+
export function loadRoute<
|
|
305
|
+
Data = unknown,
|
|
306
|
+
Context = unknown,
|
|
307
|
+
Route extends RouteDefinition = RouteDefinition,
|
|
308
|
+
>(
|
|
309
|
+
app: AppDefinition<Route>,
|
|
310
|
+
request: Request,
|
|
311
|
+
importRoute: RouteImporter<Data, Context>,
|
|
312
|
+
context?: Context,
|
|
313
|
+
): Promise<LoadedRoute<Data, Context, Route> | null>
|
|
314
|
+
|
|
315
|
+
export async function loadRoute<
|
|
316
|
+
Data = unknown,
|
|
317
|
+
Context = unknown,
|
|
318
|
+
Route extends RouteDefinition = RouteDefinition,
|
|
319
|
+
>(
|
|
320
|
+
app: AppDefinition<Route>,
|
|
321
|
+
request: Request,
|
|
322
|
+
importRoute: RouteImporter<Data, Context>,
|
|
323
|
+
context?: Context,
|
|
324
|
+
): Promise<LoadedRouteFor<Route, Context> | null> {
|
|
325
|
+
const sanitizedRequest = stripFlamefrontProtocolRequest(request)
|
|
326
|
+
const match = app.match(sanitizedRequest.url)
|
|
327
|
+
|
|
328
|
+
if (!match) {
|
|
329
|
+
return null
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
return loadMatchedRoute(
|
|
333
|
+
match,
|
|
334
|
+
sanitizedRequest,
|
|
335
|
+
importRoute,
|
|
336
|
+
context,
|
|
337
|
+
) as Promise<LoadedRouteFor<Route, Context>>
|
|
338
|
+
}
|
package/src/srvx.ts
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
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
|
+
stripBasename,
|
|
8
|
+
type AppDefinition,
|
|
9
|
+
type RouteDefinition,
|
|
10
|
+
} from "./index.ts"
|
|
11
|
+
import { isRouteFragmentRequest } from "./fragment-protocol.ts"
|
|
12
|
+
import {
|
|
13
|
+
createFetchServerEntry,
|
|
14
|
+
type ResponseHeadersHook,
|
|
15
|
+
type ServerDocuments,
|
|
16
|
+
type ServerEntryLifecycle,
|
|
17
|
+
type TemplateLoader,
|
|
18
|
+
} from "./fetch.ts"
|
|
19
|
+
import { staticRouteFragmentDataFile } from "./static-fragment-artifacts.ts"
|
|
20
|
+
|
|
21
|
+
export type {
|
|
22
|
+
FetchMiddleware,
|
|
23
|
+
FetchServerAssets,
|
|
24
|
+
FetchServerEntryOptions,
|
|
25
|
+
FlamefrontFetchServerEntry,
|
|
26
|
+
ResponseHeaders,
|
|
27
|
+
ResponseHeadersContext,
|
|
28
|
+
ResponseHeadersHook,
|
|
29
|
+
ServerDocuments,
|
|
30
|
+
StaticFragmentContext,
|
|
31
|
+
StaticFragmentLoader,
|
|
32
|
+
TemplateContext,
|
|
33
|
+
TemplateLoader,
|
|
34
|
+
} from "./fetch.ts"
|
|
35
|
+
|
|
36
|
+
export type SrvxMiddleware = ServerMiddleware
|
|
37
|
+
|
|
38
|
+
/** Client asset location and the optional replacement for template lookup. */
|
|
39
|
+
export interface ServerAssets<Route extends RouteDefinition = RouteDefinition> {
|
|
40
|
+
readonly clientDirectory: string | URL
|
|
41
|
+
readonly loadTemplate?: TemplateLoader<Route>
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export type { ServerEntryLifecycle }
|
|
45
|
+
|
|
46
|
+
/** The single default-export value consumed by Flamefront's lifecycle. */
|
|
47
|
+
export type FlamefrontServerEntry = ServerOptions & ServerEntryLifecycle
|
|
48
|
+
|
|
49
|
+
/** Inputs for composing the transport around an Octane document service. */
|
|
50
|
+
export interface SrvxServerEntryOptions<
|
|
51
|
+
Route extends RouteDefinition = RouteDefinition,
|
|
52
|
+
> {
|
|
53
|
+
readonly app: AppDefinition<Route>
|
|
54
|
+
readonly documents: ServerDocuments
|
|
55
|
+
readonly assets: ServerAssets<Route>
|
|
56
|
+
/** Applied outermost first, in declaration order, around framework transport. */
|
|
57
|
+
readonly middleware?: readonly SrvxMiddleware[]
|
|
58
|
+
readonly headers?: ResponseHeadersHook<Route>
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function asPath(directory: string | URL): string {
|
|
62
|
+
return directory instanceof URL ? fileURLToPath(directory) : directory
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function loadDefaultTemplate(clientDirectory: string): Promise<string> {
|
|
66
|
+
let lastError: unknown
|
|
67
|
+
const candidates = [
|
|
68
|
+
resolve(clientDirectory, "..", "server", "index.html"),
|
|
69
|
+
resolve(clientDirectory, "index.html"),
|
|
70
|
+
resolve(clientDirectory, "..", "index.html"),
|
|
71
|
+
]
|
|
72
|
+
|
|
73
|
+
for (const filename of candidates) {
|
|
74
|
+
try {
|
|
75
|
+
return await readFile(filename, "utf8")
|
|
76
|
+
} catch (error) {
|
|
77
|
+
lastError = error
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
throw lastError
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function staticRequest(request: Request, basename: string): Request {
|
|
85
|
+
if (
|
|
86
|
+
basename === "/" ||
|
|
87
|
+
(request.method !== "GET" && request.method !== "HEAD")
|
|
88
|
+
) {
|
|
89
|
+
return request
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const url = new URL(request.url)
|
|
93
|
+
const pathname = stripBasename(url.pathname, basename)
|
|
94
|
+
|
|
95
|
+
if (!pathname || pathname === url.pathname) {
|
|
96
|
+
return request
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
url.pathname = pathname
|
|
100
|
+
return new Request(url, request)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Compose the srvx transport and the mode-aware document/data lifecycle into
|
|
105
|
+
* the one default server entry consumed by Flamefront's lifecycle.
|
|
106
|
+
*/
|
|
107
|
+
export function createSrvxServerEntry<
|
|
108
|
+
Route extends RouteDefinition = RouteDefinition,
|
|
109
|
+
>(options: SrvxServerEntryOptions<Route>): FlamefrontServerEntry {
|
|
110
|
+
const clientDirectory = asPath(options.assets.clientDirectory)
|
|
111
|
+
const loadTemplate =
|
|
112
|
+
options.assets.loadTemplate ?? (() => loadDefaultTemplate(clientDirectory))
|
|
113
|
+
const serveClientFile = staticMiddleware({ dir: clientDirectory })
|
|
114
|
+
|
|
115
|
+
const frameworkMiddleware: SrvxMiddleware = (request, next) => {
|
|
116
|
+
const url = new URL(request.url)
|
|
117
|
+
|
|
118
|
+
if (isRouteFragmentRequest(url)) {
|
|
119
|
+
return next()
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (url.pathname === options.app.routing.dataPath) {
|
|
123
|
+
return next()
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const match = options.app.match(url)
|
|
127
|
+
|
|
128
|
+
if (url.pathname === options.app.routing.basename && !match) {
|
|
129
|
+
return next()
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (match?.data.render === "client" || match?.data.render === "server") {
|
|
133
|
+
return next()
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return serveClientFile(
|
|
137
|
+
staticRequest(request, options.app.routing.basename),
|
|
138
|
+
next,
|
|
139
|
+
)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const fetchEntry = createFetchServerEntry({
|
|
143
|
+
app: options.app,
|
|
144
|
+
documents: options.documents,
|
|
145
|
+
assets: {
|
|
146
|
+
loadTemplate,
|
|
147
|
+
loadStaticFragment: async ({ route }) => {
|
|
148
|
+
try {
|
|
149
|
+
return JSON.parse(
|
|
150
|
+
await readFile(
|
|
151
|
+
staticRouteFragmentDataFile(clientDirectory, route),
|
|
152
|
+
"utf8",
|
|
153
|
+
),
|
|
154
|
+
)
|
|
155
|
+
} catch (error) {
|
|
156
|
+
if ((error as { code?: string }).code !== "ENOENT") {
|
|
157
|
+
throw error
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return undefined
|
|
161
|
+
}
|
|
162
|
+
},
|
|
163
|
+
},
|
|
164
|
+
headers: options.headers,
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
return {
|
|
168
|
+
fetch: fetchEntry.fetch,
|
|
169
|
+
middleware: [...(options.middleware ?? []), frameworkMiddleware],
|
|
170
|
+
renderDocument: fetchEntry.renderDocument,
|
|
171
|
+
loadRouteData: fetchEntry.loadRouteData,
|
|
172
|
+
renderFragment: fetchEntry.renderFragment,
|
|
173
|
+
}
|
|
174
|
+
}
|
|
@@ -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
|
+
}
|