akanjs 2.2.12 → 2.2.13-rc.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/client/csrTypes.ts +37 -6
- package/client/makePageProto.tsx +8 -8
- package/client/router.ts +5 -2
- package/fetch/requestStorage.ts +41 -11
- package/package.json +1 -1
- package/server/akanApp.ts +55 -0
- package/server/cachePolicy.ts +192 -0
- package/server/metadata.tsx +114 -0
- package/server/routeElementComposer.tsx +21 -1
- package/server/routeTreeBuilder.ts +44 -5
- package/server/rscClient.tsx +127 -50
- package/server/rscHttp.ts +120 -0
- package/server/rscNavigationState.ts +95 -0
- package/server/rscWorker.tsx +318 -121
- package/server/rscWorkerHost.ts +281 -66
- package/server/rscWorkerReplay.ts +40 -0
- package/server/ssrFromRscRenderer.tsx +462 -77
- package/server/ssrTypes.ts +11 -1
- package/server/webRouter.ts +173 -88
- package/service/ipcTypes.ts +1 -0
- package/types/client/csrTypes.d.ts +37 -6
- package/types/dictionary/base.dictionary.d.ts +1 -1
- package/types/dictionary/dictionary.d.ts +8 -8
- package/types/fetch/requestStorage.d.ts +16 -6
- package/types/server/cachePolicy.d.ts +55 -0
- package/types/server/metadata.d.ts +13 -0
- package/types/server/routeElementComposer.d.ts +6 -1
- package/types/server/rscHttp.d.ts +16 -0
- package/types/server/rscNavigationState.d.ts +35 -0
- package/types/server/rscWorkerHost.d.ts +38 -0
- package/types/server/rscWorkerReplay.d.ts +29 -0
- package/types/server/ssrFromRscRenderer.d.ts +20 -1
- package/types/server/ssrTypes.d.ts +10 -1
- package/types/server/webRouter.d.ts +27 -1
- package/types/service/ipcTypes.d.ts +1 -0
- package/types/webkit/useCsrValues.d.ts +1 -1
- package/ui/Link/SsrLink.tsx +0 -2
- package/webkit/bootCsr.tsx +16 -2
|
@@ -4,9 +4,11 @@ import type {
|
|
|
4
4
|
LayoutFallbackRoute,
|
|
5
5
|
LayoutNotFoundRender,
|
|
6
6
|
PathRoute,
|
|
7
|
+
ResolvedHead,
|
|
7
8
|
RouteRender,
|
|
8
9
|
} from "akanjs/client";
|
|
9
10
|
import { Children, cloneElement, isValidElement, type ReactElement, type ReactNode, Suspense } from "react";
|
|
11
|
+
import { resolveHeadResult } from "./metadata";
|
|
10
12
|
|
|
11
13
|
export class RouteElementComposer {
|
|
12
14
|
static compose({
|
|
@@ -43,7 +45,25 @@ export class RouteElementComposer {
|
|
|
43
45
|
params: Record<string, string>;
|
|
44
46
|
searchParams: Record<string, string | string[]>;
|
|
45
47
|
}): Promise<Head | null | undefined> {
|
|
46
|
-
return
|
|
48
|
+
return (
|
|
49
|
+
await RouteElementComposer.resolveHeadWithMetadata({
|
|
50
|
+
pathRoute,
|
|
51
|
+
params,
|
|
52
|
+
searchParams,
|
|
53
|
+
})
|
|
54
|
+
).node;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
static async resolveHeadWithMetadata({
|
|
58
|
+
pathRoute,
|
|
59
|
+
params,
|
|
60
|
+
searchParams,
|
|
61
|
+
}: {
|
|
62
|
+
pathRoute: PathRoute;
|
|
63
|
+
params: Record<string, string>;
|
|
64
|
+
searchParams: Record<string, string[] | string>;
|
|
65
|
+
}): Promise<ResolvedHead> {
|
|
66
|
+
return resolveHeadResult(await pathRoute.resolveHead?.({ params, searchParams }));
|
|
47
67
|
}
|
|
48
68
|
|
|
49
69
|
static async composeFallback({
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import type {
|
|
2
|
-
Head,
|
|
3
2
|
LayoutFallbackRoute,
|
|
4
3
|
LayoutModule,
|
|
5
4
|
LayoutProps,
|
|
@@ -19,6 +18,7 @@ import {
|
|
|
19
18
|
parseRouteModuleKey,
|
|
20
19
|
routeSegmentToTreePath,
|
|
21
20
|
} from "akanjs/common";
|
|
21
|
+
import { resolveHeadExport, resolveMetadataHead } from "./metadata";
|
|
22
22
|
|
|
23
23
|
export type PagesContext = Record<string, () => Promise<RouteModule>>;
|
|
24
24
|
|
|
@@ -44,11 +44,21 @@ export interface RouteModuleCacheStats {
|
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
export class RouteTreeBuilder {
|
|
47
|
-
static readonly #pageRouteExports = new Set([
|
|
47
|
+
static readonly #pageRouteExports = new Set([
|
|
48
|
+
"default",
|
|
49
|
+
"pageConfig",
|
|
50
|
+
"head",
|
|
51
|
+
"metadata",
|
|
52
|
+
"generateHead",
|
|
53
|
+
"generateMetadata",
|
|
54
|
+
"Loading",
|
|
55
|
+
]);
|
|
48
56
|
static readonly #rootLayoutExports = new Set([
|
|
49
57
|
"default",
|
|
50
58
|
"head",
|
|
59
|
+
"metadata",
|
|
51
60
|
"generateHead",
|
|
61
|
+
"generateMetadata",
|
|
52
62
|
"fonts",
|
|
53
63
|
"manifest",
|
|
54
64
|
"theme",
|
|
@@ -60,7 +70,16 @@ export class RouteTreeBuilder {
|
|
|
60
70
|
"NotFound",
|
|
61
71
|
"Error",
|
|
62
72
|
]);
|
|
63
|
-
static readonly #layoutRouteExports = new Set([
|
|
73
|
+
static readonly #layoutRouteExports = new Set([
|
|
74
|
+
"default",
|
|
75
|
+
"head",
|
|
76
|
+
"metadata",
|
|
77
|
+
"generateHead",
|
|
78
|
+
"generateMetadata",
|
|
79
|
+
"Loading",
|
|
80
|
+
"NotFound",
|
|
81
|
+
"Error",
|
|
82
|
+
]);
|
|
64
83
|
static readonly #moduleCacheStats: RouteModuleCacheStats = {
|
|
65
84
|
moduleCount: 0,
|
|
66
85
|
loadedModuleCount: 0,
|
|
@@ -279,6 +298,18 @@ export class RouteTreeBuilder {
|
|
|
279
298
|
if ("head" in mod && "generateHead" in mod) {
|
|
280
299
|
throw new Error(`[route-convention] head and generateHead cannot both be exported in ${key}`);
|
|
281
300
|
}
|
|
301
|
+
if (
|
|
302
|
+
!parsed.isInternalRootLayout &&
|
|
303
|
+
("head" in mod || "generateHead" in mod) &&
|
|
304
|
+
("metadata" in mod || "generateMetadata" in mod)
|
|
305
|
+
) {
|
|
306
|
+
throw new Error(
|
|
307
|
+
`[route-convention] head/generateHead and metadata/generateMetadata cannot both be exported in ${key}`,
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
if ("metadata" in mod && "generateMetadata" in mod) {
|
|
311
|
+
throw new Error(`[route-convention] metadata and generateMetadata cannot both be exported in ${key}`);
|
|
312
|
+
}
|
|
282
313
|
}
|
|
283
314
|
|
|
284
315
|
static #makeRouteRender(key: string, kind: "page" | "layout", loader: () => Promise<RouteModule>): RouteRender {
|
|
@@ -304,8 +335,16 @@ export class RouteTreeBuilder {
|
|
|
304
335
|
routeRender.NotFound = layoutMod.NotFound;
|
|
305
336
|
routeRender.Error = layoutMod.Error;
|
|
306
337
|
}
|
|
307
|
-
if (mod.generateHead)
|
|
308
|
-
|
|
338
|
+
if (mod.generateHead) {
|
|
339
|
+
const head = await mod.generateHead(props);
|
|
340
|
+
if (head !== null && head !== undefined) return resolveHeadExport(head);
|
|
341
|
+
}
|
|
342
|
+
if (mod.generateMetadata) {
|
|
343
|
+
const metadata = await mod.generateMetadata(props);
|
|
344
|
+
return metadata === null || metadata === undefined ? metadata : resolveMetadataHead(metadata);
|
|
345
|
+
}
|
|
346
|
+
if (mod.head !== undefined) return mod.head === null ? null : resolveHeadExport(mod.head);
|
|
347
|
+
return mod.metadata === undefined ? undefined : resolveMetadataHead(mod.metadata);
|
|
309
348
|
},
|
|
310
349
|
};
|
|
311
350
|
if (kind === "page") {
|
package/server/rscClient.tsx
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
import { createElement, type ReactNode, startTransition, use, useLayoutEffect, useState } from "react";
|
|
2
2
|
import { hydrateRoot } from "react-dom/client";
|
|
3
3
|
import { createFromReadableStream } from "react-server-dom-webpack/client.browser";
|
|
4
|
-
import {
|
|
4
|
+
import { getRscPayloadStream, guardRscRedirectRows, type RscRedirectRow } from "./rscHttp";
|
|
5
|
+
import {
|
|
6
|
+
commitLatestRscNavigation,
|
|
7
|
+
deleteRscCacheEntryIfCurrent,
|
|
8
|
+
observeRscNavigation,
|
|
9
|
+
rememberRscCacheEntry,
|
|
10
|
+
} from "./rscNavigationState";
|
|
5
11
|
|
|
6
12
|
type InlineRscChunk = [1, string] | [3, string];
|
|
7
13
|
|
|
@@ -32,6 +38,14 @@ function decodeInlineRscChunk([type, data]: InlineRscChunk): Uint8Array {
|
|
|
32
38
|
type RscThenable = Promise<ReactNode>;
|
|
33
39
|
type RscFetchResult = { type: "rsc"; thenable: RscThenable } | { type: "redirected"; status?: number };
|
|
34
40
|
const MAX_RSC_CACHE_ENTRIES = 32;
|
|
41
|
+
let documentNavigationFallbackInFlight = false;
|
|
42
|
+
|
|
43
|
+
class RscRedirectNavigationStarted extends Error {
|
|
44
|
+
constructor(readonly location: string) {
|
|
45
|
+
super("[rscClient] RSC redirect navigation started");
|
|
46
|
+
this.name = "RscRedirectNavigationStarted";
|
|
47
|
+
}
|
|
48
|
+
}
|
|
35
49
|
|
|
36
50
|
function createInitialRscStream(): ReadableStream<Uint8Array> {
|
|
37
51
|
return new ReadableStream<Uint8Array>({
|
|
@@ -59,7 +73,31 @@ function createRscThenable(stream: ReadableStream<Uint8Array>): RscThenable {
|
|
|
59
73
|
return createFromReadableStream<ReactNode>(stream) as RscThenable;
|
|
60
74
|
}
|
|
61
75
|
|
|
62
|
-
|
|
76
|
+
function hardNavigateAfterRscFailure(target: string, replace = false, error?: unknown): void {
|
|
77
|
+
if (documentNavigationFallbackInFlight) return;
|
|
78
|
+
documentNavigationFallbackInFlight = true;
|
|
79
|
+
console.warn(`[rscClient] RSC navigation failed, falling back to document navigation: ${String(error)}`);
|
|
80
|
+
if (replace) window.location.replace(target);
|
|
81
|
+
else window.location.assign(target);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function navigateAfterRscRedirect(target: string, replace = true): void {
|
|
85
|
+
const error = new RscRedirectNavigationStarted(target);
|
|
86
|
+
const navigate = globalThis.__AKAN_RSC_NAVIGATE__;
|
|
87
|
+
if (!navigate) {
|
|
88
|
+
hardNavigateAfterRscFailure(target, replace, error);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
void navigate(target, { replace, scrollToTop: true }).catch((navError) => {
|
|
92
|
+
hardNavigateAfterRscFailure(target, replace, navError);
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function fetchRsc(
|
|
97
|
+
href: string,
|
|
98
|
+
options: { buildId?: number; replaceOnRedirect?: boolean; shouldApplyNavigation?: () => boolean } = {},
|
|
99
|
+
): Promise<RscFetchResult> {
|
|
100
|
+
const shouldApplyNavigation = options.shouldApplyNavigation ?? (() => true);
|
|
63
101
|
const endpoint = new URL("/__rsc", window.location.origin);
|
|
64
102
|
endpoint.searchParams.set("url", href);
|
|
65
103
|
if (options.buildId !== undefined) endpoint.searchParams.set("buildId", String(options.buildId));
|
|
@@ -73,19 +111,30 @@ async function fetchRsc(href: string, options: { buildId?: number } = {}): Promi
|
|
|
73
111
|
const method = res.headers.get("X-Akan-Redirect-Method");
|
|
74
112
|
const statusHeader = res.headers.get("X-Akan-Redirect-Status");
|
|
75
113
|
const status = statusHeader ? Number(statusHeader) : undefined;
|
|
76
|
-
|
|
114
|
+
if (shouldApplyNavigation())
|
|
115
|
+
await globalThis.__AKAN_RSC_NAVIGATE__?.(redirect, { replace: method !== "push", scrollToTop: true });
|
|
77
116
|
return { type: "redirected", status };
|
|
78
117
|
}
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
118
|
+
const stream = getRscPayloadStream(res);
|
|
119
|
+
if (!stream) throw new Error(`[rscClient] RSC fetch failed ${res.status} ${res.statusText}`);
|
|
120
|
+
let thenable: RscThenable | undefined;
|
|
121
|
+
const handleRedirect = (redirect: RscRedirectRow) => {
|
|
122
|
+
if (!shouldApplyNavigation()) return;
|
|
123
|
+
const location = redirect.location ? normalizeHref(redirect.location) : href;
|
|
124
|
+
if (thenable) deleteRscCacheEntryIfCurrent(rscCache, href, thenable);
|
|
125
|
+
navigateAfterRscRedirect(
|
|
126
|
+
location,
|
|
127
|
+
redirect.method ? redirect.method !== "push" : (options.replaceOnRedirect ?? true),
|
|
128
|
+
);
|
|
129
|
+
};
|
|
130
|
+
const guardedStream = guardRscRedirectRows(stream, {
|
|
131
|
+
onRedirect: handleRedirect,
|
|
87
132
|
});
|
|
88
|
-
|
|
133
|
+
thenable = createRscThenable(guardedStream);
|
|
134
|
+
return {
|
|
135
|
+
type: "rsc",
|
|
136
|
+
thenable,
|
|
137
|
+
};
|
|
89
138
|
}
|
|
90
139
|
|
|
91
140
|
const rscCache = new Map<string, RscThenable>();
|
|
@@ -93,16 +142,6 @@ const initialThenable = createRscThenable(createInitialRscStream());
|
|
|
93
142
|
rscCache.set(normalizeHref(window.location.href), initialThenable);
|
|
94
143
|
let navigationSeq = 0;
|
|
95
144
|
|
|
96
|
-
function rememberRsc(href: string, thenable: RscThenable): void {
|
|
97
|
-
rscCache.delete(href);
|
|
98
|
-
rscCache.set(href, thenable);
|
|
99
|
-
while (rscCache.size > MAX_RSC_CACHE_ENTRIES) {
|
|
100
|
-
const oldest = rscCache.keys().next().value;
|
|
101
|
-
if (!oldest) break;
|
|
102
|
-
rscCache.delete(oldest);
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
|
|
106
145
|
function Root(): ReactNode {
|
|
107
146
|
const [thenable, setThenable] = useState<RscThenable>(initialThenable);
|
|
108
147
|
const [scrollToTopTick, setScrollToTopTick] = useState(0);
|
|
@@ -118,48 +157,86 @@ function Root(): ReactNode {
|
|
|
118
157
|
};
|
|
119
158
|
|
|
120
159
|
globalThis.__AKAN_RSC_REFRESH__ = async (options = {}) => {
|
|
160
|
+
const navId = ++navigationSeq;
|
|
121
161
|
const target = normalizeHref(window.location.href);
|
|
122
162
|
rscCache.delete(target);
|
|
123
|
-
const next = await fetchRsc(target, options);
|
|
124
|
-
if (next.type === "redirected") return;
|
|
125
|
-
rememberRsc(target, next.thenable);
|
|
126
163
|
try {
|
|
127
|
-
await
|
|
164
|
+
const next = await fetchRsc(target, {
|
|
165
|
+
...options,
|
|
166
|
+
replaceOnRedirect: true,
|
|
167
|
+
shouldApplyNavigation: () => navId === navigationSeq,
|
|
168
|
+
});
|
|
169
|
+
if (next.type === "redirected") return;
|
|
170
|
+
observeRscNavigation({
|
|
171
|
+
cache: rscCache,
|
|
172
|
+
href: target,
|
|
173
|
+
thenable: next.thenable,
|
|
174
|
+
navId,
|
|
175
|
+
getCurrentNavId: () => navigationSeq,
|
|
176
|
+
isExpectedNavigationError: (error) => error instanceof RscRedirectNavigationStarted,
|
|
177
|
+
onLatestError: (error) => hardNavigateAfterRscFailure(target, true, error),
|
|
178
|
+
});
|
|
179
|
+
commitLatestRscNavigation({
|
|
180
|
+
cache: rscCache,
|
|
181
|
+
href: target,
|
|
182
|
+
thenable: next.thenable,
|
|
183
|
+
maxEntries: MAX_RSC_CACHE_ENTRIES,
|
|
184
|
+
startTransition,
|
|
185
|
+
commitThenable: setThenable,
|
|
186
|
+
navId,
|
|
187
|
+
getCurrentNavId: () => navigationSeq,
|
|
188
|
+
});
|
|
128
189
|
} catch (error) {
|
|
129
|
-
|
|
130
|
-
|
|
190
|
+
if (error instanceof RscRedirectNavigationStarted) return;
|
|
191
|
+
if (navId === navigationSeq) hardNavigateAfterRscFailure(target, true, error);
|
|
131
192
|
}
|
|
132
|
-
startTransition(() => {
|
|
133
|
-
setThenable(next.thenable);
|
|
134
|
-
});
|
|
135
193
|
};
|
|
136
194
|
|
|
137
195
|
globalThis.__AKAN_RSC_NAVIGATE__ = async (href, options = {}) => {
|
|
138
196
|
const navId = ++navigationSeq;
|
|
139
197
|
const target = normalizeHref(href);
|
|
140
198
|
const scrollToTop = options.scrollToTop ?? true;
|
|
141
|
-
let next = rscCache.get(target);
|
|
142
|
-
if (!next) {
|
|
143
|
-
const fetched = await fetchRsc(target);
|
|
144
|
-
if (fetched.type === "redirected") return;
|
|
145
|
-
next = fetched.thenable;
|
|
146
|
-
rememberRsc(target, next);
|
|
147
|
-
} else {
|
|
148
|
-
rememberRsc(target, next);
|
|
149
|
-
}
|
|
150
199
|
try {
|
|
151
|
-
|
|
200
|
+
let next = rscCache.get(target);
|
|
201
|
+
if (!next) {
|
|
202
|
+
const fetched = await fetchRsc(target, {
|
|
203
|
+
replaceOnRedirect: options.replace,
|
|
204
|
+
shouldApplyNavigation: () => navId === navigationSeq,
|
|
205
|
+
});
|
|
206
|
+
if (fetched.type === "redirected") return;
|
|
207
|
+
next = fetched.thenable;
|
|
208
|
+
} else {
|
|
209
|
+
rememberRscCacheEntry(rscCache, target, next, MAX_RSC_CACHE_ENTRIES);
|
|
210
|
+
}
|
|
211
|
+
observeRscNavigation({
|
|
212
|
+
cache: rscCache,
|
|
213
|
+
href: target,
|
|
214
|
+
thenable: next,
|
|
215
|
+
navId,
|
|
216
|
+
getCurrentNavId: () => navigationSeq,
|
|
217
|
+
isExpectedNavigationError: (error) => error instanceof RscRedirectNavigationStarted,
|
|
218
|
+
onLatestError: (error) => hardNavigateAfterRscFailure(target, options.replace, error),
|
|
219
|
+
});
|
|
220
|
+
commitLatestRscNavigation({
|
|
221
|
+
cache: rscCache,
|
|
222
|
+
href: target,
|
|
223
|
+
thenable: next,
|
|
224
|
+
maxEntries: MAX_RSC_CACHE_ENTRIES,
|
|
225
|
+
startTransition,
|
|
226
|
+
commitThenable: setThenable,
|
|
227
|
+
updateHistory: () => {
|
|
228
|
+
if (options.replace) window.history.replaceState(null, "", target);
|
|
229
|
+
else window.history.pushState(null, "", target);
|
|
230
|
+
},
|
|
231
|
+
scrollToTop,
|
|
232
|
+
bumpScrollToTop: () => setScrollToTopTick((tick) => tick + 1),
|
|
233
|
+
navId,
|
|
234
|
+
getCurrentNavId: () => navigationSeq,
|
|
235
|
+
});
|
|
152
236
|
} catch (error) {
|
|
153
|
-
|
|
154
|
-
|
|
237
|
+
if (error instanceof RscRedirectNavigationStarted) return;
|
|
238
|
+
if (navId === navigationSeq) hardNavigateAfterRscFailure(target, options.replace, error);
|
|
155
239
|
}
|
|
156
|
-
if (navId !== navigationSeq) return;
|
|
157
|
-
startTransition(() => {
|
|
158
|
-
setThenable(next as RscThenable);
|
|
159
|
-
if (options.replace) window.history.replaceState(null, "", target);
|
|
160
|
-
else window.history.pushState(null, "", target);
|
|
161
|
-
if (scrollToTop) setScrollToTopTick((tick) => tick + 1);
|
|
162
|
-
});
|
|
163
240
|
};
|
|
164
241
|
|
|
165
242
|
return use(thenable);
|
package/server/rscHttp.ts
CHANGED
|
@@ -1,7 +1,127 @@
|
|
|
1
1
|
export const RSC_CONTENT_TYPE = "text/x-component; charset=utf-8";
|
|
2
|
+
const RSC_REDIRECT_ROW_RE = /^([0-9a-z]+):E(\{[^\n]*"digest":"AKAN_REDIRECT(?:;[^"]*)?"[^\n]*\})(\n?)$/;
|
|
3
|
+
const AKAN_REDIRECT_DIGEST_PREFIX = "AKAN_REDIRECT";
|
|
4
|
+
|
|
5
|
+
export interface RscRedirectRow {
|
|
6
|
+
rowId: string;
|
|
7
|
+
location?: string;
|
|
8
|
+
method?: "replace" | "push";
|
|
9
|
+
status?: 303 | 307 | 308;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function encodeAkanRedirectDigest(input: {
|
|
13
|
+
location: string;
|
|
14
|
+
method: "replace" | "push";
|
|
15
|
+
status: 303 | 307 | 308;
|
|
16
|
+
}): string {
|
|
17
|
+
return [AKAN_REDIRECT_DIGEST_PREFIX, input.method, String(input.status), encodeURIComponent(input.location)].join(
|
|
18
|
+
";",
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function decodeAkanRedirectDigest(digest: unknown): Omit<RscRedirectRow, "rowId"> {
|
|
23
|
+
if (typeof digest !== "string" || !digest.startsWith(AKAN_REDIRECT_DIGEST_PREFIX)) return {};
|
|
24
|
+
const [, method, rawStatus, encodedLocation] = digest.split(";");
|
|
25
|
+
const out: Omit<RscRedirectRow, "rowId"> = {};
|
|
26
|
+
if (method === "replace" || method === "push") out.method = method;
|
|
27
|
+
const status = Number(rawStatus);
|
|
28
|
+
if (status === 303 || status === 307 || status === 308) out.status = status;
|
|
29
|
+
if (encodedLocation) {
|
|
30
|
+
try {
|
|
31
|
+
out.location = decodeURIComponent(encodedLocation);
|
|
32
|
+
} catch {
|
|
33
|
+
out.location = encodedLocation;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
2
38
|
|
|
3
39
|
export function isRscPayloadResponse(res: Response): boolean {
|
|
4
40
|
if (!res.body) return false;
|
|
5
41
|
if (res.ok) return true;
|
|
6
42
|
return res.status === 404 && (res.headers.get("Content-Type") ?? "").toLowerCase().startsWith("text/x-component");
|
|
7
43
|
}
|
|
44
|
+
|
|
45
|
+
export function getRscPayloadStream(res: Response): ReadableStream<Uint8Array> | null {
|
|
46
|
+
if (!isRscPayloadResponse(res)) return null;
|
|
47
|
+
return res.body;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function concatBytes(left: Uint8Array, right: Uint8Array): Uint8Array<ArrayBuffer> {
|
|
51
|
+
const combined = new Uint8Array(left.byteLength + right.byteLength);
|
|
52
|
+
combined.set(left, 0);
|
|
53
|
+
combined.set(right, left.byteLength);
|
|
54
|
+
return combined;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function guardRscRedirectRows(
|
|
58
|
+
stream: ReadableStream<Uint8Array>,
|
|
59
|
+
options: { onRedirect?: (redirect: RscRedirectRow) => void } = {},
|
|
60
|
+
): ReadableStream<Uint8Array> {
|
|
61
|
+
const decoder = new TextDecoder("utf-8", { fatal: true });
|
|
62
|
+
const encoder = new TextEncoder();
|
|
63
|
+
let buffered: Uint8Array<ArrayBuffer> = new Uint8Array(0);
|
|
64
|
+
let redirected = false;
|
|
65
|
+
|
|
66
|
+
const getRedirectRow = (row: Uint8Array): RscRedirectRow | null => {
|
|
67
|
+
try {
|
|
68
|
+
const match = RSC_REDIRECT_ROW_RE.exec(decoder.decode(row));
|
|
69
|
+
if (!match?.[1] || !match[2]) return null;
|
|
70
|
+
let redirect: Omit<RscRedirectRow, "rowId"> = {};
|
|
71
|
+
try {
|
|
72
|
+
const payload = JSON.parse(match[2]) as { digest?: unknown; location?: unknown; message?: unknown };
|
|
73
|
+
redirect = decodeAkanRedirectDigest(payload.digest);
|
|
74
|
+
if (!redirect.location && typeof payload.location === "string") redirect.location = payload.location;
|
|
75
|
+
else if (!redirect.location && typeof payload.message === "string")
|
|
76
|
+
redirect.location = /^Redirect to (.+)$/.exec(payload.message)?.[1];
|
|
77
|
+
} catch {}
|
|
78
|
+
return { rowId: match[1], ...redirect };
|
|
79
|
+
} catch {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const enqueueCompleteRows = (chunk: Uint8Array, controller: TransformStreamDefaultController<Uint8Array>) => {
|
|
85
|
+
buffered = concatBytes(buffered, chunk);
|
|
86
|
+
let rowStart = 0;
|
|
87
|
+
for (let index = 0; index < buffered.byteLength; index += 1) {
|
|
88
|
+
if (buffered[index] !== 10) continue;
|
|
89
|
+
const row = buffered.slice(rowStart, index + 1);
|
|
90
|
+
const redirect = getRedirectRow(row);
|
|
91
|
+
if (redirect) {
|
|
92
|
+
if (!redirected) {
|
|
93
|
+
redirected = true;
|
|
94
|
+
options.onRedirect?.(redirect);
|
|
95
|
+
}
|
|
96
|
+
controller.enqueue(encoder.encode(`${redirect.rowId}:null\n`));
|
|
97
|
+
rowStart = index + 1;
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
controller.enqueue(row);
|
|
101
|
+
rowStart = index + 1;
|
|
102
|
+
}
|
|
103
|
+
buffered = rowStart === 0 ? buffered : buffered.slice(rowStart);
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
return stream.pipeThrough(
|
|
107
|
+
new TransformStream<Uint8Array, Uint8Array>({
|
|
108
|
+
transform(chunk, controller) {
|
|
109
|
+
enqueueCompleteRows(chunk, controller);
|
|
110
|
+
},
|
|
111
|
+
flush(controller) {
|
|
112
|
+
if (buffered.byteLength === 0) return;
|
|
113
|
+
const redirect = getRedirectRow(buffered);
|
|
114
|
+
if (redirect) {
|
|
115
|
+
if (!redirected) {
|
|
116
|
+
redirected = true;
|
|
117
|
+
options.onRedirect?.(redirect);
|
|
118
|
+
}
|
|
119
|
+
controller.enqueue(encoder.encode(`${redirect.rowId}:null`));
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
controller.enqueue(buffered);
|
|
123
|
+
buffered = new Uint8Array(0);
|
|
124
|
+
},
|
|
125
|
+
}),
|
|
126
|
+
);
|
|
127
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
export interface RscNavigationCache<T> {
|
|
2
|
+
get(key: string): T | undefined;
|
|
3
|
+
set(key: string, value: T): void;
|
|
4
|
+
delete(key: string): boolean;
|
|
5
|
+
keys(): IterableIterator<string>;
|
|
6
|
+
readonly size: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function rememberRscCacheEntry<T>(
|
|
10
|
+
cache: RscNavigationCache<T>,
|
|
11
|
+
href: string,
|
|
12
|
+
thenable: T,
|
|
13
|
+
maxEntries: number,
|
|
14
|
+
): void {
|
|
15
|
+
cache.delete(href);
|
|
16
|
+
cache.set(href, thenable);
|
|
17
|
+
while (cache.size > maxEntries) {
|
|
18
|
+
const oldest = cache.keys().next().value;
|
|
19
|
+
if (!oldest) break;
|
|
20
|
+
cache.delete(oldest);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function deleteRscCacheEntryIfCurrent<T>(cache: RscNavigationCache<T>, href: string, thenable: T): boolean {
|
|
25
|
+
if (cache.get(href) !== thenable) return false;
|
|
26
|
+
return cache.delete(href);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface CommitRscNavigationInput<T> {
|
|
30
|
+
cache: RscNavigationCache<T>;
|
|
31
|
+
href: string;
|
|
32
|
+
thenable: T;
|
|
33
|
+
maxEntries: number;
|
|
34
|
+
startTransition: (callback: () => void) => void;
|
|
35
|
+
commitThenable: (thenable: T) => void;
|
|
36
|
+
updateHistory?: () => void;
|
|
37
|
+
scrollToTop?: boolean;
|
|
38
|
+
bumpScrollToTop?: () => void;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function commitRscNavigation<T>({
|
|
42
|
+
cache,
|
|
43
|
+
href,
|
|
44
|
+
thenable,
|
|
45
|
+
maxEntries,
|
|
46
|
+
startTransition,
|
|
47
|
+
commitThenable,
|
|
48
|
+
updateHistory,
|
|
49
|
+
scrollToTop,
|
|
50
|
+
bumpScrollToTop,
|
|
51
|
+
}: CommitRscNavigationInput<T>): void {
|
|
52
|
+
rememberRscCacheEntry(cache, href, thenable, maxEntries);
|
|
53
|
+
startTransition(() => {
|
|
54
|
+
commitThenable(thenable);
|
|
55
|
+
updateHistory?.();
|
|
56
|
+
if (scrollToTop) bumpScrollToTop?.();
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function commitLatestRscNavigation<T>({
|
|
61
|
+
navId,
|
|
62
|
+
getCurrentNavId,
|
|
63
|
+
...input
|
|
64
|
+
}: CommitRscNavigationInput<T> & {
|
|
65
|
+
navId: number;
|
|
66
|
+
getCurrentNavId: () => number;
|
|
67
|
+
}): boolean {
|
|
68
|
+
if (navId !== getCurrentNavId()) return false;
|
|
69
|
+
commitRscNavigation(input);
|
|
70
|
+
return true;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function observeRscNavigation<T extends PromiseLike<unknown>>({
|
|
74
|
+
cache,
|
|
75
|
+
href,
|
|
76
|
+
thenable,
|
|
77
|
+
navId,
|
|
78
|
+
getCurrentNavId,
|
|
79
|
+
isExpectedNavigationError,
|
|
80
|
+
onLatestError,
|
|
81
|
+
}: {
|
|
82
|
+
cache: RscNavigationCache<T>;
|
|
83
|
+
href: string;
|
|
84
|
+
thenable: T;
|
|
85
|
+
navId: number;
|
|
86
|
+
getCurrentNavId: () => number;
|
|
87
|
+
isExpectedNavigationError?: (error: unknown) => boolean;
|
|
88
|
+
onLatestError: (error: unknown) => void;
|
|
89
|
+
}): void {
|
|
90
|
+
void Promise.resolve(thenable).catch((error) => {
|
|
91
|
+
deleteRscCacheEntryIfCurrent(cache, href, thenable);
|
|
92
|
+
if (isExpectedNavigationError?.(error)) return;
|
|
93
|
+
if (navId === getCurrentNavId()) onLatestError(error);
|
|
94
|
+
});
|
|
95
|
+
}
|