flamefront 0.1.0-alpha.0 → 0.1.1
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 +2 -91
- package/README.md +35 -620
- package/package.json +49 -26
- package/src/cli.ts +16 -1
- package/src/entry.ts +49 -0
- package/src/fetch.ts +280 -0
- package/src/fragment-client.ts +127 -87
- package/src/fragment-hydration-client.tsx +81 -0
- package/src/fragment-protocol.ts +3 -3
- package/src/fragment.tsx +656 -0
- package/src/glob.ts +294 -0
- package/src/identifier-prefix.ts +5 -0
- package/src/index.ts +569 -49
- package/src/lifecycle.ts +7 -6
- package/src/octane-client-core.ts +10 -3
- package/src/octane-client.ts +12 -6
- package/src/octane-compiler.d.ts +19 -0
- package/src/octane-default-renderer.tsx +124 -0
- package/src/octane-router-document.ts +19 -2
- package/src/{octane.ts → octane.tsx} +152 -145
- package/src/output.ts +48 -0
- package/src/remix-route-data.ts +28 -20
- package/src/remix-router.ts +10 -4
- package/src/route-data-client.ts +22 -12
- package/src/route-prefetch.ts +19 -12
- package/src/server.ts +122 -24
- package/src/srvx.ts +43 -183
- package/src/typegen.ts +207 -0
- package/src/virtual-remix-routes.d.ts +16 -1
- package/src/vite.ts +364 -27
- package/src/fragment.ts +0 -260
package/src/fragment-client.ts
CHANGED
|
@@ -1,17 +1,14 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
GeneratedHydration,
|
|
3
|
-
GeneratedRouteMetadata,
|
|
4
|
-
HydrationMode,
|
|
5
|
-
RouteBoundaryKind,
|
|
6
|
-
} from "./index.ts"
|
|
1
|
+
import type { HydrationMode, RouteBoundaryKind } from "./index.ts"
|
|
7
2
|
import {
|
|
8
3
|
stripFlamefrontProtocolParams,
|
|
9
|
-
|
|
4
|
+
withRouteFragmentProtocol,
|
|
10
5
|
} from "./fragment-protocol.ts"
|
|
11
6
|
|
|
12
|
-
export const
|
|
7
|
+
export const routeFragmentProtocol = "flamefront-route-fragment-v1" as const
|
|
8
|
+
|
|
9
|
+
export type RouteFragmentCachePolicy = "server" | "static"
|
|
13
10
|
|
|
14
|
-
export interface
|
|
11
|
+
export interface RouteFragmentBoundary {
|
|
15
12
|
readonly id: string
|
|
16
13
|
readonly boundary: string
|
|
17
14
|
readonly kind: RouteBoundaryKind
|
|
@@ -19,33 +16,36 @@ export interface StaticFragmentBoundary {
|
|
|
19
16
|
readonly html: string
|
|
20
17
|
}
|
|
21
18
|
|
|
22
|
-
export interface
|
|
23
|
-
readonly protocol: typeof
|
|
19
|
+
export interface RouteFragmentArtifact {
|
|
20
|
+
readonly protocol: typeof routeFragmentProtocol
|
|
24
21
|
readonly route: string
|
|
25
22
|
readonly boundary: string
|
|
26
23
|
readonly html: string
|
|
27
24
|
readonly routeData: unknown
|
|
28
|
-
readonly boundaries: readonly
|
|
25
|
+
readonly boundaries: readonly RouteFragmentBoundary[]
|
|
29
26
|
readonly hydration?: HydrationMode
|
|
30
27
|
readonly status?: number
|
|
31
28
|
}
|
|
32
29
|
|
|
33
|
-
export interface
|
|
30
|
+
export interface RouteFragmentLoadOptions {
|
|
31
|
+
readonly policy: RouteFragmentCachePolicy
|
|
34
32
|
readonly signal?: AbortSignal
|
|
35
33
|
readonly reload?: boolean
|
|
36
34
|
}
|
|
37
35
|
|
|
38
|
-
export interface
|
|
36
|
+
export interface RouteFragmentRoutingOptions {
|
|
39
37
|
readonly basename?: string
|
|
40
38
|
}
|
|
41
39
|
|
|
42
|
-
export function
|
|
40
|
+
export function shouldHydrateRouteFragment(
|
|
43
41
|
hydration: HydrationMode | undefined,
|
|
44
42
|
): boolean {
|
|
45
43
|
return hydration !== "none"
|
|
46
44
|
}
|
|
47
45
|
|
|
48
|
-
const
|
|
46
|
+
const staticFragmentRequests = new Map<string, Promise<RouteFragmentArtifact>>()
|
|
47
|
+
const serverFragmentRequests = new Map<string, Promise<RouteFragmentArtifact>>()
|
|
48
|
+
const latestRouteFragments = new Map<string, RouteFragmentArtifact>()
|
|
49
49
|
|
|
50
50
|
function resolveRouteUrl(input: string | URL): URL {
|
|
51
51
|
const browserOrigin =
|
|
@@ -53,11 +53,11 @@ function resolveRouteUrl(input: string | URL): URL {
|
|
|
53
53
|
|
|
54
54
|
if (!browserOrigin && typeof input === "string" && !URL.canParse(input)) {
|
|
55
55
|
throw new TypeError(
|
|
56
|
-
"flamefront
|
|
56
|
+
"flamefront route fragments require an absolute URL outside the browser.",
|
|
57
57
|
)
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
-
return new URL(input, browserOrigin)
|
|
60
|
+
return stripFlamefrontProtocolParams(new URL(input, browserOrigin))
|
|
61
61
|
}
|
|
62
62
|
|
|
63
63
|
function basenamePath(pathname: string, basename: string): string | null {
|
|
@@ -76,13 +76,18 @@ function basenamePath(pathname: string, basename: string): string | null {
|
|
|
76
76
|
return pathname.slice(basename.length) || "/"
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
-
function
|
|
80
|
-
const url = stripFlamefrontProtocolParams(resolveRouteUrl(input))
|
|
79
|
+
function staticFragmentKey(url: URL, basename: string): string {
|
|
81
80
|
const pathname = basenamePath(url.pathname, basename) ?? url.pathname
|
|
82
81
|
|
|
83
82
|
return `${url.origin}${pathname}`
|
|
84
83
|
}
|
|
85
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
|
+
|
|
86
91
|
function abortable<Data>(
|
|
87
92
|
pending: Promise<Data>,
|
|
88
93
|
signal: AbortSignal | undefined,
|
|
@@ -112,17 +117,17 @@ function abortable<Data>(
|
|
|
112
117
|
})
|
|
113
118
|
}
|
|
114
119
|
|
|
115
|
-
export function
|
|
120
|
+
export function isRouteFragmentArtifact(
|
|
116
121
|
value: unknown,
|
|
117
|
-
): value is
|
|
122
|
+
): value is RouteFragmentArtifact {
|
|
118
123
|
if (!value || typeof value !== "object") {
|
|
119
124
|
return false
|
|
120
125
|
}
|
|
121
126
|
|
|
122
|
-
const artifact = value as Partial<
|
|
127
|
+
const artifact = value as Partial<RouteFragmentArtifact>
|
|
123
128
|
|
|
124
129
|
return (
|
|
125
|
-
artifact.protocol ===
|
|
130
|
+
artifact.protocol === routeFragmentProtocol &&
|
|
126
131
|
typeof artifact.route === "string" &&
|
|
127
132
|
typeof artifact.boundary === "string" &&
|
|
128
133
|
typeof artifact.html === "string" &&
|
|
@@ -130,98 +135,133 @@ export function isStaticFragmentArtifact(
|
|
|
130
135
|
)
|
|
131
136
|
}
|
|
132
137
|
|
|
133
|
-
export function
|
|
138
|
+
export function assertRouteFragmentArtifact(
|
|
134
139
|
value: unknown,
|
|
135
|
-
):
|
|
136
|
-
if (!
|
|
140
|
+
): RouteFragmentArtifact {
|
|
141
|
+
if (!isRouteFragmentArtifact(value)) {
|
|
137
142
|
throw new Error(
|
|
138
|
-
"flamefront
|
|
143
|
+
"flamefront route fragment response has an invalid protocol.",
|
|
139
144
|
)
|
|
140
145
|
}
|
|
141
146
|
|
|
142
147
|
return value
|
|
143
148
|
}
|
|
144
149
|
|
|
145
|
-
export function
|
|
150
|
+
export function getRouteFragment(
|
|
146
151
|
url: string | URL,
|
|
147
|
-
routing:
|
|
148
|
-
):
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
+
)
|
|
155
198
|
}
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
function rememberArtifact(
|
|
161
|
-
key: string,
|
|
162
|
-
pending: Promise<StaticFragmentArtifact>,
|
|
163
|
-
): Promise<StaticFragmentArtifact> {
|
|
164
|
-
const tracked = pending.then((artifact) => {
|
|
165
|
-
;(
|
|
166
|
-
tracked as Promise<StaticFragmentArtifact> & {
|
|
167
|
-
value?: StaticFragmentArtifact
|
|
199
|
+
|
|
200
|
+
throw new Error(
|
|
201
|
+
"flamefront route fragment response has an invalid protocol.",
|
|
202
|
+
)
|
|
168
203
|
}
|
|
169
|
-
).value = artifact
|
|
170
|
-
return artifact
|
|
171
|
-
})
|
|
172
204
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
if (fragmentCache.get(key) === tracked) {
|
|
176
|
-
fragmentCache.delete(key)
|
|
177
|
-
}
|
|
178
|
-
})
|
|
179
|
-
return tracked
|
|
205
|
+
return assertRouteFragmentArtifact(value)
|
|
206
|
+
})
|
|
180
207
|
}
|
|
181
208
|
|
|
182
|
-
export function
|
|
209
|
+
export function loadRouteFragment(
|
|
183
210
|
url: string | URL,
|
|
184
|
-
routing:
|
|
185
|
-
options:
|
|
186
|
-
): Promise<
|
|
211
|
+
routing: RouteFragmentRoutingOptions = {},
|
|
212
|
+
options: RouteFragmentLoadOptions,
|
|
213
|
+
): Promise<RouteFragmentArtifact> {
|
|
187
214
|
const routeUrl = resolveRouteUrl(url)
|
|
188
|
-
const
|
|
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
|
|
189
224
|
|
|
190
225
|
if (options.reload) {
|
|
191
|
-
|
|
226
|
+
requests.delete(requestKey)
|
|
192
227
|
}
|
|
193
228
|
|
|
194
|
-
|
|
229
|
+
let pending = requests.get(requestKey)
|
|
195
230
|
|
|
196
|
-
if (
|
|
197
|
-
|
|
198
|
-
|
|
231
|
+
if (!pending) {
|
|
232
|
+
pending = fetchRouteFragment(
|
|
233
|
+
routeUrl,
|
|
234
|
+
routing.basename ?? "/",
|
|
235
|
+
options.signal,
|
|
236
|
+
)
|
|
237
|
+
requests.set(requestKey, pending)
|
|
199
238
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
headers: { Accept: "application/vnd.flamefront.fragment+json" },
|
|
204
|
-
...(options.signal ? { signal: options.signal } : {}),
|
|
205
|
-
})
|
|
206
|
-
.then(async (response) => {
|
|
207
|
-
if (!response.ok) {
|
|
208
|
-
throw new Error(
|
|
209
|
-
`flamefront static fragment request failed with ${response.status}.`,
|
|
210
|
-
)
|
|
239
|
+
const evict = () => {
|
|
240
|
+
if (requests.get(requestKey) === pending) {
|
|
241
|
+
requests.delete(requestKey)
|
|
211
242
|
}
|
|
243
|
+
}
|
|
212
244
|
|
|
213
|
-
|
|
214
|
-
|
|
245
|
+
if (options.policy === "static") {
|
|
246
|
+
void pending.catch(evict)
|
|
247
|
+
} else {
|
|
248
|
+
void pending.then(evict, evict)
|
|
249
|
+
}
|
|
250
|
+
}
|
|
215
251
|
|
|
216
|
-
return abortable(
|
|
252
|
+
return abortable(pending, options.signal).then((artifact) => {
|
|
253
|
+
latestRouteFragments.set(handoffKey, artifact)
|
|
254
|
+
return artifact
|
|
255
|
+
})
|
|
217
256
|
}
|
|
218
257
|
|
|
219
|
-
export async function
|
|
258
|
+
export async function prefetchRouteFragment(
|
|
220
259
|
url: string | URL,
|
|
221
|
-
|
|
222
|
-
|
|
260
|
+
policy: RouteFragmentCachePolicy,
|
|
261
|
+
routing: RouteFragmentRoutingOptions = {},
|
|
262
|
+
options: Omit<RouteFragmentLoadOptions, "policy"> = {},
|
|
223
263
|
): Promise<void> {
|
|
224
|
-
await
|
|
264
|
+
await loadRouteFragment(url, routing, { ...options, policy })
|
|
225
265
|
}
|
|
226
266
|
|
|
227
267
|
export type {
|
|
@@ -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
|
+
}
|
package/src/fragment-protocol.ts
CHANGED
|
@@ -11,7 +11,7 @@ export function stripFlamefrontProtocolParams(input: string | URL): URL {
|
|
|
11
11
|
return url
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
-
export function
|
|
14
|
+
export function isRouteFragmentRequest(input: string | URL): boolean {
|
|
15
15
|
const url = new URL(input, "http://flamefront.local")
|
|
16
16
|
|
|
17
17
|
return (
|
|
@@ -20,8 +20,8 @@ export function isStaticFragmentRequest(input: string | URL): boolean {
|
|
|
20
20
|
)
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
/** Mark a route URL for the
|
|
24
|
-
export function
|
|
23
|
+
/** Mark a route URL for the fragment transport. */
|
|
24
|
+
export function withRouteFragmentProtocol(input: string | URL): URL {
|
|
25
25
|
const url = stripFlamefrontProtocolParams(input)
|
|
26
26
|
|
|
27
27
|
url.searchParams.set(
|