akanjs 2.1.1 → 2.1.2-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.
@@ -1,4 +1,4 @@
1
- import type { AkanNotFoundError, AkanRedirectError, PathRoute } from "akanjs/client";
1
+ import type { AkanNotFoundError, AkanRedirectError, LayoutFallbackRoute, PathRoute } from "akanjs/client";
2
2
  import { type AkanI18nConfig, DEFAULT_AKAN_I18N, getBasePathFromPathname, Logger } from "akanjs/common";
3
3
  import { cookies, getRequest, getRequestTheme, requestStorage } from "akanjs/fetch";
4
4
  import type { ReactNode } from "react";
@@ -7,6 +7,7 @@ import type { ClientManifest } from "./artifact";
7
7
  import { ProcessMetricsCollector } from "./processMetricsCollector";
8
8
  import { RouteElementComposer } from "./routeElementComposer";
9
9
  import { type PagesContext, RouteTreeBuilder } from "./routeTreeBuilder";
10
+ import { createSystemPageDocument, getSystemPageHomeHref } from "./systemPages";
10
11
 
11
12
  interface InitMsg {
12
13
  type: "init";
@@ -14,6 +15,7 @@ interface InitMsg {
14
15
  pagesBundlePath: string;
15
16
  pagesBundleBuildId: number;
16
17
  cssAssets?: Record<string, { cssUrl: string; cssRelPath: string }>;
18
+ basePaths?: string[];
17
19
  i18n?: AkanI18nConfig;
18
20
  }
19
21
  interface RenderMsg {
@@ -37,7 +39,10 @@ interface UpdateCssAssetsMsg {
37
39
  cssAssets: Record<string, { cssUrl: string; cssRelPath: string }>;
38
40
  }
39
41
  type InMsg = InitMsg | RenderMsg | ReloadMsg | UpdateCssAssetsMsg;
40
- type RenderControl = { type: "redirect"; location: string; method: "replace" | "push" } | { type: "not-found" };
42
+ type RenderControl =
43
+ | { type: "redirect"; location: string; method: "replace" | "push" }
44
+ | { type: "not-found" }
45
+ | { type: "error"; error: unknown };
41
46
 
42
47
  interface RscRendererStats {
43
48
  renderCount: number;
@@ -94,7 +99,9 @@ class RscRenderer {
94
99
  readonly #logger = new Logger("scWorker");
95
100
  #clientManifest: ClientManifest = {};
96
101
  #pathRoutes: PathRoute[] = [];
102
+ #fallbackRoutes: LayoutFallbackRoute[] = [];
97
103
  #cssAssets: Record<string, { cssUrl: string; cssRelPath: string }> = {};
104
+ #basePaths: string[] = [];
98
105
  #i18n: AkanI18nConfig = DEFAULT_AKAN_I18N;
99
106
  #pagesBundlePath = "";
100
107
  #pagesBundleBuildId = 0;
@@ -159,6 +166,7 @@ class RscRenderer {
159
166
  try {
160
167
  this.#clientManifest = msg.clientManifest;
161
168
  this.#cssAssets = msg.cssAssets ?? {};
169
+ this.#basePaths = msg.basePaths ?? Object.keys(this.#cssAssets);
162
170
  this.#i18n = msg.i18n ?? DEFAULT_AKAN_I18N;
163
171
  this.#pagesBundlePath = msg.pagesBundlePath;
164
172
  this.#pagesBundleBuildId = msg.pagesBundleBuildId;
@@ -168,7 +176,9 @@ class RscRenderer {
168
176
  this.#logger.verbose(
169
177
  `init state pagesBundlePath=${msg.pagesBundlePath} buildId=${msg.pagesBundleBuildId} cssAssets=${Object.keys(this.#cssAssets).length} clientEntries=${Object.keys(msg.clientManifest).length}`,
170
178
  );
171
- this.#pathRoutes = await this.#importPages(msg.pagesBundlePath, msg.pagesBundleBuildId);
179
+ const routes = await this.#importPages(msg.pagesBundlePath, msg.pagesBundleBuildId);
180
+ this.#pathRoutes = routes.pathRoutes;
181
+ this.#fallbackRoutes = routes.fallbackRoutes;
172
182
  this.#logger.verbose(`init complete in ${Date.now() - startedAt}ms`);
173
183
  this.#send({ type: "ready" });
174
184
  } catch (error) {
@@ -193,7 +203,7 @@ class RscRenderer {
193
203
  this.#logger.verbose(
194
204
  `reload state buildId=${msg.buildId} bundlePath=${nextPagesBundlePath} cssAssets=${Object.keys(nextCssAssets).length} clientEntries=${Object.keys(msg.clientManifest).length}`,
195
205
  );
196
- const pathRoutes = await this.#importPages(nextPagesBundlePath, msg.buildId);
206
+ const routes = await this.#importPages(nextPagesBundlePath, msg.buildId);
197
207
  if (seq !== this.#reloadSeq) {
198
208
  this.#logger.verbose(`reload stale buildId=${msg.buildId} seq=${seq} latest=${this.#reloadSeq}`);
199
209
  return;
@@ -203,7 +213,8 @@ class RscRenderer {
203
213
  this.#pagesBundlePath = nextPagesBundlePath;
204
214
  this.#pagesBundleBuildId = msg.buildId;
205
215
  this.#stats.pagesBundleBuildId = msg.buildId;
206
- this.#pathRoutes = pathRoutes;
216
+ this.#pathRoutes = routes.pathRoutes;
217
+ this.#fallbackRoutes = routes.fallbackRoutes;
207
218
  this.#routeStats.clear();
208
219
  this.#resultCache.clear();
209
220
  this.#logger.verbose(`reload complete buildId=${msg.buildId} in ${Date.now() - startedAt}ms`);
@@ -221,7 +232,10 @@ class RscRenderer {
221
232
  }
222
233
  }
223
234
 
224
- async #importPages(bundlePath: string, buildId: number): Promise<PathRoute[]> {
235
+ async #importPages(
236
+ bundlePath: string,
237
+ buildId: number,
238
+ ): Promise<{ pathRoutes: PathRoute[]; fallbackRoutes: LayoutFallbackRoute[] }> {
225
239
  const specifier = `${bundlePath}?v=${buildId}`;
226
240
  this.#logger.verbose(`importing pages bundle ${specifier}`);
227
241
  const importStart = Date.now();
@@ -231,12 +245,13 @@ class RscRenderer {
231
245
  if (!pages) throw new Error(`pages export not found in ${specifier}`);
232
246
 
233
247
  const routeBuildStart = Date.now();
234
- const pathRoutes = new RouteTreeBuilder(pages).build();
248
+ const routeTree = new RouteTreeBuilder(pages);
249
+ const pathRoutes = routeTree.build();
235
250
  const routeBuildMs = Date.now() - routeBuildStart;
236
251
  this.#logger.verbose(
237
252
  `pages imported in ${Date.now() - importStart}ms import=${importedAt - importStart}ms routeBuild=${routeBuildMs}ms routes=${pathRoutes.length} specifier=${specifier}`,
238
253
  );
239
- return pathRoutes;
254
+ return { pathRoutes, fallbackRoutes: routeTree.getFallbackRoutes() };
240
255
  }
241
256
 
242
257
  async #handleRender(msg: RenderMsg): Promise<void> {
@@ -244,12 +259,18 @@ class RscRenderer {
244
259
  const startedAt = Date.now();
245
260
  this.#stats.renderCount += 1;
246
261
  this.#stats.inFlightRenderCount += 1;
262
+ const activeRoute: {
263
+ url: URL | null;
264
+ match: { pathRoute: PathRoute; params: Record<string, string> } | null;
265
+ } = { url: null, match: null };
247
266
  try {
248
267
  const request = new Request(url, { method, headers });
249
268
  await this.#runWithRequest(request, async () => {
250
269
  const urlObj = new URL(url);
270
+ activeRoute.url = urlObj;
251
271
  this.#stats.lastRenderedPath = urlObj.pathname;
252
272
  const match = RouteTreeBuilder.match(urlObj.pathname, this.#pathRoutes);
273
+ activeRoute.match = match;
253
274
  const routeId = match?.pathRoute.path ?? "__not_found__";
254
275
  this.#stats.lastRenderRouteId = routeId;
255
276
  this.#stats.lastRenderKind = match ? "route" : "not-found";
@@ -276,62 +297,67 @@ class RscRenderer {
276
297
  return;
277
298
  }
278
299
  const theme = cookies().get("theme")?.value;
279
- const element = match ? await this.#renderMatched(urlObj, match, theme) : this.#renderNotFound(urlObj);
300
+ const element = match ? await this.#renderMatched(urlObj, match, theme) : await this.#renderNotFound(urlObj);
280
301
  this.#logger.verbose(`render[${requestId}] starting Flight stream`);
281
- const controlRef: { current: RenderControl | null } = { current: null };
282
- const stream = await renderToReadableStream(element, msg.clientManifest ?? this.#clientManifest, {
283
- onError: (error) => {
284
- if (isAkanRedirectError(error)) {
285
- controlRef.current = { type: "redirect", location: error.location, method: error.method };
286
- return error.digest;
287
- }
288
- if (isAkanNotFoundError(error)) {
289
- controlRef.current = { type: "not-found" };
290
- return error.digest;
291
- }
292
- return error instanceof Error ? error.message : String(error);
293
- },
294
- });
295
- const reader = stream.getReader();
296
- let chunks = 0;
297
- let bytes = 0;
298
- const buffered: Uint8Array[] = [];
299
- while (true) {
300
- const { value, done } = await reader.read();
301
- if (done) break;
302
- if (controlRef.current) {
303
- await reader.cancel();
304
- break;
305
- }
306
- const chunk = value instanceof Uint8Array ? value : new Uint8Array(value as ArrayBufferLike);
307
- chunks += 1;
308
- bytes += chunk.byteLength;
309
- buffered.push(chunk);
310
- }
311
- const control = controlRef.current;
302
+ const result = await this.#renderFlightElement(element, msg.clientManifest ?? this.#clientManifest);
303
+ const control = result.control;
312
304
  if (control) {
313
305
  this.#stats.lastRenderKind = control.type;
306
+ if (!match && control.type === "error") {
307
+ const systemResult = await this.#renderFlightElement(
308
+ this.#renderSystemNotFound(urlObj),
309
+ msg.clientManifest ?? this.#clientManifest,
310
+ );
311
+ if (!systemResult.control) {
312
+ this.#send({ type: "meta", requestId, theme: getRequestTheme(), status: 404 });
313
+ for (const chunk of systemResult.chunks) this.#send({ type: "chunk", requestId, data: chunk });
314
+ this.#send({ type: "end", requestId });
315
+ return;
316
+ }
317
+ }
318
+ if (
319
+ match &&
320
+ control.type !== "redirect" &&
321
+ (await this.#trySendFallbackRender({
322
+ requestId,
323
+ kind: control.type,
324
+ route: match.pathRoute,
325
+ params: match.params,
326
+ searchParams: RouteTreeBuilder.parseSearchParams(urlObj.search),
327
+ pathname: urlObj.pathname,
328
+ url: urlObj,
329
+ error: control.type === "error" ? control.error : undefined,
330
+ clientManifest: msg.clientManifest ?? this.#clientManifest,
331
+ }))
332
+ ) {
333
+ return;
334
+ }
314
335
  this.#sendRenderControl(requestId, control);
315
336
  return;
316
337
  }
317
- this.#stats.lastFlightBytes = bytes;
318
- this.#stats.lastFlightChunks = chunks;
319
- this.#stats.totalFlightBytes += bytes;
320
- this.#stats.totalFlightChunks += chunks;
338
+ this.#stats.lastFlightBytes = result.bytes;
339
+ this.#stats.lastFlightChunks = result.chunks.length;
340
+ this.#stats.totalFlightBytes += result.bytes;
341
+ this.#stats.totalFlightChunks += result.chunks.length;
321
342
  this.#stats.lastRenderDurationMs = Date.now() - startedAt;
322
343
  const afterLoadedKeys = RouteTreeBuilder.getCacheStats().loadedModuleKeys;
323
344
  this.#stats.lastRenderLoadedModules = afterLoadedKeys.filter((key) => !beforeLoadedKeys.includes(key));
324
345
  this.#stats.lastRenderLoadedModuleDelta = this.#stats.lastRenderLoadedModules.length;
325
- this.#recordRouteStats(routeId, bytes, this.#stats.lastRenderDurationMs);
346
+ this.#recordRouteStats(routeId, result.bytes, this.#stats.lastRenderDurationMs);
326
347
  const responseTheme = getRequestTheme();
327
348
  if (cacheKey)
328
- this.#setCachedResult(cacheKey, { chunks: buffered, bytes, chunksCount: chunks, theme: responseTheme });
329
- this.#send({ type: "meta", requestId, theme: responseTheme });
330
- for (const chunk of buffered) {
349
+ this.#setCachedResult(cacheKey, {
350
+ chunks: result.chunks,
351
+ bytes: result.bytes,
352
+ chunksCount: result.chunks.length,
353
+ theme: responseTheme,
354
+ });
355
+ this.#send({ type: "meta", requestId, theme: responseTheme, status: match ? undefined : 404 });
356
+ for (const chunk of result.chunks) {
331
357
  this.#send({ type: "chunk", requestId, data: chunk });
332
358
  }
333
359
  this.#logger.verbose(
334
- `render[${requestId}] done chunks=${chunks} bytes=${bytes} in ${Date.now() - startedAt}ms`,
360
+ `render[${requestId}] done chunks=${result.chunks.length} bytes=${result.bytes} in ${Date.now() - startedAt}ms`,
335
361
  );
336
362
  this.#send({ type: "end", requestId });
337
363
  });
@@ -345,12 +371,49 @@ class RscRenderer {
345
371
  if (isAkanNotFoundError(error)) {
346
372
  this.#stats.lastRenderKind = "not-found";
347
373
  this.#logger.verbose(`render[${requestId}] not-found`);
374
+ const fallbackUrl = activeRoute.url;
375
+ const fallbackMatch = activeRoute.match;
376
+ if (
377
+ fallbackUrl &&
378
+ fallbackMatch &&
379
+ (await this.#trySendFallbackRender({
380
+ requestId,
381
+ kind: "not-found",
382
+ route: fallbackMatch.pathRoute,
383
+ params: fallbackMatch.params,
384
+ searchParams: RouteTreeBuilder.parseSearchParams(fallbackUrl.search),
385
+ pathname: fallbackUrl.pathname,
386
+ url: fallbackUrl,
387
+ clientManifest: msg.clientManifest ?? this.#clientManifest,
388
+ }))
389
+ ) {
390
+ return;
391
+ }
348
392
  this.#send({ type: "not-found", requestId });
349
393
  return;
350
394
  }
