flamefront 0.1.0-alpha.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.
@@ -1,18 +1,19 @@
1
1
  import type {
2
- AppDefinition,
3
2
  LoadRouteOptions,
3
+ MatchRouteOptions,
4
4
  RouteDefinition,
5
+ RouteMatchForUrl,
5
6
  } from "./index.ts"
6
7
 
7
8
  /**
8
9
  * Resources that a route-aware prefetcher can warm without taking over
9
- * navigation. The framework adapter supplies the static fragment transport;
10
+ * navigation. The framework adapter supplies the route fragment transport;
10
11
  * callers can replace it when they own the transport.
11
12
  */
12
13
  export interface RoutePrefetchResources<
13
14
  Route extends RouteDefinition = RouteDefinition,
14
15
  > {
15
- readonly staticFragment?: (
16
+ readonly routeFragment?: (
16
17
  url: string | URL,
17
18
  route: Route,
18
19
  options?: LoadRouteOptions,
@@ -23,15 +24,21 @@ export type RouteModulePreloader = (entry: string) => void | Promise<void>
23
24
 
24
25
  export type RoutePrefetchCallback = (to: string) => void | Promise<void>
25
26
 
26
- type RoutePrefetchApp<Route extends RouteDefinition> = Pick<
27
- AppDefinition<Route>,
28
- "match" | "prefetch"
29
- >
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
+ }
30
37
 
31
38
  /**
32
- * Warm the resources used by a matched route. Live routes share route data
33
- * and client-module caches. Static routes hand off to the fragment resource
34
- * seam and never import their route module as a rendering path.
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.
35
42
  */
36
43
  export async function prefetchRouteResources<
37
44
  Route extends RouteDefinition = RouteDefinition,
@@ -48,8 +55,8 @@ export async function prefetchRouteResources<
48
55
  return
49
56
  }
50
57
 
51
- if (match.data.render === "static") {
52
- await resources.staticFragment?.(url, match.data, options)
58
+ if (match.data.render !== "client") {
59
+ await resources.routeFragment?.(url, match.data, options)
53
60
  return
54
61
  }
55
62
 
package/src/server.ts CHANGED
@@ -1,25 +1,50 @@
1
- import type { Match } from "@remix-run/route-pattern/match"
2
1
  import {
3
2
  type AppDefinition,
4
3
  type MatchRouteOptions,
4
+ type RouteLoaderFor,
5
5
  type RenderMode,
6
+ type RouteLoaderData,
7
+ type RouteMatchForUrl,
8
+ type RouteParams,
6
9
  type RouteDefinition,
7
10
  } from "./index.ts"
8
11
  import { stripFlamefrontProtocolRequest } from "./fragment-protocol.ts"
9
12
 
10
- export interface LoaderArgs<Context = unknown> {
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> {
11
28
  readonly request: Request
12
- readonly params: Readonly<Record<string, string | undefined>>
13
- readonly context: Context
29
+ readonly params: Readonly<
30
+ RouteParams<LoaderPath<ContextOrPath, PathOrContext>>
31
+ >
32
+ readonly context: LoaderContext<ContextOrPath, PathOrContext>
14
33
  }
15
34
 
16
- export type Loader<Data = unknown, Context = unknown> = (
17
- args: LoaderArgs<Context>,
18
- ) => Data | Promise<Data>
35
+ export type Loader<
36
+ Data = unknown,
37
+ Context = unknown,
38
+ Path extends string = string,
39
+ > = (args: LoaderArgs<Context, Path>) => Data | Promise<Data>
19
40
 
20
- export interface RouteModule<Data = unknown, Context = unknown> {
41
+ export interface RouteModule<
42
+ Data = unknown,
43
+ Context = unknown,
44
+ Path extends string = string,
45
+ > {
21
46
  readonly default: unknown
22
- readonly loader?: Loader<Data, Context>
47
+ readonly loader?: Loader<Data, Context, Path>
23
48
  }
24
49
 
25
50
  export type DocumentMode = "shell" | RenderMode
@@ -31,7 +56,7 @@ export interface RequestContextArgs<
31
56
  > {
32
57
  readonly request: Request
33
58
  readonly route: Route | null
34
- readonly params: Readonly<Record<string, string | undefined>>
59
+ readonly params: Readonly<RouteParams<Route["path"]>>
35
60
  readonly purpose: RequestPurpose
36
61
  readonly mode?: DocumentMode
37
62
  }
@@ -42,9 +67,11 @@ export type RequestContextFactory<
42
67
  > = (args: RequestContextArgs<Route>) => Context | Promise<Context>
43
68
 
44
69
  /** 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>>
70
+ export type RouteImporter<
71
+ Data = unknown,
72
+ Context = unknown,
73
+ Path extends string = string,
74
+ > = (entry: string) => Promise<RouteModule<Data, Context, Path>>
48
75
 
49
76
  export interface RenderedDocument {
50
77
  readonly html: string
@@ -65,6 +92,41 @@ export interface LoadedRoute<
65
92
  readonly loaderData: Data | undefined
66
93
  }
67
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
+
68
130
  export interface RouteRuntimeContextOptions {
69
131
  readonly purpose: RequestPurpose
70
132
  readonly mode?: DocumentMode
@@ -84,7 +146,7 @@ export interface RouteRuntime<
84
146
  readonly match: (
85
147
  url: string | URL,
86
148
  options?: MatchRouteOptions,
87
- ) => Match<string, Route> | null
149
+ ) => RouteMatchForUrl<Route, string> | null
88
150
  readonly createRequestContext: (
89
151
  request: Request,
90
152
  options: RouteRuntimeContextOptions,
@@ -92,7 +154,7 @@ export interface RouteRuntime<
92
154
  readonly loadRoute: (
93
155
  request: Request,
94
156
  options?: RouteLoadOptions<Context>,
95
- ) => Promise<LoadedRoute<unknown, Context, Route> | null>
157
+ ) => Promise<LoadedRouteFor<Route, Context> | null>
96
158
  readonly loadRouteData: (request: Request) => Promise<Response>
97
159
  }
98
160
 
@@ -111,7 +173,7 @@ async function loadMatchedRoute<
111
173
  Context = unknown,
112
174
  Route extends RouteDefinition = RouteDefinition,
113
175
  >(
114
- match: Match<string, Route>,
176
+ match: RouteMatchForUrl<Route, string>,
115
177
  request: Request,
116
178
  importRoute: RouteImporter<Data, Context>,
117
179
  context?: Context,
@@ -120,8 +182,8 @@ async function loadMatchedRoute<
120
182
  const loaderData = routeModule.loader
121
183
  ? await routeModule.loader({
122
184
  request,
123
- params: match.params,
124
- context: context as Context,
185
+ params: match.params as LoaderArgs<Context, string>["params"],
186
+ context: context as LoaderContext<Context, string>,
125
187
  })
126
188
  : undefined
127
189
 
@@ -132,6 +194,15 @@ async function loadMatchedRoute<
132
194
  }
133
195
  }
134
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
+
135
206
  export function createRouteRuntime<
136
207
  Context = unknown,
137
208
  Route extends RouteDefinition = RouteDefinition,
@@ -148,7 +219,7 @@ export function createRouteRuntime<
148
219
  return options.requestContext({
149
220
  request,
150
221
  route: match?.data ?? null,
151
- params: match?.params ?? {},
222
+ params: (match?.params ?? {}) as Readonly<RouteParams<Route["path"]>>,
152
223
  purpose: contextOptions.purpose,
153
224
  ...(contextOptions.mode === undefined
154
225
  ? {}
@@ -159,7 +230,7 @@ export function createRouteRuntime<
159
230
  const loadRouteForRequest = async (
160
231
  request: Request,
161
232
  loadOptions: RouteLoadOptions<Context> = {},
162
- ): Promise<LoadedRoute<unknown, Context, Route> | null> => {
233
+ ): Promise<LoadedRouteFor<Route, Context> | null> => {
163
234
  const sanitizedRequest = stripFlamefrontProtocolRequest(request)
164
235
  const match = options.app.match(sanitizedRequest.url)
165
236
 
@@ -181,7 +252,7 @@ export function createRouteRuntime<
181
252
  sanitizedRequest,
182
253
  options.importRoute,
183
254
  context,
184
- )
255
+ ) as Promise<LoadedRouteFor<Route, Context>>
185
256
  }
186
257
 
187
258
  const loadRouteData = async (request: Request): Promise<Response> => {
@@ -219,6 +290,28 @@ export function createRouteRuntime<
219
290
  }
220
291
  }
221
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
+
222
315
  export async function loadRoute<
223
316
  Data = unknown,
224
317
  Context = unknown,
@@ -226,9 +319,9 @@ export async function loadRoute<
226
319
  >(
227
320
  app: AppDefinition<Route>,
228
321
  request: Request,
229
- importRoute: (entry: string) => Promise<RouteModule<Data, Context>>,
322
+ importRoute: RouteImporter<Data, Context>,
230
323
  context?: Context,
231
- ): Promise<LoadedRoute<Data, Context, Route> | null> {
324
+ ): Promise<LoadedRouteFor<Route, Context> | null> {
232
325
  const sanitizedRequest = stripFlamefrontProtocolRequest(request)
233
326
  const match = app.match(sanitizedRequest.url)
234
327
 
@@ -236,5 +329,10 @@ export async function loadRoute<
236
329
  return null
237
330
  }
238
331
 
239
- return loadMatchedRoute(match, sanitizedRequest, importRoute, context)
332
+ return loadMatchedRoute(
333
+ match,
334
+ sanitizedRequest,
335
+ importRoute,
336
+ context,
337
+ ) as Promise<LoadedRouteFor<Route, Context>>
240
338
  }
package/src/srvx.ts CHANGED
@@ -4,33 +4,36 @@ import { resolve } from "node:path"
4
4
  import { staticMiddleware } from "srvx/static"
5
5
  import type { ServerMiddleware, ServerOptions } from "srvx"
6
6
  import {
7
- joinBasename,
8
7
  stripBasename,
9
8
  type AppDefinition,
10
9
  type RouteDefinition,
11
10
  } from "./index.ts"
12
- import type { OctaneDocuments } from "./octane.ts"
13
- import type { DocumentMode, RenderedDocument } from "./server.ts"
11
+ import { isRouteFragmentRequest } from "./fragment-protocol.ts"
14
12
  import {
15
- isStaticFragmentRequest,
16
- stripFlamefrontProtocolRequest,
17
- } from "./fragment-protocol.ts"
13
+ createFetchServerEntry,
14
+ type ResponseHeadersHook,
15
+ type ServerDocuments,
16
+ type ServerEntryLifecycle,
17
+ type TemplateLoader,
18
+ } from "./fetch.ts"
18
19
  import { staticRouteFragmentDataFile } from "./static-fragment-artifacts.ts"
19
- import type { StaticFragmentArtifact } from "./fragment-client.ts"
20
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
- }
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"
30
35
 
31
- export type TemplateLoader<Route extends RouteDefinition = RouteDefinition> = (
32
- context: TemplateContext<Route>,
33
- ) => string | Promise<string>
36
+ export type SrvxMiddleware = ServerMiddleware
34
37
 
35
38
  /** Client asset location and the optional replacement for template lookup. */
36
39
  export interface ServerAssets<Route extends RouteDefinition = RouteDefinition> {
@@ -38,30 +41,7 @@ export interface ServerAssets<Route extends RouteDefinition = RouteDefinition> {
38
41
  readonly loadTemplate?: TemplateLoader<Route>
39
42
  }
40
43
 
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
- }
44
+ export type { ServerEntryLifecycle }
65
45
 
66
46
  /** The single default-export value consumed by Flamefront's lifecycle. */
67
47
  export type FlamefrontServerEntry = ServerOptions & ServerEntryLifecycle
@@ -71,11 +51,7 @@ export interface SrvxServerEntryOptions<
71
51
  Route extends RouteDefinition = RouteDefinition,
72
52
  > {
73
53
  readonly app: AppDefinition<Route>
74
- readonly documents: Pick<
75
- OctaneDocuments,
76
- "renderDocument" | "loadRouteData"
77
- > &
78
- Partial<Pick<OctaneDocuments, "renderFragment">>
54
+ readonly documents: ServerDocuments
79
55
  readonly assets: ServerAssets<Route>
80
56
  /** Applied outermost first, in declaration order, around framework transport. */
81
57
  readonly middleware?: readonly SrvxMiddleware[]
@@ -105,16 +81,6 @@ async function loadDefaultTemplate(clientDirectory: string): Promise<string> {
105
81
  throw lastError
106
82
  }
107
83
 
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
84
  function staticRequest(request: Request, basename: string): Request {
119
85
  if (
120
86
  basename === "/" ||
@@ -145,14 +111,11 @@ export function createSrvxServerEntry<
145
111
  const loadTemplate =
146
112
  options.assets.loadTemplate ?? (() => loadDefaultTemplate(clientDirectory))
147
113
  const serveClientFile = staticMiddleware({ dir: clientDirectory })
148
- const defaultClientRoute = options.app.routes.find(
149
- (route) => route.render === "client",
150
- )
151
114
 
152
115
  const frameworkMiddleware: SrvxMiddleware = (request, next) => {
153
116
  const url = new URL(request.url)
154
117
 
155
- if (isStaticFragmentRequest(url)) {
118
+ if (isRouteFragmentRequest(url)) {
156
119
  return next()
157
120
  }
158
121
 
@@ -176,139 +139,36 @@ export function createSrvxServerEntry<
176
139
  )
177
140
  }
178
141
 
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
-
142
+ const fetchEntry = createFetchServerEntry({
143
+ app: options.app,
144
+ documents: options.documents,
145
+ assets: {
146
+ loadTemplate,
147
+ loadStaticFragment: async ({ route }) => {
214
148
  try {
215
- artifact = JSON.parse(
149
+ return JSON.parse(
216
150
  await readFile(
217
- staticRouteFragmentDataFile(clientDirectory, fragmentMatch.data),
151
+ staticRouteFragmentDataFile(clientDirectory, route),
218
152
  "utf8",
219
153
  ),
220
- ) as StaticFragmentArtifact
154
+ )
221
155
  } catch (error) {
222
156
  if ((error as { code?: string }).code !== "ENOENT") {
223
157
  throw error
224
158
  }
225
159
 
226
- if (!options.documents.renderFragment) {
227
- return new Response("Not found", { status: 404 })
228
- }
229
-
230
- artifact = await options.documents.renderFragment(sanitizedRequest)
160
+ return undefined
231
161
  }
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
- }
162
+ },
163
+ },
164
+ headers: options.headers,
165
+ })
306
166
 
307
167
  return {
308
- fetch,
168
+ fetch: fetchEntry.fetch,
309
169
  middleware: [...(options.middleware ?? []), frameworkMiddleware],
310
- renderDocument: options.documents.renderDocument,
311
- loadRouteData: options.documents.loadRouteData,
312
- renderFragment: options.documents.renderFragment,
170
+ renderDocument: fetchEntry.renderDocument,
171
+ loadRouteData: fetchEntry.loadRouteData,
172
+ renderFragment: fetchEntry.renderFragment,
313
173
  }
314
174
  }