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/src/fetch.ts ADDED
@@ -0,0 +1,280 @@
1
+ import {
2
+ joinBasename,
3
+ type AppDefinition,
4
+ type RouteDefinition,
5
+ } from "./index.ts"
6
+ import type { OctaneDocuments } from "./octane.tsx"
7
+ import type { DocumentMode, RenderedDocument } from "./server.ts"
8
+ import {
9
+ isRouteFragmentRequest,
10
+ stripFlamefrontProtocolRequest,
11
+ } from "./fragment-protocol.ts"
12
+ import type { RouteFragmentArtifact } from "./fragment-client.ts"
13
+
14
+ export interface TemplateContext<
15
+ Route extends RouteDefinition = RouteDefinition,
16
+ > {
17
+ readonly request: Request
18
+ readonly route: Route | null
19
+ readonly mode: DocumentMode
20
+ }
21
+
22
+ export type TemplateLoader<Route extends RouteDefinition = RouteDefinition> = (
23
+ context: TemplateContext<Route>,
24
+ ) => string | Promise<string>
25
+
26
+ export interface StaticFragmentContext<
27
+ Route extends RouteDefinition = RouteDefinition,
28
+ > {
29
+ readonly request: Request
30
+ readonly route: Route
31
+ }
32
+
33
+ /** Load a pre-rendered static fragment from the host's asset system. */
34
+ export type StaticFragmentLoader<
35
+ Route extends RouteDefinition = RouteDefinition,
36
+ > = (
37
+ context: StaticFragmentContext<Route>,
38
+ ) =>
39
+ RouteFragmentArtifact | undefined | Promise<RouteFragmentArtifact | undefined>
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
+ export type FetchMiddleware = (
60
+ request: Request,
61
+ next: () => Response | Promise<Response>,
62
+ ) => Response | Promise<Response>
63
+
64
+ export type ServerDocuments = Pick<
65
+ OctaneDocuments,
66
+ "renderDocument" | "loadRouteData"
67
+ > &
68
+ Partial<Pick<OctaneDocuments, "renderFragment">>
69
+
70
+ /** Lifecycle operations shared by all Flamefront server adapters. */
71
+ export interface ServerEntryLifecycle {
72
+ readonly renderDocument: OctaneDocuments["renderDocument"]
73
+ readonly loadRouteData: OctaneDocuments["loadRouteData"]
74
+ readonly renderFragment?: OctaneDocuments["renderFragment"]
75
+ }
76
+
77
+ /** A Web Fetch-compatible server entry. */
78
+ export interface FlamefrontFetchServerEntry extends ServerEntryLifecycle {
79
+ readonly fetch: (request: Request) => Response | Promise<Response>
80
+ }
81
+
82
+ export interface FetchServerAssets<
83
+ Route extends RouteDefinition = RouteDefinition,
84
+ > {
85
+ readonly loadTemplate: TemplateLoader<Route>
86
+ readonly loadStaticFragment?: StaticFragmentLoader<Route>
87
+ }
88
+
89
+ export interface FetchServerEntryOptions<
90
+ Route extends RouteDefinition = RouteDefinition,
91
+ > {
92
+ readonly app: AppDefinition<Route>
93
+ readonly documents: ServerDocuments
94
+ readonly assets: FetchServerAssets<Route>
95
+ /** Applied outermost first, in declaration order. */
96
+ readonly middleware?: readonly FetchMiddleware[]
97
+ readonly headers?: ResponseHeadersHook<Route>
98
+ }
99
+
100
+ function mergeHeaders(target: Headers, source: HeadersInit | undefined): void {
101
+ if (source === undefined) {
102
+ return
103
+ }
104
+
105
+ for (const [name, value] of new Headers(source)) {
106
+ target.set(name, value)
107
+ }
108
+ }
109
+
110
+ function composeMiddleware(
111
+ middleware: readonly FetchMiddleware[],
112
+ handler: (request: Request) => Promise<Response>,
113
+ ): (request: Request) => Promise<Response> {
114
+ return middleware.reduceRight<(request: Request) => Promise<Response>>(
115
+ (next, current) => async (request) => current(request, () => next(request)),
116
+ handler,
117
+ )
118
+ }
119
+
120
+ /**
121
+ * Create the transport-neutral Flamefront request handler. Hosts can expose
122
+ * the returned `fetch` function directly or wrap it in their own adapter.
123
+ */
124
+ export function createFetchServerEntry<
125
+ Route extends RouteDefinition = RouteDefinition,
126
+ >(options: FetchServerEntryOptions<Route>): FlamefrontFetchServerEntry {
127
+ const defaultClientRoute = options.app.routes.find(
128
+ (route) => route.render === "client",
129
+ )
130
+
131
+ const handleRequest = async (request: Request): Promise<Response> => {
132
+ const url = new URL(request.url)
133
+ const match = options.app.match(url)
134
+
135
+ try {
136
+ if (
137
+ url.pathname === options.app.routing.basename &&
138
+ !match &&
139
+ defaultClientRoute
140
+ ) {
141
+ return new Response(null, {
142
+ status: 302,
143
+ headers: {
144
+ Location: joinBasename(
145
+ options.app.routing.basename,
146
+ defaultClientRoute.path,
147
+ ),
148
+ },
149
+ })
150
+ }
151
+
152
+ if (url.pathname === options.app.routing.dataPath) {
153
+ return options.documents.loadRouteData(request)
154
+ }
155
+
156
+ if (isRouteFragmentRequest(url)) {
157
+ const sanitizedRequest = stripFlamefrontProtocolRequest(request)
158
+ const fragmentMatch = options.app.match(sanitizedRequest.url)
159
+
160
+ if (!fragmentMatch || fragmentMatch.data.render === "client") {
161
+ return new Response("Not found", { status: 404 })
162
+ }
163
+
164
+ let artifact: RouteFragmentArtifact | undefined
165
+
166
+ if (
167
+ fragmentMatch.data.render === "static" &&
168
+ options.assets.loadStaticFragment
169
+ ) {
170
+ artifact = await options.assets.loadStaticFragment({
171
+ request: sanitizedRequest,
172
+ route: fragmentMatch.data,
173
+ })
174
+ }
175
+
176
+ if (!artifact && options.documents.renderFragment) {
177
+ artifact = await options.documents.renderFragment(sanitizedRequest)
178
+ }
179
+
180
+ if (!artifact) {
181
+ return new Response("Not found", { status: 404 })
182
+ }
183
+
184
+ const responseHeaders = new Headers({
185
+ "Content-Type":
186
+ "application/vnd.flamefront.fragment+json; charset=utf-8",
187
+ })
188
+
189
+ if (options.headers) {
190
+ mergeHeaders(
191
+ responseHeaders,
192
+ await options.headers({
193
+ request: sanitizedRequest,
194
+ route: fragmentMatch.data,
195
+ mode: fragmentMatch.data.render,
196
+ document: {
197
+ html: artifact.html,
198
+ routeData: artifact.routeData,
199
+ status: artifact.status,
200
+ },
201
+ }),
202
+ )
203
+ }
204
+
205
+ return new Response(JSON.stringify(artifact), {
206
+ status: artifact.status ?? 200,
207
+ headers: responseHeaders,
208
+ })
209
+ }
210
+
211
+ if (!match) {
212
+ return new Response("Not found", { status: 404 })
213
+ }
214
+
215
+ const mode: DocumentMode = url.searchParams.has("__flamefront_shell")
216
+ ? "shell"
217
+ : match.data.render
218
+ const template = await options.assets.loadTemplate({
219
+ request,
220
+ route: match.data,
221
+ mode,
222
+ })
223
+ const document = await options.documents.renderDocument(
224
+ template,
225
+ request,
226
+ { mode },
227
+ )
228
+ const responseHeaders = new Headers({
229
+ "Content-Type": "text/html; charset=utf-8",
230
+ })
231
+
232
+ mergeHeaders(responseHeaders, document.headers)
233
+ if (options.headers) {
234
+ mergeHeaders(
235
+ responseHeaders,
236
+ await options.headers({
237
+ request,
238
+ route: match.data,
239
+ mode,
240
+ document,
241
+ }),
242
+ )
243
+ }
244
+
245
+ return new Response(document.html, {
246
+ status: document.status ?? 200,
247
+ headers: responseHeaders,
248
+ })
249
+ } catch (error) {
250
+ if (error instanceof Response) {
251
+ return error
252
+ }
253
+
254
+ throw error
255
+ }
256
+ }
257
+
258
+ const composedFetch = composeMiddleware(
259
+ options.middleware ?? [],
260
+ handleRequest,
261
+ )
262
+ const fetch = async (request: Request): Promise<Response> => {
263
+ try {
264
+ return await composedFetch(request)
265
+ } catch (error) {
266
+ if (error instanceof Response) {
267
+ return error
268
+ }
269
+
270
+ throw error
271
+ }
272
+ }
273
+
274
+ return {
275
+ fetch,
276
+ renderDocument: options.documents.renderDocument,
277
+ loadRouteData: options.documents.loadRouteData,
278
+ renderFragment: options.documents.renderFragment,
279
+ }
280
+ }
@@ -0,0 +1,271 @@
1
+ import type { HydrationMode, RouteBoundaryKind } from "./index.ts"
2
+ import {
3
+ stripFlamefrontProtocolParams,
4
+ withRouteFragmentProtocol,
5
+ } from "./fragment-protocol.ts"
6
+
7
+ export const routeFragmentProtocol = "flamefront-route-fragment-v1" as const
8
+
9
+ export type RouteFragmentCachePolicy = "server" | "static"
10
+
11
+ export interface RouteFragmentBoundary {
12
+ readonly id: string
13
+ readonly boundary: string
14
+ readonly kind: RouteBoundaryKind
15
+ readonly parent?: string
16
+ readonly html: string
17
+ }
18
+
19
+ export interface RouteFragmentArtifact {
20
+ readonly protocol: typeof routeFragmentProtocol
21
+ readonly route: string
22
+ readonly boundary: string
23
+ readonly html: string
24
+ readonly routeData: unknown
25
+ readonly boundaries: readonly RouteFragmentBoundary[]
26
+ readonly hydration?: HydrationMode
27
+ readonly status?: number
28
+ }
29
+
30
+ export interface RouteFragmentLoadOptions {
31
+ readonly policy: RouteFragmentCachePolicy
32
+ readonly signal?: AbortSignal
33
+ readonly reload?: boolean
34
+ }
35
+
36
+ export interface RouteFragmentRoutingOptions {
37
+ readonly basename?: string
38
+ }
39
+
40
+ export function shouldHydrateRouteFragment(
41
+ hydration: HydrationMode | undefined,
42
+ ): boolean {
43
+ return hydration !== "none"
44
+ }
45
+
46
+ const staticFragmentRequests = new Map<string, Promise<RouteFragmentArtifact>>()
47
+ const serverFragmentRequests = new Map<string, Promise<RouteFragmentArtifact>>()
48
+ const latestRouteFragments = new Map<string, RouteFragmentArtifact>()
49
+
50
+ function resolveRouteUrl(input: string | URL): URL {
51
+ const browserOrigin =
52
+ typeof location === "undefined" ? undefined : location.origin
53
+
54
+ if (!browserOrigin && typeof input === "string" && !URL.canParse(input)) {
55
+ throw new TypeError(
56
+ "flamefront route fragments require an absolute URL outside the browser.",
57
+ )
58
+ }
59
+
60
+ return stripFlamefrontProtocolParams(new URL(input, browserOrigin))
61
+ }
62
+
63
+ function basenamePath(pathname: string, basename: string): string | null {
64
+ if (basename === "/") {
65
+ return pathname
66
+ }
67
+
68
+ if (pathname === basename) {
69
+ return "/"
70
+ }
71
+
72
+ if (!pathname.startsWith(`${basename}/`)) {
73
+ return null
74
+ }
75
+
76
+ return pathname.slice(basename.length) || "/"
77
+ }
78
+
79
+ function staticFragmentKey(url: URL, basename: string): string {
80
+ const pathname = basenamePath(url.pathname, basename) ?? url.pathname
81
+
82
+ return `${url.origin}${pathname}`
83
+ }
84
+
85
+ function routeFragmentKey(url: URL, basename = "/"): string {
86
+ const pathname = basenamePath(url.pathname, basename) ?? url.pathname
87
+
88
+ return `${url.origin}${pathname}${url.search}${url.hash}`
89
+ }
90
+
91
+ function abortable<Data>(
92
+ pending: Promise<Data>,
93
+ signal: AbortSignal | undefined,
94
+ ): Promise<Data> {
95
+ if (!signal) {
96
+ return pending
97
+ }
98
+
99
+ if (signal.aborted) {
100
+ return Promise.reject(signal.reason)
101
+ }
102
+
103
+ return new Promise<Data>((resolve, reject) => {
104
+ const onAbort = () => reject(signal.reason)
105
+
106
+ signal.addEventListener("abort", onAbort, { once: true })
107
+ pending.then(
108
+ (value) => {
109
+ signal.removeEventListener("abort", onAbort)
110
+ resolve(value)
111
+ },
112
+ (error) => {
113
+ signal.removeEventListener("abort", onAbort)
114
+ reject(error)
115
+ },
116
+ )
117
+ })
118
+ }
119
+
120
+ export function isRouteFragmentArtifact(
121
+ value: unknown,
122
+ ): value is RouteFragmentArtifact {
123
+ if (!value || typeof value !== "object") {
124
+ return false
125
+ }
126
+
127
+ const artifact = value as Partial<RouteFragmentArtifact>
128
+
129
+ return (
130
+ artifact.protocol === routeFragmentProtocol &&
131
+ typeof artifact.route === "string" &&
132
+ typeof artifact.boundary === "string" &&
133
+ typeof artifact.html === "string" &&
134
+ Array.isArray(artifact.boundaries)
135
+ )
136
+ }
137
+
138
+ export function assertRouteFragmentArtifact(
139
+ value: unknown,
140
+ ): RouteFragmentArtifact {
141
+ if (!isRouteFragmentArtifact(value)) {
142
+ throw new Error(
143
+ "flamefront route fragment response has an invalid protocol.",
144
+ )
145
+ }
146
+
147
+ return value
148
+ }
149
+
150
+ export function getRouteFragment(
151
+ url: string | URL,
152
+ routing: RouteFragmentRoutingOptions = {},
153
+ ): RouteFragmentArtifact | undefined {
154
+ return latestRouteFragments.get(
155
+ routeFragmentKey(resolveRouteUrl(url), routing.basename ?? "/"),
156
+ )
157
+ }
158
+
159
+ function fetchRouteFragment(
160
+ routeUrl: URL,
161
+ basename: string,
162
+ signal: AbortSignal | undefined,
163
+ ): Promise<RouteFragmentArtifact> {
164
+ const endpoint = withRouteFragmentProtocol(routeUrl)
165
+
166
+ return globalThis
167
+ .fetch(endpoint, {
168
+ headers: { Accept: "application/vnd.flamefront.fragment+json" },
169
+ ...(signal ? { signal } : {}),
170
+ })
171
+ .then(async (response) => {
172
+ if (response.redirected) {
173
+ const location = stripFlamefrontProtocolParams(response.url)
174
+ const pathname =
175
+ basenamePath(location.pathname, basename) ?? location.pathname
176
+
177
+ throw new Response(null, {
178
+ status: 302,
179
+ headers: {
180
+ Location: `${pathname}${location.search}${location.hash}`,
181
+ },
182
+ })
183
+ }
184
+
185
+ if (response.status >= 300 && response.status < 400) {
186
+ throw response
187
+ }
188
+
189
+ let value: unknown
190
+
191
+ try {
192
+ value = await response.json()
193
+ } catch {
194
+ if (!response.ok) {
195
+ throw new Error(
196
+ `flamefront route fragment request failed with ${response.status}.`,
197
+ )
198
+ }
199
+
200
+ throw new Error(
201
+ "flamefront route fragment response has an invalid protocol.",
202
+ )
203
+ }
204
+
205
+ return assertRouteFragmentArtifact(value)
206
+ })
207
+ }
208
+
209
+ export function loadRouteFragment(
210
+ url: string | URL,
211
+ routing: RouteFragmentRoutingOptions = {},
212
+ options: RouteFragmentLoadOptions,
213
+ ): Promise<RouteFragmentArtifact> {
214
+ const routeUrl = resolveRouteUrl(url)
215
+ const handoffKey = routeFragmentKey(routeUrl, routing.basename ?? "/")
216
+ const requests =
217
+ options.policy === "static"
218
+ ? staticFragmentRequests
219
+ : serverFragmentRequests
220
+ const requestKey =
221
+ options.policy === "static"
222
+ ? staticFragmentKey(routeUrl, routing.basename ?? "/")
223
+ : routeUrl.href
224
+
225
+ if (options.reload) {
226
+ requests.delete(requestKey)
227
+ }
228
+
229
+ let pending = requests.get(requestKey)
230
+
231
+ if (!pending) {
232
+ pending = fetchRouteFragment(
233
+ routeUrl,
234
+ routing.basename ?? "/",
235
+ options.signal,
236
+ )
237
+ requests.set(requestKey, pending)
238
+
239
+ const evict = () => {
240
+ if (requests.get(requestKey) === pending) {
241
+ requests.delete(requestKey)
242
+ }
243
+ }
244
+
245
+ if (options.policy === "static") {
246
+ void pending.catch(evict)
247
+ } else {
248
+ void pending.then(evict, evict)
249
+ }
250
+ }
251
+
252
+ return abortable(pending, options.signal).then((artifact) => {
253
+ latestRouteFragments.set(handoffKey, artifact)
254
+ return artifact
255
+ })
256
+ }
257
+
258
+ export async function prefetchRouteFragment(
259
+ url: string | URL,
260
+ policy: RouteFragmentCachePolicy,
261
+ routing: RouteFragmentRoutingOptions = {},
262
+ options: Omit<RouteFragmentLoadOptions, "policy"> = {},
263
+ ): Promise<void> {
264
+ await loadRouteFragment(url, routing, { ...options, policy })
265
+ }
266
+
267
+ export type {
268
+ GeneratedHydration,
269
+ GeneratedRouteMetadata,
270
+ HydrationMode,
271
+ } from "./index.ts"
@@ -0,0 +1,81 @@
1
+ import {
2
+ createRoot,
3
+ hydrateRoot,
4
+ setDangerouslySetInnerHTML,
5
+ setHTML,
6
+ } from "octane"
7
+ import { outletIdentifierPrefix } from "./identifier-prefix.ts"
8
+
9
+ type RenderableComponent = (props: Record<string, unknown>) => unknown
10
+
11
+ interface OutletRoot {
12
+ unmount(): void
13
+ render(Component: RenderableComponent, props?: Record<string, unknown>): void
14
+ }
15
+
16
+ function removeHostChildRange(html: string): string {
17
+ const start = "<!--[-->"
18
+ const end = "<!--]-->"
19
+
20
+ return html.startsWith(start) && html.endsWith(end)
21
+ ? html.slice(start.length, -end.length)
22
+ : html
23
+ }
24
+
25
+ function createOutletRoot(root: ReturnType<typeof createRoot>): OutletRoot {
26
+ return {
27
+ unmount: () => root.unmount(),
28
+ render: (nextComponent, props) => {
29
+ root.render(nextComponent, props)
30
+ },
31
+ }
32
+ }
33
+
34
+ /** Transfer inserted fragment DOM from the outer root to a nested Octane root. */
35
+ export function hydrateRouteFragment(
36
+ host: HTMLDivElement,
37
+ Component: RenderableComponent,
38
+ ): OutletRoot {
39
+ const html = host.innerHTML
40
+
41
+ setDangerouslySetInnerHTML(host, null)
42
+ setHTML(host, html)
43
+
44
+ const root = hydrateRoot(host, Component, undefined, {
45
+ identifierPrefix: outletIdentifierPrefix,
46
+ })
47
+
48
+ return {
49
+ unmount: () => root.unmount(),
50
+ render: (nextComponent) => root.render(nextComponent),
51
+ }
52
+ }
53
+
54
+ /** Mount a routed outlet into its own root after the outer shell commits. */
55
+ export function renderRouteOutlet(
56
+ host: HTMLDivElement,
57
+ Component: RenderableComponent,
58
+ hydrate: boolean,
59
+ ): OutletRoot {
60
+ if (hydrate) {
61
+ const html = removeHostChildRange(host.innerHTML)
62
+
63
+ setDangerouslySetInnerHTML(host, null)
64
+ setHTML(host, html)
65
+
66
+ const root = hydrateRoot(host, Component, undefined, {
67
+ identifierPrefix: outletIdentifierPrefix,
68
+ })
69
+
70
+ return createOutletRoot(root)
71
+ }
72
+
73
+ setDangerouslySetInnerHTML(host, null)
74
+ setHTML(host, "")
75
+
76
+ const root = createRoot(host, { identifierPrefix: outletIdentifierPrefix })
77
+
78
+ root.render(Component)
79
+
80
+ return createOutletRoot(root)
81
+ }
@@ -0,0 +1,39 @@
1
+ export const flamefrontFragmentQueryParam = "__flamefront_fragment"
2
+ export const flamefrontShellQueryParam = "__flamefront_shell"
3
+ export const flamefrontFragmentQueryValue = "1"
4
+
5
+ /** Remove framework-only query parameters before a URL reaches app code. */
6
+ export function stripFlamefrontProtocolParams(input: string | URL): URL {
7
+ const url = new URL(input, "http://flamefront.local")
8
+
9
+ url.searchParams.delete(flamefrontFragmentQueryParam)
10
+ url.searchParams.delete(flamefrontShellQueryParam)
11
+ return url
12
+ }
13
+
14
+ export function isRouteFragmentRequest(input: string | URL): boolean {
15
+ const url = new URL(input, "http://flamefront.local")
16
+
17
+ return (
18
+ url.searchParams.get(flamefrontFragmentQueryParam) ===
19
+ flamefrontFragmentQueryValue
20
+ )
21
+ }
22
+
23
+ /** Mark a route URL for the fragment transport. */
24
+ export function withRouteFragmentProtocol(input: string | URL): URL {
25
+ const url = stripFlamefrontProtocolParams(input)
26
+
27
+ url.searchParams.set(
28
+ flamefrontFragmentQueryParam,
29
+ flamefrontFragmentQueryValue,
30
+ )
31
+ return url
32
+ }
33
+
34
+ /** Pass a request to a loader without leaking framework protocol parameters. */
35
+ export function stripFlamefrontProtocolRequest(request: Request): Request {
36
+ const url = stripFlamefrontProtocolParams(request.url)
37
+
38
+ return new Request(url, request)
39
+ }