351
395
  this.#logger.error(
352
396
  `render[${requestId}] failed url=${url}: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}`,
353
397
  );
398
+ const fallbackUrl = activeRoute.url;
399
+ const fallbackMatch = activeRoute.match;
400
+ if (
401
+ fallbackUrl &&
402
+ fallbackMatch &&
403
+ (await this.#trySendFallbackRender({
404
+ requestId,
405
+ kind: "error",
406
+ route: fallbackMatch.pathRoute,
407
+ params: fallbackMatch.params,
408
+ searchParams: RouteTreeBuilder.parseSearchParams(fallbackUrl.search),
409
+ pathname: fallbackUrl.pathname,
410
+ url: fallbackUrl,
411
+ error,
412
+ clientManifest: msg.clientManifest ?? this.#clientManifest,
413
+ }))
414
+ ) {
415
+ return;
416
+ }
354
417
  this.#send({
355
418
  type: "error",
356
419
  requestId,
@@ -403,12 +466,107 @@ class RscRenderer {
403
466
  this.#send({ type: "metrics", metrics });
404
467
  }
405
468
 
469
+ async #renderFlightElement(
470
+ element: ReactNode,
471
+ clientManifest: ClientManifest,
472
+ ): Promise<{ chunks: Uint8Array[]; bytes: number; control: RenderControl | null }> {
473
+ const controlRef: { current: RenderControl | null } = { current: null };
474
+ const stream = await renderToReadableStream(element, clientManifest, {
475
+ onError: (error) => {
476
+ if (isAkanRedirectError(error)) {
477
+ controlRef.current = { type: "redirect", location: error.location, method: error.method };
478
+ return error.digest;
479
+ }
480
+ if (isAkanNotFoundError(error)) {
481
+ controlRef.current = { type: "not-found" };
482
+ return error.digest;
483
+ }
484
+ controlRef.current = { type: "error", error };
485
+ return error instanceof Error ? error.message : String(error);
486
+ },
487
+ });
488
+ const reader = stream.getReader();
489
+ let bytes = 0;
490
+ const chunks: Uint8Array[] = [];
491
+ while (true) {
492
+ const { value, done } = await reader.read();
493
+ if (done) break;
494
+ if (controlRef.current) {
495
+ await reader.cancel();
496
+ break;
497
+ }
498
+ const chunk = value instanceof Uint8Array ? value : new Uint8Array(value as ArrayBufferLike);
499
+ bytes += chunk.byteLength;
500
+ chunks.push(chunk);
501
+ }
502
+ return { chunks, bytes, control: controlRef.current };
503
+ }
504
+
505
+ async #trySendFallbackRender({
506
+ requestId,
507
+ kind,
508
+ route,
509
+ params,
510
+ searchParams,
511
+ pathname,
512
+ url,
513
+ error,
514
+ clientManifest,
515
+ }: {
516
+ requestId: string;
517
+ kind: "not-found" | "error";
518
+ route: PathRoute | LayoutFallbackRoute;
519
+ params: Record<string, string>;
520
+ searchParams: Record<string, string | string[]>;
521
+ pathname: string;
522
+ url: URL;
523
+ error?: unknown;
524
+ clientManifest: ClientManifest;
525
+ }): Promise<boolean> {
526
+ try {
527
+ const element = await this.#renderFallbackDocument({
528
+ kind,
529
+ route,
530
+ params,
531
+ searchParams,
532
+ pathname,
533
+ url,
534
+ error: kind === "error" ? RscRenderer.#errorForFallback(error) : undefined,
535
+ digest: kind === "error" ? "AKAN_RENDER_ERROR" : undefined,
536
+ });
537
+ if (!element) return false;
538
+ const result = await this.#renderFlightElement(element, clientManifest);
539
+ if (result.control) return false;
540
+ this.#send({ type: "meta", requestId, theme: getRequestTheme(), status: kind === "not-found" ? 404 : 500 });
541
+ for (const chunk of result.chunks) this.#send({ type: "chunk", requestId, data: chunk });
542
+ this.#send({ type: "end", requestId });
543
+ this.#stats.lastFlightBytes = result.bytes;
544
+ this.#stats.lastFlightChunks = result.chunks.length;
545
+ this.#stats.totalFlightBytes += result.bytes;
546
+ this.#stats.totalFlightChunks += result.chunks.length;
547
+ return true;
548
+ } catch (fallbackError) {
549
+ this.#logger.error(
550
+ `render[${requestId}] custom ${kind} fallback failed: ${
551
+ fallbackError instanceof Error ? (fallbackError.stack ?? fallbackError.message) : String(fallbackError)
552
+ }`,
553
+ );
554
+ return false;
555
+ }
556
+ }
557
+
406
558
  #sendRenderControl(requestId: string, control: RenderControl): void {
