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
package/server/rscWorker.tsx
CHANGED
|
@@ -6,13 +6,36 @@ import type {
|
|
|
6
6
|
RedirectStatus,
|
|
7
7
|
} from "akanjs/client";
|
|
8
8
|
import { type AkanI18nConfig, DEFAULT_AKAN_I18N, getBasePathFromPathname, Logger } from "akanjs/common";
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
getRequestDynamicUsage,
|
|
11
|
+
getRequestPolicy,
|
|
12
|
+
getRequestTheme,
|
|
13
|
+
requestStorage,
|
|
14
|
+
untrackedCookies,
|
|
15
|
+
untrackedRequest,
|
|
16
|
+
updateRequestPolicy,
|
|
17
|
+
} from "akanjs/fetch";
|
|
10
18
|
import type { ReactNode } from "react";
|
|
11
19
|
import { renderToReadableStream } from "react-server-dom-webpack/server.node";
|
|
12
20
|
import type { ClientManifest } from "./artifact";
|
|
21
|
+
import {
|
|
22
|
+
createRouteCacheEntry,
|
|
23
|
+
isPublicRouteCacheableRequest,
|
|
24
|
+
isRouteCachePathAllowed,
|
|
25
|
+
LruTtlCache,
|
|
26
|
+
parsePositiveInt,
|
|
27
|
+
type RouteCacheEntry,
|
|
28
|
+
type RouteCacheRenderState,
|
|
29
|
+
resolveAutoRouteCacheTtl,
|
|
30
|
+
resolveRouteCacheStoreTtl,
|
|
31
|
+
shouldStoreRouteCache,
|
|
32
|
+
} from "./cachePolicy";
|
|
33
|
+
import { shouldRenderLocaleAlternates } from "./metadata";
|
|
13
34
|
import { ProcessMetricsCollector } from "./processMetricsCollector";
|
|
14
35
|
import { RouteElementComposer } from "./routeElementComposer";
|
|
15
36
|
import { type PagesContext, RouteTreeBuilder } from "./routeTreeBuilder";
|
|
37
|
+
import { encodeAkanRedirectDigest } from "./rscHttp";
|
|
38
|
+
import { replayCachedRscResult } from "./rscWorkerReplay";
|
|
16
39
|
import { createSystemPageDocument, getSystemPageHomeHref } from "./systemPages";
|
|
17
40
|
|
|
18
41
|
interface InitMsg {
|
|
@@ -32,6 +55,10 @@ interface RenderMsg {
|
|
|
32
55
|
headers?: Record<string, string>;
|
|
33
56
|
clientManifest?: ClientManifest;
|
|
34
57
|
}
|
|
58
|
+
interface CancelMsg {
|
|
59
|
+
type: "cancel";
|
|
60
|
+
requestId: string;
|
|
61
|
+
}
|
|
35
62
|
interface ReloadMsg {
|
|
36
63
|
type: "reload";
|
|
37
64
|
clientManifest: ClientManifest;
|
|
@@ -44,11 +71,23 @@ interface UpdateCssAssetsMsg {
|
|
|
44
71
|
type: "updateCssAssets";
|
|
45
72
|
cssAssets: Record<string, { cssUrl: string; cssRelPath: string }>;
|
|
46
73
|
}
|
|
47
|
-
|
|
74
|
+
interface InvalidateCacheMsg {
|
|
75
|
+
type: "invalidate-cache";
|
|
76
|
+
reason?: string;
|
|
77
|
+
}
|
|
78
|
+
type InMsg = InitMsg | RenderMsg | CancelMsg | ReloadMsg | UpdateCssAssetsMsg | InvalidateCacheMsg;
|
|
48
79
|
type RenderControl =
|
|
49
80
|
| { type: "redirect"; location: string; method: "replace" | "push"; status: RedirectStatus }
|
|
50
81
|
| { type: "not-found" }
|
|
51
82
|
| { type: "error"; error: unknown };
|
|
83
|
+
interface FlightRenderResult {
|
|
84
|
+
chunks: Uint8Array[];
|
|
85
|
+
bytes: number;
|
|
86
|
+
chunksCount: number;
|
|
87
|
+
control: RenderControl | null;
|
|
88
|
+
lateControlSent: boolean;
|
|
89
|
+
cancelled: boolean;
|
|
90
|
+
}
|
|
52
91
|
|
|
53
92
|
interface RscRendererStats {
|
|
54
93
|
renderCount: number;
|
|
@@ -74,11 +113,11 @@ interface RouteRenderStats {
|
|
|
74
113
|
}
|
|
75
114
|
|
|
76
115
|
interface CachedRscResult {
|
|
77
|
-
expiresAt: number;
|
|
78
116
|
chunks: Uint8Array[];
|
|
79
117
|
bytes: number;
|
|
80
118
|
chunksCount: number;
|
|
81
119
|
theme?: string;
|
|
120
|
+
cacheState: RouteCacheRenderState;
|
|
82
121
|
}
|
|
83
122
|
|
|
84
123
|
export function isAkanRedirectError(error: unknown): error is AkanRedirectError {
|
|
@@ -127,7 +166,11 @@ class RscRenderer {
|
|
|
127
166
|
pagesBundleBuildId: 0,
|
|
128
167
|
};
|
|
129
168
|
readonly #routeStats = new Map<string, RouteRenderStats>();
|
|
130
|
-
|
|
169
|
+
#resultCache = new LruTtlCache<CachedRscResult>(
|
|
170
|
+
parsePositiveInt(process.env.AKAN_RSC_RESULT_CACHE_MAX_ENTRIES) ?? 100,
|
|
171
|
+
);
|
|
172
|
+
readonly #activeRenderReaders = new Map<string, ReadableStreamDefaultReader<Uint8Array>>();
|
|
173
|
+
readonly #cancelledRenderRequests = new Set<string>();
|
|
131
174
|
#resultCacheHits = 0;
|
|
132
175
|
#resultCacheMisses = 0;
|
|
133
176
|
#resultCacheBypass = 0;
|
|
@@ -158,6 +201,10 @@ class RscRenderer {
|
|
|
158
201
|
this.#logger.verbose(`received render requestId=${msg.requestId} url=${msg.url} method=${msg.method ?? "GET"}`);
|
|
159
202
|
void this.#handleRender(msg);
|
|
160
203
|
return;
|
|
204
|
+
case "cancel":
|
|
205
|
+
this.#logger.verbose(`received cancel requestId=${msg.requestId}`);
|
|
206
|
+
this.#handleCancel(msg.requestId);
|
|
207
|
+
return;
|
|
161
208
|
case "reload":
|
|
162
209
|
this.#logger.verbose(`received reload buildId=${msg.buildId}`);
|
|
163
210
|
void this.#handleReload(msg);
|
|
@@ -166,9 +213,21 @@ class RscRenderer {
|
|
|
166
213
|
this.#logger.verbose(`received updateCssAssets count=${Object.keys(msg.cssAssets).length}`);
|
|
167
214
|
this.#cssAssets = msg.cssAssets;
|
|
168
215
|
return;
|
|
216
|
+
case "invalidate-cache":
|
|
217
|
+
this.#logger.verbose(`received invalidate-cache reason=${msg.reason ?? "(none)"}`);
|
|
218
|
+
this.#resultCache.clear();
|
|
219
|
+
return;
|
|
169
220
|
}
|
|
170
221
|
}
|
|
171
222
|
|
|
223
|
+
#handleCancel(requestId: string): void {
|
|
224
|
+
this.#cancelledRenderRequests.add(requestId);
|
|
225
|
+
const reader = this.#activeRenderReaders.get(requestId);
|
|
226
|
+
if (!reader) return;
|
|
227
|
+
void reader.cancel().catch(() => {
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
|
|
172
231
|
async #handleInit(msg: InitMsg): Promise<void> {
|
|
173
232
|
const startedAt = Date.now();
|
|
174
233
|
try {
|
|
@@ -289,8 +348,8 @@ class RscRenderer {
|
|
|
289
348
|
);
|
|
290
349
|
else this.#logger.verbose(`render[${requestId}] no route matched pathname=${urlObj.pathname} — rendering 404`);
|
|
291
350
|
const beforeLoadedKeys = RouteTreeBuilder.getCacheStats().loadedModuleKeys;
|
|
292
|
-
const
|
|
293
|
-
const cached =
|
|
351
|
+
const cacheEntry = match ? this.#getResultCacheEntry(request, urlObj) : null;
|
|
352
|
+
const cached = cacheEntry ? this.#getCachedResult(cacheEntry.key) : null;
|
|
294
353
|
if (cached) {
|
|
295
354
|
this.#stats.lastRenderDurationMs = Date.now() - startedAt;
|
|
296
355
|
this.#stats.lastRenderLoadedModuleDelta = 0;
|
|
@@ -300,27 +359,66 @@ class RscRenderer {
|
|
|
300
359
|
this.#stats.totalFlightBytes += cached.bytes;
|
|
301
360
|
this.#stats.totalFlightChunks += cached.chunksCount;
|
|
302
361
|
this.#recordRouteStats(routeId, cached.bytes, this.#stats.lastRenderDurationMs);
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
362
|
+
await replayCachedRscResult({
|
|
363
|
+
requestId,
|
|
364
|
+
chunks: cached.chunks,
|
|
365
|
+
theme: cached.theme,
|
|
366
|
+
cacheState: cached.cacheState,
|
|
367
|
+
send: (message) => this.#send(message),
|
|
368
|
+
isCancelled: () => this.#cancelledRenderRequests.has(requestId),
|
|
369
|
+
});
|
|
306
370
|
return;
|
|
307
371
|
}
|
|
308
|
-
const theme =
|
|
309
|
-
const
|
|
372
|
+
const theme = untrackedCookies().get("theme")?.value;
|
|
373
|
+
const searchParams = RouteTreeBuilder.parseSearchParams(urlObj.search);
|
|
374
|
+
let element: ReactNode;
|
|
375
|
+
if (match) element = await this.#renderMatched(urlObj, match, theme, searchParams);
|
|
376
|
+
else element = await this.#renderNotFound(urlObj);
|
|
310
377
|
this.#logger.verbose(`render[${requestId}] starting Flight stream`);
|
|
311
|
-
const result = await this.#renderFlightElement(element, msg.clientManifest ?? this.#clientManifest
|
|
378
|
+
const result = await this.#renderFlightElement(element, msg.clientManifest ?? this.#clientManifest, {
|
|
379
|
+
requestId,
|
|
380
|
+
collectChunks: cacheEntry !== null,
|
|
381
|
+
status: match ? undefined : 404,
|
|
382
|
+
onComplete: ({ chunks, bytes, chunksCount, control, lateControlSent }) => {
|
|
383
|
+
const cacheState = shouldStoreRouteCache({
|
|
384
|
+
policy: getRequestPolicy(),
|
|
385
|
+
dynamicUsage: getRequestDynamicUsage(),
|
|
386
|
+
renderControlType: control?.type,
|
|
387
|
+
lateRedirect: control?.type === "redirect" && lateControlSent,
|
|
388
|
+
});
|
|
389
|
+
const storeTtl = cacheEntry ? resolveRouteCacheStoreTtl(cacheEntry.ttl, cacheState) : null;
|
|
390
|
+
if (cacheEntry && storeTtl !== null) {
|
|
391
|
+
this.#setCachedResult(
|
|
392
|
+
cacheEntry.key,
|
|
393
|
+
{
|
|
394
|
+
chunks,
|
|
395
|
+
bytes,
|
|
396
|
+
chunksCount,
|
|
397
|
+
theme: getRequestTheme(),
|
|
398
|
+
cacheState,
|
|
399
|
+
},
|
|
400
|
+
storeTtl,
|
|
401
|
+
);
|
|
402
|
+
}
|
|
403
|
+
return cacheState;
|
|
404
|
+
},
|
|
405
|
+
});
|
|
406
|
+
if (result.cancelled) return;
|
|
312
407
|
const control = result.control;
|
|
313
408
|
if (control) {
|
|
314
409
|
this.#stats.lastRenderKind = control.type;
|
|
410
|
+
if (result.lateControlSent) {
|
|
411
|
+
this.#logger.verbose(`render[${requestId}] late ${control.type} delivered after stream start`);
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
315
414
|
if (!match && control.type === "error") {
|
|
316
415
|
const systemResult = await this.#renderFlightElement(
|
|
317
416
|
this.#renderSystemNotFound(urlObj),
|
|
318
417
|
msg.clientManifest ?? this.#clientManifest,
|
|
418
|
+
{ requestId, status: 404 },
|
|
319
419
|
);
|
|
420
|
+
if (systemResult.cancelled) return;
|
|
320
421
|
if (!systemResult.control) {
|
|
321
|
-
this.#send({ type: "meta", requestId, theme: getRequestTheme(), status: 404 });
|
|
322
|
-
for (const chunk of systemResult.chunks) this.#send({ type: "chunk", requestId, data: chunk });
|
|
323
|
-
this.#send({ type: "end", requestId });
|
|
324
422
|
return;
|
|
325
423
|
}
|
|
326
424
|
}
|
|
@@ -332,7 +430,7 @@ class RscRenderer {
|
|
|
332
430
|
kind: control.type,
|
|
333
431
|
route: match.pathRoute,
|
|
334
432
|
params: match.params,
|
|
335
|
-
searchParams
|
|
433
|
+
searchParams,
|
|
336
434
|
pathname: urlObj.pathname,
|
|
337
435
|
url: urlObj,
|
|
338
436
|
error: control.type === "error" ? control.error : undefined,
|
|
@@ -341,34 +439,34 @@ class RscRenderer {
|
|
|
341
439
|
) {
|
|
342
440
|
return;
|
|
343
441
|
}
|
|
442
|
+
if (
|
|
443
|
+
control.type === "not-found" &&
|
|
444
|
+
(await this.#trySendSystemNotFoundRender({
|
|
445
|
+
requestId,
|
|
446
|
+
url: urlObj,
|
|
447
|
+
clientManifest: msg.clientManifest ?? this.#clientManifest,
|
|
448
|
+
}))
|
|
449
|
+
) {
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
344
452
|
this.#sendRenderControl(requestId, control);
|
|
345
453
|
return;
|
|
346
454
|
}
|
|
347
455
|
this.#stats.lastFlightBytes = result.bytes;
|
|
348
|
-
this.#stats.lastFlightChunks = result.
|
|
456
|
+
this.#stats.lastFlightChunks = result.chunksCount;
|
|
349
457
|
this.#stats.totalFlightBytes += result.bytes;
|
|
350
|
-
this.#stats.totalFlightChunks += result.
|
|
458
|
+
this.#stats.totalFlightChunks += result.chunksCount;
|
|
351
459
|
this.#stats.lastRenderDurationMs = Date.now() - startedAt;
|
|
352
460
|
const afterLoadedKeys = RouteTreeBuilder.getCacheStats().loadedModuleKeys;
|
|
353
461
|
this.#stats.lastRenderLoadedModules = afterLoadedKeys.filter((key) => !beforeLoadedKeys.includes(key));
|
|
354
462
|
this.#stats.lastRenderLoadedModuleDelta = this.#stats.lastRenderLoadedModules.length;
|
|
355
463
|
this.#recordRouteStats(routeId, result.bytes, this.#stats.lastRenderDurationMs);
|
|
356
464
|
const responseTheme = getRequestTheme();
|
|
357
|
-
if (cacheKey)
|
|
358
|
-
this.#setCachedResult(cacheKey, {
|
|
359
|
-
chunks: result.chunks,
|
|
360
|
-
bytes: result.bytes,
|
|
361
|
-
chunksCount: result.chunks.length,
|
|
362
|
-
theme: responseTheme,
|
|
363
|
-
});
|
|
364
|
-
this.#send({ type: "meta", requestId, theme: responseTheme, status: match ? undefined : 404 });
|
|
365
|
-
for (const chunk of result.chunks) {
|
|
366
|
-
this.#send({ type: "chunk", requestId, data: chunk });
|
|
367
|
-
}
|
|
368
465
|
this.#logger.verbose(
|
|
369
|
-
`render[${requestId}] done chunks=${result.
|
|
466
|
+
`render[${requestId}] done chunks=${result.chunksCount} bytes=${result.bytes} theme=${responseTheme ?? "(none)"} in ${
|
|
467
|
+
Date.now() - startedAt
|
|
468
|
+
}ms`,
|
|
370
469
|
);
|
|
371
|
-
this.#send({ type: "end", requestId });
|
|
372
470
|
});
|
|
373
471
|
} catch (error) {
|
|
374
472
|
if (isAkanRedirectError(error)) {
|
|
@@ -404,6 +502,16 @@ class RscRenderer {
|
|
|
404
502
|
) {
|
|
405
503
|
return;
|
|
406
504
|
}
|
|
505
|
+
if (
|
|
506
|
+
fallbackUrl &&
|
|
507
|
+
(await this.#trySendSystemNotFoundRender({
|
|
508
|
+
requestId,
|
|
509
|
+
url: fallbackUrl,
|
|
510
|
+
clientManifest: msg.clientManifest ?? this.#clientManifest,
|
|
511
|
+
}))
|
|
512
|
+
) {
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
407
515
|
this.#send({ type: "not-found", requestId });
|
|
408
516
|
return;
|
|
409
517
|
}
|
|
@@ -435,6 +543,8 @@ class RscRenderer {
|
|
|
435
543
|
message: error instanceof Error ? error.message : String(error),
|
|
436
544
|
});
|
|
437
545
|
} finally {
|
|
546
|
+
this.#activeRenderReaders.delete(requestId);
|
|
547
|
+
this.#cancelledRenderRequests.delete(requestId);
|
|
438
548
|
this.#stats.inFlightRenderCount = Math.max(0, this.#stats.inFlightRenderCount - 1);
|
|
439
549
|
}
|
|
440
550
|
}
|
|
@@ -484,7 +594,19 @@ class RscRenderer {
|
|
|
484
594
|
async #renderFlightElement(
|
|
485
595
|
element: ReactNode,
|
|
486
596
|
clientManifest: ClientManifest,
|
|
487
|
-
|
|
597
|
+
options: {
|
|
598
|
+
requestId?: string;
|
|
599
|
+
collectChunks?: boolean;
|
|
600
|
+
status?: number;
|
|
601
|
+
onComplete?: (result: {
|
|
602
|
+
chunks: Uint8Array[];
|
|
603
|
+
bytes: number;
|
|
604
|
+
chunksCount: number;
|
|
605
|
+
control: RenderControl | null;
|
|
606
|
+
lateControlSent: boolean;
|
|
607
|
+
}) => Promise<RouteCacheRenderState> | RouteCacheRenderState;
|
|
608
|
+
} = {},
|
|
609
|
+
): Promise<FlightRenderResult> {
|
|
488
610
|
const controlRef: { current: RenderControl | null } = { current: null };
|
|
489
611
|
const stream = await renderToReadableStream(element, clientManifest, {
|
|
490
612
|
onError: (error) => {
|
|
@@ -495,7 +617,11 @@ class RscRenderer {
|
|
|
495
617
|
method: error.method,
|
|
496
618
|
status: error.status,
|
|
497
619
|
};
|
|
498
|
-
return
|
|
620
|
+
return encodeAkanRedirectDigest({
|
|
621
|
+
location: error.location,
|
|
622
|
+
method: error.method,
|
|
623
|
+
status: error.status,
|
|
624
|
+
});
|
|
499
625
|
}
|
|
500
626
|
if (isAkanNotFoundError(error)) {
|
|
501
627
|
controlRef.current = { type: "not-found" };
|
|
@@ -506,20 +632,85 @@ class RscRenderer {
|
|
|
506
632
|
},
|
|
507
633
|
});
|
|
508
634
|
const reader = stream.getReader();
|
|
635
|
+
if (options.requestId) this.#activeRenderReaders.set(options.requestId, reader);
|
|
509
636
|
let bytes = 0;
|
|
637
|
+
let chunksCount = 0;
|
|
638
|
+
let sentMeta = false;
|
|
639
|
+
let sentChunk = false;
|
|
640
|
+
let lateControlSent = false;
|
|
510
641
|
const chunks: Uint8Array[] = [];
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
642
|
+
const sendMeta = () => {
|
|
643
|
+
if (!options.requestId || sentMeta) return;
|
|
644
|
+
sentMeta = true;
|
|
645
|
+
this.#send({ type: "meta", requestId: options.requestId, theme: getRequestTheme(), status: options.status });
|
|
646
|
+
};
|
|
647
|
+
const sendLateRedirect = () => {
|
|
648
|
+
if (!options.requestId || lateControlSent || controlRef.current?.type !== "redirect") return;
|
|
649
|
+
|
|
650
|
+
lateControlSent = true;
|
|
651
|
+
this.#send({
|
|
652
|
+
type: "late-redirect",
|
|
653
|
+
requestId: options.requestId,
|
|
654
|
+
location: controlRef.current.location,
|
|
655
|
+
method: controlRef.current.method,
|
|
656
|
+
status: controlRef.current.status,
|
|
657
|
+
});
|
|
658
|
+
};
|
|
659
|
+
try {
|
|
660
|
+
while (true) {
|
|
661
|
+
if (options.requestId && this.#cancelledRenderRequests.has(options.requestId)) {
|
|
662
|
+
await reader.cancel();
|
|
663
|
+
return { chunks, bytes, chunksCount, control: null, lateControlSent, cancelled: true };
|
|
664
|
+
}
|
|
665
|
+
const { value, done } = await reader.read();
|
|
666
|
+
if (controlRef.current && !sentChunk) {
|
|
667
|
+
await reader.cancel();
|
|
668
|
+
return { chunks, bytes, chunksCount, control: controlRef.current, lateControlSent, cancelled: false };
|
|
669
|
+
}
|
|
670
|
+
if (controlRef.current && sentChunk) sendLateRedirect();
|
|
671
|
+
if (done) break;
|
|
672
|
+
const chunk = value instanceof Uint8Array ? value : new Uint8Array(value as ArrayBufferLike);
|
|
673
|
+
bytes += chunk.byteLength;
|
|
674
|
+
chunksCount += 1;
|
|
675
|
+
if (options.collectChunks) chunks.push(chunk);
|
|
676
|
+
if (options.requestId) {
|
|
677
|
+
sendMeta();
|
|
678
|
+
this.#send({ type: "chunk", requestId: options.requestId, data: chunk });
|
|
679
|
+
sentChunk = true;
|
|
680
|
+
}
|
|
517
681
|
}
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
682
|
+
} catch (error) {
|
|
683
|
+
if (options.requestId && this.#cancelledRenderRequests.has(options.requestId)) {
|
|
684
|
+
return { chunks, bytes, chunksCount, control: null, lateControlSent, cancelled: true };
|
|
685
|
+
}
|
|
686
|
+
throw error;
|
|
687
|
+
} finally {
|
|
688
|
+
if (options.requestId) this.#activeRenderReaders.delete(options.requestId);
|
|
689
|
+
reader.releaseLock();
|
|
690
|
+
}
|
|
691
|
+
if (controlRef.current && sentChunk) sendLateRedirect();
|
|
692
|
+
if (controlRef.current && !sentChunk)
|
|
693
|
+
return { chunks, bytes, chunksCount, control: controlRef.current, lateControlSent, cancelled: false };
|
|
694
|
+
if (options.requestId) {
|
|
695
|
+
sendMeta();
|
|
696
|
+
const cacheState = (await options.onComplete?.({
|
|
697
|
+
chunks,
|
|
698
|
+
bytes,
|
|
699
|
+
chunksCount,
|
|
700
|
+
control: controlRef.current,
|
|
701
|
+
lateControlSent,
|
|
702
|
+
})) ?? { cacheable: false, reason: "uncacheable-render" };
|
|
703
|
+
this.#send({ type: "cache-state", requestId: options.requestId, state: cacheState });
|
|
704
|
+
this.#send({ type: "end", requestId: options.requestId });
|
|
521
705
|
}
|
|
522
|
-
return {
|
|
706
|
+
return {
|
|
707
|
+
chunks,
|
|
708
|
+
bytes,
|
|
709
|
+
chunksCount,
|
|
710
|
+
control: lateControlSent ? controlRef.current : null,
|
|
711
|
+
lateControlSent,
|
|
712
|
+
cancelled: false,
|
|
713
|
+
};
|
|
523
714
|
}
|
|
524
715
|
|
|
525
716
|
async #trySendFallbackRender({
|
|
@@ -555,15 +746,16 @@ class RscRenderer {
|
|
|
555
746
|
digest: kind === "error" ? "AKAN_RENDER_ERROR" : undefined,
|
|
556
747
|
});
|
|
557
748
|
if (!element) return false;
|
|
558
|
-
const result = await this.#renderFlightElement(element, clientManifest
|
|
749
|
+
const result = await this.#renderFlightElement(element, clientManifest, {
|
|
750
|
+
requestId,
|
|
751
|
+
status: kind === "not-found" ? 404 : 500,
|
|
752
|
+
});
|
|
753
|
+
if (result.cancelled) return true;
|
|
559
754
|
if (result.control) return false;
|
|
560
|
-
this.#send({ type: "meta", requestId, theme: getRequestTheme(), status: kind === "not-found" ? 404 : 500 });
|
|
561
|
-
for (const chunk of result.chunks) this.#send({ type: "chunk", requestId, data: chunk });
|
|
562
|
-
this.#send({ type: "end", requestId });
|
|
563
755
|
this.#stats.lastFlightBytes = result.bytes;
|
|
564
|
-
this.#stats.lastFlightChunks = result.
|
|
756
|
+
this.#stats.lastFlightChunks = result.chunksCount;
|
|
565
757
|
this.#stats.totalFlightBytes += result.bytes;
|
|
566
|
-
this.#stats.totalFlightChunks += result.
|
|
758
|
+
this.#stats.totalFlightChunks += result.chunksCount;
|
|
567
759
|
return true;
|
|
568
760
|
} catch (fallbackError) {
|
|
569
761
|
this.#logger.error(
|
|
@@ -575,6 +767,37 @@ class RscRenderer {
|
|
|
575
767
|
}
|
|
576
768
|
}
|
|
577
769
|
|
|
770
|
+
async #trySendSystemNotFoundRender({
|
|
771
|
+
requestId,
|
|
772
|
+
url,
|
|
773
|
+
clientManifest,
|
|
774
|
+
}: {
|
|
775
|
+
requestId: string;
|
|
776
|
+
url: URL;
|
|
777
|
+
clientManifest: ClientManifest;
|
|
778
|
+
}): Promise<boolean> {
|
|
779
|
+
try {
|
|
780
|
+
const result = await this.#renderFlightElement(this.#renderSystemNotFound(url), clientManifest, {
|
|
781
|
+
requestId,
|
|
782
|
+
status: 404,
|
|
783
|
+
});
|
|
784
|
+
if (result.cancelled) return true;
|
|
785
|
+
if (result.control) return false;
|
|
786
|
+
this.#stats.lastFlightBytes = result.bytes;
|
|
787
|
+
this.#stats.lastFlightChunks = result.chunksCount;
|
|
788
|
+
this.#stats.totalFlightBytes += result.bytes;
|
|
789
|
+
this.#stats.totalFlightChunks += result.chunksCount;
|
|
790
|
+
return true;
|
|
791
|
+
} catch (error) {
|
|
792
|
+
this.#logger.error(
|
|
793
|
+
`render[${requestId}] system not-found fallback failed: ${
|
|
794
|
+
error instanceof Error ? (error.stack ?? error.message) : String(error)
|
|
795
|
+
}`,
|
|
796
|
+
);
|
|
797
|
+
return false;
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
|
|
578
801
|
#sendRenderControl(requestId: string, control: RenderControl): void {
|
|
579
802
|
if (control.type === "redirect") {
|
|
580
803
|
this.#logger.verbose(`render[${requestId}] redirect ${control.location}`);
|
|
@@ -617,35 +840,29 @@ class RscRenderer {
|
|
|
617
840
|
}));
|
|
618
841
|
}
|
|
619
842
|
|
|
620
|
-
|
|
621
|
-
const
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
rscCache: config?.rscCache,
|
|
625
|
-
rscCacheTtl: config?.rscCacheTtl,
|
|
626
|
-
cacheable: config?.rscCache === "public" || ttl !== null,
|
|
843
|
+
#getResultCacheEntry(request: Request, url: URL): RouteCacheEntry | null {
|
|
844
|
+
const ttl = resolveAutoRouteCacheTtl({
|
|
845
|
+
enabled: process.env.AKAN_RSC_RESULT_CACHE,
|
|
846
|
+
ttl: process.env.AKAN_RSC_RESULT_CACHE_TTL,
|
|
627
847
|
});
|
|
628
|
-
if (
|
|
848
|
+
if (ttl === null) {
|
|
629
849
|
this.#resultCacheBypass += 1;
|
|
630
850
|
return null;
|
|
631
851
|
}
|
|
632
|
-
if (
|
|
852
|
+
if (
|
|
853
|
+
!isRouteCachePathAllowed(url.pathname, {
|
|
854
|
+
allow: process.env.AKAN_RSC_RESULT_CACHE_PATHS,
|
|
855
|
+
deny: process.env.AKAN_RSC_RESULT_CACHE_EXCLUDE_PATHS,
|
|
856
|
+
})
|
|
857
|
+
) {
|
|
633
858
|
this.#resultCacheBypass += 1;
|
|
634
859
|
return null;
|
|
635
860
|
}
|
|
636
|
-
if (!
|
|
861
|
+
if (!isPublicRouteCacheableRequest(request)) {
|
|
637
862
|
this.#resultCacheBypass += 1;
|
|
638
863
|
return null;
|
|
639
864
|
}
|
|
640
|
-
return
|
|
641
|
-
request.headers.get("x-forwarded-host") ?? request.headers.get("host") ?? url.host,
|
|
642
|
-
request.headers.get("x-base-path") ?? "",
|
|
643
|
-
url.pathname,
|
|
644
|
-
url.search,
|
|
645
|
-
request.headers.get("accept-language") ?? "",
|
|
646
|
-
cookies().get("theme")?.value ?? "",
|
|
647
|
-
ttl ?? 30,
|
|
648
|
-
].join("\n");
|
|
865
|
+
return createRouteCacheEntry({ request, url, theme: untrackedCookies().get("theme")?.value, ttl });
|
|
649
866
|
}
|
|
650
867
|
|
|
651
868
|
#getCachedResult(cacheKey: string): CachedRscResult | null {
|
|
@@ -654,27 +871,12 @@ class RscRenderer {
|
|
|
654
871
|
this.#resultCacheMisses += 1;
|
|
655
872
|
return null;
|
|
656
873
|
}
|
|
657
|
-
if (cached.expiresAt <= Date.now()) {
|
|
658
|
-
this.#resultCache.delete(cacheKey);
|
|
659
|
-
this.#resultCacheMisses += 1;
|
|
660
|
-
return null;
|
|
661
|
-
}
|
|
662
874
|
this.#resultCacheHits += 1;
|
|
663
875
|
return cached;
|
|
664
876
|
}
|
|
665
877
|
|
|
666
|
-
#setCachedResult(
|
|
667
|
-
cacheKey
|
|
668
|
-
result: { chunks: Uint8Array[]; bytes: number; chunksCount: number; theme?: string },
|
|
669
|
-
): void {
|
|
670
|
-
const ttl = Number.parseInt(cacheKey.split("\n").at(-1) ?? "30", 10);
|
|
671
|
-
const maxEntries = RscRenderer.#parsePositiveIntEnv("AKAN_RSC_RESULT_CACHE_MAX_ENTRIES") ?? 100;
|
|
672
|
-
while (this.#resultCache.size >= maxEntries) {
|
|
673
|
-
const firstKey = this.#resultCache.keys().next().value;
|
|
674
|
-
if (!firstKey) break;
|
|
675
|
-
this.#resultCache.delete(firstKey);
|
|
676
|
-
}
|
|
677
|
-
this.#resultCache.set(cacheKey, { ...result, expiresAt: Date.now() + ttl * 1000 });
|
|
878
|
+
#setCachedResult(cacheKey: string, result: CachedRscResult, ttl: number): void {
|
|
879
|
+
this.#resultCache.set(cacheKey, result, ttl);
|
|
678
880
|
}
|
|
679
881
|
|
|
680
882
|
#runWithRequest<T>(request: Request, fn: () => Promise<T>): Promise<T> {
|
|
@@ -711,8 +913,18 @@ class RscRenderer {
|
|
|
711
913
|
digest,
|
|
712
914
|
});
|
|
713
915
|
if (!body) return null;
|
|
714
|
-
const routeHead =
|
|
715
|
-
|
|
916
|
+
const routeHead =
|
|
917
|
+
"resolveHead" in route
|
|
918
|
+
? await RouteElementComposer.resolveHeadWithMetadata({
|
|
919
|
+
pathRoute: route,
|
|
920
|
+
params,
|
|
921
|
+
searchParams,
|
|
922
|
+
})
|
|
923
|
+
: { node: undefined, hasExplicitLanguageAlternates: false };
|
|
924
|
+
const renderLocaleAlternates = shouldRenderLocaleAlternates({
|
|
925
|
+
hasExplicitLanguageAlternates: routeHead.hasExplicitLanguageAlternates,
|
|
926
|
+
});
|
|
927
|
+
const theme = untrackedCookies().get("theme")?.value;
|
|
716
928
|
return (
|
|
717
929
|
<html
|
|
718
930
|
lang={params.lang ?? RscRenderer.#getLocale(pathname, this.#i18n)}
|
|
@@ -722,8 +934,8 @@ class RscRenderer {
|
|
|
722
934
|
<meta key="charset" charSet="utf-8" />
|
|
723
935
|
<meta key="viewport" name="viewport" content="width=device-width, initial-scale=1" />
|
|
724
936
|
<meta key="robots" name="robots" content="noindex" />
|
|
725
|
-
{routeHead ?? this.#renderDefaultHead()}
|
|
726
|
-
{this.#renderLocaleAlternates(url)}
|
|
937
|
+
{routeHead.node ?? this.#renderDefaultHead()}
|
|
938
|
+
{renderLocaleAlternates ? this.#renderLocaleAlternates(url) : null}
|
|
727
939
|
{this.#renderStylesheet(pathname)}
|
|
728
940
|
</head>
|
|
729
941
|
<body key="body">{body}</body>
|
|
@@ -735,17 +947,25 @@ class RscRenderer {
|
|
|
735
947
|
url: URL,
|
|
736
948
|
match: { pathRoute: PathRoute; params: Record<string, string> },
|
|
737
949
|
theme?: string,
|
|
950
|
+
searchParams = RouteTreeBuilder.parseSearchParams(url.search),
|
|
738
951
|
): Promise<ReactNode> {
|
|
739
|
-
const searchParams = RouteTreeBuilder.parseSearchParams(url.search);
|
|
740
952
|
this.#logger.verbose(
|
|
741
953
|
`composing route element pathname=${url.pathname} search=${url.search || "(none)"} params=${JSON.stringify(match.params)}`,
|
|
742
954
|
);
|
|
743
|
-
const routeHead = await RouteElementComposer.
|
|
955
|
+
const routeHead = await RouteElementComposer.resolveHeadWithMetadata({
|
|
956
|
+
pathRoute: match.pathRoute,
|
|
957
|
+
params: match.params,
|
|
958
|
+
searchParams,
|
|
959
|
+
});
|
|
960
|
+
const renderLocaleAlternates = shouldRenderLocaleAlternates({
|
|
961
|
+
isSpecialRoute: match.pathRoute.isSpecialRoute,
|
|
962
|
+
hasExplicitLanguageAlternates: routeHead.hasExplicitLanguageAlternates,
|
|
963
|
+
});
|
|
964
|
+
const body = RouteElementComposer.compose({
|
|
744
965
|
pathRoute: match.pathRoute,
|
|
745
966
|
params: match.params,
|
|
746
967
|
searchParams,
|
|
747
968
|
});
|
|
748
|
-
const body = RouteElementComposer.compose({ pathRoute: match.pathRoute, params: match.params, searchParams });
|
|
749
969
|
return (
|
|
750
970
|
<html
|
|
751
971
|
lang={match.params.lang ?? this.#i18n.defaultLocale}
|
|
@@ -754,8 +974,8 @@ class RscRenderer {
|
|
|
754
974
|
<head key="head">
|
|
755
975
|
<meta key="charset" charSet="utf-8" />
|
|
756
976
|
<meta key="viewport" name="viewport" content="width=device-width, initial-scale=1" />
|
|
757
|
-
{routeHead ?? this.#renderDefaultHead()}
|
|
758
|
-
{
|
|
977
|
+
{routeHead.node ?? this.#renderDefaultHead()}
|
|
978
|
+
{renderLocaleAlternates ? this.#renderLocaleAlternates(url) : null}
|
|
759
979
|
{this.#renderStylesheet(url.pathname)}
|
|
760
980
|
</head>
|
|
761
981
|
<body key="body">{body}</body>
|
|
@@ -794,7 +1014,7 @@ class RscRenderer {
|
|
|
794
1014
|
pathname: url.pathname,
|
|
795
1015
|
i18n: this.#i18n,
|
|
796
1016
|
basePaths: this.#basePaths,
|
|
797
|
-
headerBasePath:
|
|
1017
|
+
headerBasePath: untrackedRequest()?.headers.get("x-base-path"),
|
|
798
1018
|
}),
|
|
799
1019
|
stylesheetHref: this.#getStylesheetHref(url.pathname),
|
|
800
1020
|
});
|
|
@@ -832,7 +1052,7 @@ class RscRenderer {
|
|
|
832
1052
|
const basePath = getBasePathFromPathname(pathname, {
|
|
833
1053
|
basePaths: Object.keys(this.#cssAssets),
|
|
834
1054
|
i18n: this.#i18n,
|
|
835
|
-
headerBasePath:
|
|
1055
|
+
headerBasePath: untrackedRequest()?.headers.get("x-base-path"),
|
|
836
1056
|
});
|
|
837
1057
|
return this.#cssAssets[basePath ?? ""]?.cssUrl ?? null;
|
|
838
1058
|
}
|
|
@@ -842,29 +1062,6 @@ class RscRenderer {
|
|
|
842
1062
|
return segment && i18n.locales.includes(segment) ? segment : i18n.defaultLocale;
|
|
843
1063
|
}
|
|
844
1064
|
|
|
845
|
-
static #parsePositiveIntEnv(name: string): number | null {
|
|
846
|
-
const parsed = Number.parseInt(process.env[name] ?? "", 10);
|
|
847
|
-
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
|
|
848
|
-
}
|
|
849
|
-
|
|
850
|
-
static #normalizeCacheTtl(value: unknown): number | null {
|
|
851
|
-
if (value === undefined || value === null) return null;
|
|
852
|
-
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) return null;
|
|
853
|
-
return Math.floor(value);
|
|
854
|
-
}
|
|
855
|
-
|
|
856
|
-
static #isPublicCacheableRequest(request: Request): boolean {
|
|
857
|
-
if (request.method !== "GET") return false;
|
|
858
|
-
if (request.headers.has("authorization")) return false;
|
|
859
|
-
const cookie = request.headers.get("cookie");
|
|
860
|
-
if (!cookie) return true;
|
|
861
|
-
return cookie
|
|
862
|
-
.split(";")
|
|
863
|
-
.map((part) => part.trim().split("=")[0])
|
|
864
|
-
.filter(Boolean)
|
|
865
|
-
.every((name) => name === "theme" || name.startsWith("akan_public_"));
|
|
866
|
-
}
|
|
867
|
-
|
|
868
1065
|
static #errorForFallback(error: unknown): unknown {
|
|
869
1066
|
if (process.env.NODE_ENV !== "production") return error;
|
|
870
1067
|
return undefined;
|
|
@@ -872,7 +1069,7 @@ class RscRenderer {
|
|
|
872
1069
|
|
|
873
1070
|
static #getPublicRequestUrl(url: URL): URL {
|
|
874
1071
|
const publicUrl = new URL(url);
|
|
875
|
-
const req =
|
|
1072
|
+
const req = untrackedRequest();
|
|
876
1073
|
const headers = req?.headers;
|
|
877
1074
|
const host = headers?.get("x-forwarded-host") ?? headers?.get("host");
|
|
878
1075
|
const proto = headers?.get("x-forwarded-proto");
|
|
@@ -895,4 +1092,4 @@ class RscRenderer {
|
|
|
895
1092
|
}
|
|
896
1093
|
}
|
|
897
1094
|
|
|
898
|
-
new RscRenderer().start();
|
|
1095
|
+
if (import.meta.main) new RscRenderer().start();
|