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.
@@ -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
- withStaticFragmentProtocol,
4
+ withRouteFragmentProtocol,
10
5
  } from "./fragment-protocol.ts"
11
6
 
12
- export const staticFragmentProtocol = "flamefront-static-fragment-v1" as const
7
+ export const routeFragmentProtocol = "flamefront-route-fragment-v1" as const
8
+
9
+ export type RouteFragmentCachePolicy = "server" | "static"
13
10
 
14
- export interface StaticFragmentBoundary {
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 StaticFragmentArtifact {
23
- readonly protocol: typeof staticFragmentProtocol
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 StaticFragmentBoundary[]
25
+ readonly boundaries: readonly RouteFragmentBoundary[]
29
26
  readonly hydration?: HydrationMode
30
27
  readonly status?: number
31
28
  }
32
29
 
33
- export interface StaticFragmentLoadOptions {
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 StaticFragmentRoutingOptions {
36
+ export interface RouteFragmentRoutingOptions {
39
37
  readonly basename?: string
40
38
  }
41
39
 
42
- export function shouldHydrateStaticFragment(
40
+ export function shouldHydrateRouteFragment(
43
41
  hydration: HydrationMode | undefined,
44
42
  ): boolean {
45
43
  return hydration !== "none"
46
44
  }
47
45
 
48
- const fragmentCache = new Map<string, Promise<StaticFragmentArtifact>>()
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 static fragments require an absolute URL outside the browser.",
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 fragmentKey(input: string | URL, basename = "/"): string {
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 isStaticFragmentArtifact(
120
+ export function isRouteFragmentArtifact(
116
121
  value: unknown,
117
- ): value is StaticFragmentArtifact {
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<StaticFragmentArtifact>
127
+ const artifact = value as Partial<RouteFragmentArtifact>
123
128
 
124
129
  return (
125
- artifact.protocol === staticFragmentProtocol &&
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 assertStaticFragmentArtifact(
138
+ export function assertRouteFragmentArtifact(
134
139
  value: unknown,
135
- ): StaticFragmentArtifact {
136
- if (!isStaticFragmentArtifact(value)) {
140
+ ): RouteFragmentArtifact {
141
+ if (!isRouteFragmentArtifact(value)) {
137
142
  throw new Error(
138
- "flamefront static fragment response has an invalid protocol.",
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 getStaticFragment(
150
+ export function getRouteFragment(
146
151
  url: string | URL,
147
- routing: StaticFragmentRoutingOptions = {},
148
- ): StaticFragmentArtifact | undefined {
149
- const pending = fragmentCache.get(fragmentKey(url, routing.basename ?? "/"))
150
-
151
- return pending && "value" in pending
152
- ? (
153
- pending as Promise<StaticFragmentArtifact> & {
154
- value?: StaticFragmentArtifact
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
- ).value
157
- : undefined
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
- fragmentCache.set(key, tracked)
174
- void tracked.catch(() => {
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 loadStaticFragment(
209
+ export function loadRouteFragment(
183
210
  url: string | URL,
184
- routing: StaticFragmentRoutingOptions = {},
185
- options: StaticFragmentLoadOptions = {},
186
- ): Promise<StaticFragmentArtifact> {
211
+ routing: RouteFragmentRoutingOptions = {},
212
+ options: RouteFragmentLoadOptions,
213
+ ): Promise<RouteFragmentArtifact> {
187
214
  const routeUrl = resolveRouteUrl(url)
188
- const key = fragmentKey(routeUrl, routing.basename ?? "/")
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
- fragmentCache.delete(key)
226
+ requests.delete(requestKey)
192
227
  }
193
228
 
194
- const cached = fragmentCache.get(key)
229
+ let pending = requests.get(requestKey)
195
230
 
196
- if (cached) {
197
- return abortable(cached, options.signal)
198
- }
231
+ if (!pending) {
232
+ pending = fetchRouteFragment(
233
+ routeUrl,
234
+ routing.basename ?? "/",
235
+ options.signal,
236
+ )
237
+ requests.set(requestKey, pending)
199
238
 
200
- const endpoint = withStaticFragmentProtocol(routeUrl)
201
- const pending = globalThis
202
- .fetch(endpoint, {
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
- return assertStaticFragmentArtifact(await response.json())
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(rememberArtifact(key, pending), options.signal)
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 prefetchStaticFragment(
258
+ export async function prefetchRouteFragment(
220
259
  url: string | URL,
221
- routing: StaticFragmentRoutingOptions = {},
222
- options?: StaticFragmentLoadOptions,
260
+ policy: RouteFragmentCachePolicy,
261
+ routing: RouteFragmentRoutingOptions = {},
262
+ options: Omit<RouteFragmentLoadOptions, "policy"> = {},
223
263
  ): Promise<void> {
224
- await loadStaticFragment(url, routing, options)
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
+ }
@@ -11,7 +11,7 @@ export function stripFlamefrontProtocolParams(input: string | URL): URL {
11
11
  return url
12
12
  }
13
13
 
14
- export function isStaticFragmentRequest(input: string | URL): boolean {
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 static-fragment transport. */
24
- export function withStaticFragmentProtocol(input: string | URL): URL {
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(