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.
- package/base/baseEnv.ts +2 -1
- package/client/csrTypes.ts +22 -0
- package/client/translator.ts +25 -11
- package/package.json +1 -1
- package/server/akanApp.ts +1 -1
- package/server/artifact/routeSeedIndexStore.ts +34 -0
- package/server/routeElementComposer.tsx +101 -1
- package/server/routeTreeBuilder.ts +82 -1
- package/server/rscClient.tsx +9 -1
- package/server/rscWorker.tsx +308 -66
- package/server/rscWorkerHost.ts +10 -5
- package/server/systemPages.tsx +165 -0
- package/server/webRouter.ts +116 -18
- package/service/predefinedAdaptor/database.adaptor.ts +2 -0
- package/service/predefinedAdaptor/solidSqlite.ts +1 -0
- package/service/predefinedAdaptor/sqlitePath.ts +4 -1
- package/service/predefinedAdaptor/storage.adaptor.ts +2 -2
- package/types/client/csrTypes.d.ts +21 -0
- package/types/client/translator.d.ts +1 -0
- package/types/server/artifact/routeSeedIndexStore.d.ts +1 -0
- package/types/server/routeElementComposer.d.ts +15 -1
- package/types/server/routeTreeBuilder.d.ts +6 -1
- package/types/server/rscWorkerHost.d.ts +1 -0
- package/types/server/systemPages.d.ts +27 -0
- package/types/service/predefinedAdaptor/sqlitePath.d.ts +2 -1
- package/types/ui/System/Client.d.ts +5 -9
- package/types/ui/System/Common.d.ts +2 -0
- package/types/ui/System/SSR.d.ts +2 -2
- package/ui/InfiniteScroll.tsx +0 -1
- package/ui/System/Client.tsx +78 -20
- package/ui/System/Common.tsx +11 -2
- package/ui/System/SSR.tsx +58 -11
- package/webkit/bootCsr.tsx +13 -2
package/server/rscWorker.tsx
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
|
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
|
|
282
|
-
const
|
|
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, {
|
|
329
|
-
|
|
330
|
-
|
|
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
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
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
|
-
|
|
573
|
-
|
|
574
|
-
|
|
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();
|
package/server/rscWorkerHost.ts
CHANGED
|
@@ -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());
|