407
559
  if (control.type === "redirect") {
408
560
  this.#logger.verbose(`render[${requestId}] redirect ${control.location}`);
409
561
  this.#send({ type: "redirect", requestId, location: control.location, method: control.method });
410
562
  return;
411
563
  }
564
+ if (control.type === "error") {
565
+ const message = control.error instanceof Error ? control.error.message : String(control.error);
566
+ this.#logger.verbose(`render[${requestId}] error`);
567
+ this.#send({ type: "error", requestId, message });
568
+ return;
569
+ }
412
570
  this.#logger.verbose(`render[${requestId}] not-found`);
413
571
  this.#send({ type: "not-found", requestId });
414
572
  }
@@ -493,6 +651,55 @@ class RscRenderer {
493
651
  return fn();
494
652
  }
495
653
 
654
+ async #renderFallbackDocument({
655
+ kind,
656
+ route,
657
+ params,
658
+ searchParams,
659
+ pathname,
660
+ url,
661
+ error,
662
+ digest,
663
+ }: {
664
+ kind: "not-found" | "error";
665
+ route: PathRoute | LayoutFallbackRoute;
666
+ params: Record<string, string>;
667
+ searchParams: Record<string, string | string[]>;
668
+ pathname: string;
669
+ url: URL;
670
+ error?: unknown;
671
+ digest?: string;
672
+ }): Promise<ReactNode | null> {
673
+ const body = await RouteElementComposer.composeFallback({
674
+ kind,
675
+ route,
676
+ params,
677
+ searchParams,
678
+ pathname,
679
+ error,
680
+ digest,
681
+ });
682
+ if (!body) return null;
683
+ const routeHead = "resolveHead" in route ? await route.resolveHead?.({ params, searchParams }) : undefined;
684
+ const theme = cookies().get("theme")?.value;
685
+ return (
686
+ <html
687
+ lang={params.lang ?? RscRenderer.#getLocale(pathname, this.#i18n)}
688
+ {...(theme ? { "data-theme": theme } : { suppressHydrationWarning: true })}
689
+ >
690
+ <head key="head">
691
+ <meta key="charset" charSet="utf-8" />
692
+ <meta key="viewport" name="viewport" content="width=device-width, initial-scale=1" />
693
+ <meta key="robots" name="robots" content="noindex" />
694
+ {routeHead ?? this.#renderDefaultHead()}
695
+ {this.#renderLocaleAlternates(url)}
696
+ {this.#renderStylesheet(pathname)}
697
+ </head>
698
+ <body key="body">{body}</body>
699
+ </html>
700
+ );
701
+ }
702
+
496
703
  async #renderMatched(
497
704
  url: URL,
498
705
  match: { pathRoute: PathRoute; params: Record<string, string> },
@@ -525,20 +732,41 @@ class RscRenderer {
525
732
  );
526
733
  }
527
734
 
528
- #renderNotFound(url: URL): ReactNode {
529
- return (
530
- <html lang={this.#i18n.defaultLocale}>
531
- <head key="head">
532
- <meta key="charset" charSet="utf-8" />
533
- <title key="title">Not Found</title>
534
- {this.#renderStylesheet(url.pathname)}
535
- </head>
536
- <body key="body">
537
- <h1 key="title">404</h1>
538
- <p key="message">No route matched: {url.pathname}</p>
539
- </body>
540
- </html>
541
- );
735
+ async #renderNotFound(url: URL): Promise<ReactNode> {
736
+ const matchedFallback = RouteTreeBuilder.matchFallback(url.pathname, this.#fallbackRoutes);
737
+ if (matchedFallback) {
738
+ try {
739
+ const fallback = await this.#renderFallbackDocument({
740
+ kind: "not-found",
741
+ route: matchedFallback.fallbackRoute,
742
+ params: matchedFallback.params,
743
+ searchParams: RouteTreeBuilder.parseSearchParams(url.search),
744
+ pathname: url.pathname,
745
+ url,
746
+ });
747
+ if (fallback) return fallback;
748
+ } catch (error) {
749
+ this.#logger.error(
750
+ `custom unmatched not-found fallback failed: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}`,
751
+ );
752
+ }
753
+ }
754
+ return this.#renderSystemNotFound(url);
755
+ }
756
+
757
+ #renderSystemNotFound(url: URL): ReactNode {
758
+ return createSystemPageDocument({
759
+ kind: "not-found",
760
+ pathname: url.pathname,
761
+ lang: RscRenderer.#getLocale(url.pathname, this.#i18n),
762
+ homeHref: getSystemPageHomeHref({
763
+ pathname: url.pathname,
764
+ i18n: this.#i18n,
765
+ basePaths: this.#basePaths,
766
+ headerBasePath: getRequest()?.headers.get("x-base-path"),
767
+ }),
768
+ stylesheetHref: this.#getStylesheetHref(url.pathname),
769
+ });
542
770
  }
543
771
 
544
772
  #renderDefaultHead(): ReactNode {
@@ -564,14 +792,23 @@ class RscRenderer {
564
792
  }
565
793
 
566
794
  #renderStylesheet(pathname: string): ReactNode {
795
+ const cssUrl = this.#getStylesheetHref(pathname);
796
+ if (!cssUrl) return null;
797
+ return <link key="stylesheet" rel="stylesheet" href={cssUrl} precedence="default" data-akan-css="active" />;
798
+ }
799
+
800
+ #getStylesheetHref(pathname: string): string | null {
567
801
  const basePath = getBasePathFromPathname(pathname, {
568
802
  basePaths: Object.keys(this.#cssAssets),
569
803
  i18n: this.#i18n,
570
804
  headerBasePath: getRequest()?.headers.get("x-base-path"),
571
805
  });
572
- const cssUrl = this.#cssAssets[basePath ?? ""]?.cssUrl;
573
- if (!cssUrl) return null;
574
- return <link key="stylesheet" rel="stylesheet" href={cssUrl} precedence="default" data-akan-css="active" />;
806
+ return this.#cssAssets[basePath ?? ""]?.cssUrl ?? null;
807
+ }
808
+
809
+ static #getLocale(pathname: string, i18n: AkanI18nConfig): string {
810
+ const [segment] = pathname.split("/").filter(Boolean);
811
+ return segment && i18n.locales.includes(segment) ? segment : i18n.defaultLocale;
575
812
  }
