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.
Files changed (38) hide show
  1. package/client/csrTypes.ts +37 -6
  2. package/client/makePageProto.tsx +8 -8
  3. package/client/router.ts +5 -2
  4. package/fetch/requestStorage.ts +41 -11
  5. package/package.json +1 -1
  6. package/server/akanApp.ts +55 -0
  7. package/server/cachePolicy.ts +192 -0
  8. package/server/metadata.tsx +114 -0
  9. package/server/routeElementComposer.tsx +21 -1
  10. package/server/routeTreeBuilder.ts +44 -5
  11. package/server/rscClient.tsx +127 -50
  12. package/server/rscHttp.ts +120 -0
  13. package/server/rscNavigationState.ts +95 -0
  14. package/server/rscWorker.tsx +318 -121
  15. package/server/rscWorkerHost.ts +281 -66
  16. package/server/rscWorkerReplay.ts +40 -0
  17. package/server/ssrFromRscRenderer.tsx +462 -77
  18. package/server/ssrTypes.ts +11 -1
  19. package/server/webRouter.ts +173 -88
  20. package/service/ipcTypes.ts +1 -0
  21. package/types/client/csrTypes.d.ts +37 -6
  22. package/types/dictionary/base.dictionary.d.ts +1 -1
  23. package/types/dictionary/dictionary.d.ts +8 -8
  24. package/types/fetch/requestStorage.d.ts +16 -6
  25. package/types/server/cachePolicy.d.ts +55 -0
  26. package/types/server/metadata.d.ts +13 -0
  27. package/types/server/routeElementComposer.d.ts +6 -1
  28. package/types/server/rscHttp.d.ts +16 -0
  29. package/types/server/rscNavigationState.d.ts +35 -0
  30. package/types/server/rscWorkerHost.d.ts +38 -0
  31. package/types/server/rscWorkerReplay.d.ts +29 -0
  32. package/types/server/ssrFromRscRenderer.d.ts +20 -1
  33. package/types/server/ssrTypes.d.ts +10 -1
  34. package/types/server/webRouter.d.ts +27 -1
  35. package/types/service/ipcTypes.d.ts +1 -0
  36. package/types/webkit/useCsrValues.d.ts +1 -1
  37. package/ui/Link/SsrLink.tsx +0 -2
  38. package/webkit/bootCsr.tsx +16 -2
@@ -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 { cookies, getRequest, getRequestTheme, requestStorage, updateRequestPolicy } from "akanjs/fetch";
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
- type InMsg = InitMsg | RenderMsg | ReloadMsg | UpdateCssAssetsMsg;
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
- readonly #resultCache = new Map<string, CachedRscResult>();
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 cacheKey = match ? await this.#getResultCacheKey(request, urlObj, match.pathRoute) : null;
293
- const cached = cacheKey ? this.#getCachedResult(cacheKey) : null;
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
- this.#send({ type: "meta", requestId, theme: cached.theme });
304
- for (const chunk of cached.chunks) this.#send({ type: "chunk", requestId, data: chunk });
305
- this.#send({ type: "end", requestId });
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 = cookies().get("theme")?.value;
309
- const element = match ? await this.#renderMatched(urlObj, match, theme) : await this.#renderNotFound(urlObj);
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: RouteTreeBuilder.parseSearchParams(urlObj.search),
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.chunks.length;
456
+ this.#stats.lastFlightChunks = result.chunksCount;
349
457
  this.#stats.totalFlightBytes += result.bytes;
350
- this.#stats.totalFlightChunks += result.chunks.length;
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.chunks.length} bytes=${result.bytes} in ${Date.now() - startedAt}ms`,
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
- ): Promise<{ chunks: Uint8Array[]; bytes: number; control: RenderControl | null }> {
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 error.digest;
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
- while (true) {
512
- const { value, done } = await reader.read();
513
- if (done) break;
514
- if (controlRef.current) {
515
- await reader.cancel();
516
- break;
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
- const chunk = value instanceof Uint8Array ? value : new Uint8Array(value as ArrayBufferLike);
519
- bytes += chunk.byteLength;
520
- chunks.push(chunk);
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 { chunks, bytes, control: controlRef.current };
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.chunks.length;
756
+ this.#stats.lastFlightChunks = result.chunksCount;
565
757
  this.#stats.totalFlightBytes += result.bytes;
566
- this.#stats.totalFlightChunks += result.chunks.length;
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
- async #getResultCacheKey(request: Request, url: URL, pathRoute: PathRoute): Promise<string | null> {
621
- const config = await pathRoute.renderPage.getPageConfig?.();
622
- const ttl = RscRenderer.#normalizeCacheTtl(config?.rscCacheTtl);
623
- updateRequestPolicy({
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 (config?.rscCache !== "public" && ttl === null) {
848
+ if (ttl === null) {
629
849
  this.#resultCacheBypass += 1;
630
850
  return null;
631
851
  }
632
- if (ttl === 0) {
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 (!RscRenderer.#isPublicCacheableRequest(request)) {
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: string,
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 = "resolveHead" in route ? await route.resolveHead?.({ params, searchParams }) : undefined;
715
- const theme = cookies().get("theme")?.value;
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.resolveHead({
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
- {match.pathRoute.isSpecialRoute ? null : this.#renderLocaleAlternates(url)}
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: getRequest()?.headers.get("x-base-path"),
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: getRequest()?.headers.get("x-base-path"),
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 = getRequest();
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();