flamefront 0.0.0 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.md +110 -0
- package/README.md +58 -0
- package/bin/ff-loader.mjs +18 -0
- package/bin/ff.js +6 -0
- package/package.json +84 -8
- package/src/babel.ts +22 -0
- package/src/cli.ts +98 -0
- package/src/entry.ts +49 -0
- package/src/fetch.ts +280 -0
- package/src/fragment-client.ts +271 -0
- package/src/fragment-hydration-client.tsx +81 -0
- package/src/fragment-protocol.ts +39 -0
- package/src/fragment.tsx +656 -0
- package/src/glob.ts +294 -0
- package/src/identifier-prefix.ts +5 -0
- package/src/index.ts +1168 -0
- package/src/lifecycle.ts +487 -0
- package/src/octane-client-core.ts +141 -0
- package/src/octane-client.ts +48 -0
- package/src/octane-compiler.d.ts +19 -0
- package/src/octane-default-renderer.tsx +124 -0
- package/src/octane-router-document.ts +22 -0
- package/src/octane.tsx +661 -0
- package/src/output.ts +48 -0
- package/src/remix-route-data.ts +76 -0
- package/src/remix-router-core.ts +106 -0
- package/src/remix-router.ts +117 -0
- package/src/remove-exports.ts +148 -0
- package/src/route-data-client.ts +234 -0
- package/src/route-prefetch.ts +79 -0
- package/src/server.ts +338 -0
- package/src/srvx.ts +174 -0
- package/src/static-fragment-artifacts.ts +86 -0
- package/src/typegen.ts +207 -0
- package/src/virtual-remix-routes.d.ts +35 -0
- package/src/vite.ts +1030 -0
- package/readme.md +0 -1
package/src/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
|
+
}
|