576
813
 
577
814
  static #parsePositiveIntEnv(name: string): number | null {
@@ -597,6 +834,11 @@ class RscRenderer {
597
834
  .every((name) => name === "theme" || name.startsWith("akan_public_"));
598
835
  }
599
836
 
837
+ static #errorForFallback(error: unknown): unknown {
838
+ if (process.env.NODE_ENV !== "production") return error;
839
+ return undefined;
840
+ }
841
+
600
842
  static #getPublicRequestUrl(url: URL): URL {
601
843
  const publicUrl = new URL(url);
602
844
  const req = getRequest();
@@ -11,7 +11,7 @@ interface RscPending {
11
11
  onChunk: (data: Uint8Array) => void;
12
12
  onEnd: () => void;
13
13
  onError: (message: string) => void;
14
- onMeta?: (meta: { theme?: AkanTheme }) => void;
14
+ onMeta?: (meta: { theme?: AkanTheme; status?: number }) => void;
15
15
  onRedirect?: (location: string, method: RscRedirectMethod) => void;
16
16
  onNotFound?: () => void;
17
17
  }
@@ -19,7 +19,7 @@ interface RscPending {
19
19
  export type RscRedirectMethod = "replace" | "push";
20
20
 
21
21
  export type RscRenderResult =
22
- | { type: "stream"; stream: ReadableStream<Uint8Array>; theme?: AkanTheme }
22
+ | { type: "stream"; stream: ReadableStream<Uint8Array>; theme?: AkanTheme; status?: number }
23
23
  | { type: "redirect"; location: string; method: RscRedirectMethod }
24
24
  | { type: "not-found" };
25
25
 
@@ -27,7 +27,7 @@ type RscInMsg =
27
27
  | { type: "hello" }
28
28
  | { type: "ready" }
29
29
  | { type: "reloaded"; buildId: number }
30
- | { type: "meta"; requestId: string; theme?: AkanTheme }
30
+ | { type: "meta"; requestId: string; theme?: AkanTheme; status?: number }
31
31
  | { type: "chunk"; requestId: string; data: Uint8Array }
32
32
  | { type: "end"; requestId: string }
33
33
  | { type: "redirect"; requestId: string; location: string; method?: RscRedirectMethod }
@@ -89,6 +89,7 @@ export class RscWorker {
89
89
  #pagesBundlePath: string;
90
90
  #pagesBundleBuildId: number;
91
91
  #cssAssets: Record<string, CssAsset>;
92
+ #basePaths: string[];
92
93
  #i18n: AkanI18nConfig;
93
94
  #resolveReady!: () => void;
94
95
  #rejectReady!: (err: Error) => void;
@@ -116,6 +117,7 @@ export class RscWorker {
116
117
  this.#pagesBundlePath = artifact.pagesBundlePath;
117
118
  this.#pagesBundleBuildId = artifact.pagesBundleBuildId;
118
119
  this.#cssAssets = artifact.cssAssets ?? {};
120
+ this.#basePaths = artifact.basePaths ?? [];
119
121
  this.#i18n = artifact.i18n ?? DEFAULT_AKAN_I18N;
120
122
  this.#restartOpts = { baseDelayMs: 200, maxDelayMs: 30_000, maxAttempts: undefined };
121
123
  this.ready = new Promise<void>((resolve, reject) => {
@@ -178,17 +180,19 @@ export class RscWorker {
178
180
  let settled = false;
179
181
  let stream!: ReadableStream<Uint8Array>;
180
182
  let theme: AkanTheme | undefined;
183
+ let status: number | undefined;
181
184
  const result = new Promise<RscRenderResult>((resolve, reject) => {
182
185
  stream = new ReadableStream<Uint8Array>({
183
186
  start: (controller) => {
184
187
  const settleStream = () => {
185
188
  if (settled) return;
186
189
  settled = true;
187
- resolve({ type: "stream", stream, theme });
190
+ resolve({ type: "stream", stream, theme, status });
188
191
  };
189
192
  this.#pending.set(requestId, {
190
193
  onMeta: (meta) => {
191
194
  theme = meta.theme;
195
+ status = meta.status;
192
196
  settleStream();
193
197
  },
194
198
  onChunk: (data) => {
@@ -375,6 +379,7 @@ export class RscWorker {
375
379
  pagesBundlePath: this.#pagesBundlePath,
376
380
  pagesBundleBuildId: this.#pagesBundleBuildId,
377
381
  cssAssets: this.#cssAssets,
382
+ basePaths: this.#basePaths,
378
383
  i18n: this.#i18n,
379
384
  });
380
385
  return;
@@ -395,7 +400,7 @@ export class RscWorker {
395
400
  this.#pending.get(message.requestId)?.onChunk(message.data);
396
401
  return;
397
402
  case "meta":
398
- this.#pending.get(message.requestId)?.onMeta?.({ theme: message.theme });
403
+ this.#pending.get(message.requestId)?.onMeta?.({ theme: message.theme, status: message.status });
399
404
  return;
400
405
  case "end":
401
406
  this.#resolvePending(message.requestId, (p) => p.onEnd());