akanjs 3.1.0 → 3.1.1-rc.0

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 (72) hide show
  1. package/base/primitiveRegistry.ts +3 -0
  2. package/client/dictionaryAsset.ts +32 -0
  3. package/client/index.ts +1 -0
  4. package/client/makePageProto.tsx +9 -15
  5. package/client/native.ts +1 -22
  6. package/client/nativeHost.ts +22 -0
  7. package/client/translator.ts +20 -60
  8. package/client/translatorState.ts +44 -0
  9. package/client/types.ts +1 -0
  10. package/common/index.ts +1 -0
  11. package/common/requestLocale.ts +28 -0
  12. package/dictionary/base.dictionary.ts +1 -0
  13. package/fetch/client/fetchClient.ts +4 -0
  14. package/fetch/client/wsClient.ts +21 -4
  15. package/native/api.ts +1 -0
  16. package/native/config.ts +1 -0
  17. package/package.json +11 -1
  18. package/server/akanApp.ts +8 -9
  19. package/server/akanAppHeaders.ts +1 -1
  20. package/server/contentEncoding.ts +164 -26
  21. package/server/hmr/devArtifactPruner.ts +13 -3
  22. package/server/hmr/devHmrController.ts +1 -0
  23. package/server/imageOptimizer.ts +3 -1
  24. package/server/routeState.ts +1 -0
  25. package/server/routing/apiRouter.ts +17 -2
  26. package/server/rscClient.tsx +25 -12
  27. package/server/ssrFromRscRenderer.tsx +6 -6
  28. package/server/ssrTypes.ts +2 -0
  29. package/server/types.tsx +7 -0
  30. package/server/vendor/tailwind-merge.ts +1 -0
  31. package/server/webRouter.ts +77 -20
  32. package/store/storeInstance.ts +6 -5
  33. package/types/base/primitiveRegistry.d.ts +1 -0
  34. package/types/client/dictionaryAsset.d.ts +4 -0
  35. package/types/client/index.d.ts +1 -0
  36. package/types/client/native.d.ts +1 -8
  37. package/types/client/nativeHost.d.ts +8 -0
  38. package/types/client/translator.d.ts +4 -9
  39. package/types/client/translatorState.d.ts +21 -0
  40. package/types/client/types.d.ts +1 -0
  41. package/types/common/index.d.ts +1 -0
  42. package/types/common/requestLocale.d.ts +13 -0
  43. package/types/dictionary/base.dictionary.d.ts +1 -1
  44. package/types/dictionary/dictionary.d.ts +8 -8
  45. package/types/fetch/client/fetchClient.d.ts +1 -0
  46. package/types/fetch/client/wsClient.d.ts +2 -0
  47. package/types/native/api.d.ts +1 -0
  48. package/types/native/config.d.ts +1 -0
  49. package/types/server/contentEncoding.d.ts +16 -4
  50. package/types/server/hmr/devArtifactPruner.d.ts +1 -0
  51. package/types/server/routeState.d.ts +1 -0
  52. package/types/server/rscClient.d.ts +1 -0
  53. package/types/server/ssrTypes.d.ts +2 -0
  54. package/types/server/types.d.ts +6 -0
  55. package/types/server/vendor/tailwind-merge.d.ts +1 -0
  56. package/types/ui/System/Client.d.ts +7 -5
  57. package/types/ui/System/Common.d.ts +1 -4
  58. package/types/ui/System/SSR.d.ts +2 -2
  59. package/types/ui/index.d.ts +2 -0
  60. package/types/ui/recipe/badgeRecipe.d.ts +2 -2
  61. package/types/ui/recipe/buttonRecipe.d.ts +2 -2
  62. package/types/ui/recipe/factory.d.ts +2 -1
  63. package/types/ui/recipe/inputRecipe.d.ts +2 -2
  64. package/ui/Clipboard.tsx +3 -1
  65. package/ui/Link/Anchor.tsx +1 -2
  66. package/ui/System/CSR.tsx +4 -2
  67. package/ui/System/Client.tsx +23 -14
  68. package/ui/System/Common.tsx +1 -5
  69. package/ui/System/SSR.tsx +4 -5
  70. package/ui/fontCss.ts +1 -1
  71. package/ui/index.ts +2 -0
  72. package/ui/recipe/factory.ts +17 -4
@@ -1,4 +1,5 @@
1
- import { brotliCompressSync, constants, gzipSync } from "node:zlib";
1
+ import { promisify } from "node:util";
2
+ import { brotliCompress, constants, createBrotliCompress, createGzip, gzip } from "node:zlib";
2
3
 
3
4
  const COMPRESSIBLE_TYPES = new Set([
4
5
  "application/javascript",
@@ -8,16 +9,35 @@ const COMPRESSIBLE_TYPES = new Set([
8
9
  ]);
9
10
 
10
11
  export const CONTENT_ENCODINGS = [
11
- { encoding: "br", ext: ".br", accept: /(?:^|,)\s*(?:br|\*)(?![\w-])\s*(?:;\s*q=([\d.]+))?/i },
12
- { encoding: "gzip", ext: ".gz", accept: /(?:^|,)\s*(?:gzip|\*)(?![\w-])\s*(?:;\s*q=([\d.]+))?/i },
12
+ { encoding: "br", ext: ".br" },
13
+ { encoding: "gzip", ext: ".gz" },
13
14
  ] as const;
14
15
 
15
16
  export type ContentEncoding = (typeof CONTENT_ENCODINGS)[number]["encoding"];
16
17
 
17
- /** `q=0` is an explicit refusal, not a preference. */
18
- export const acceptsEncoding = (acceptEncoding: string, accept: RegExp): boolean => {
19
- const match = accept.exec(acceptEncoding);
20
- return !!match && !(match[1] !== undefined && Number.parseFloat(match[1]) <= 0);
18
+ /** `q=0` is an explicit refusal, and a coding named outright overrides `*`. */
19
+ export const acceptedEncodings = (acceptEncoding: string): ContentEncoding[] => {
20
+ const qByCoding = new Map<string, number>();
21
+ for (const part of acceptEncoding.split(",")) {
22
+ const [coding = "", ...params] = part.split(";");
23
+ const q = params.map((param) => param.trim()).find((param) => param.toLowerCase().startsWith("q="));
24
+ qByCoding.set(coding.trim().toLowerCase(), q === undefined ? 1 : Number.parseFloat(q.slice(2)));
25
+ }
26
+ return CONTENT_ENCODINGS.map(({ encoding }) => encoding).filter((encoding) => {
27
+ const q = qByCoding.get(encoding) ?? qByCoding.get("*");
28
+ return q !== undefined && !(q <= 0);
29
+ });
30
+ };
31
+
32
+ export const varyOnEncoding = (headers: Headers) => {
33
+ if (!/(?:^|,)\s*accept-encoding\s*(?:,|$)/i.test(headers.get("vary") ?? ""))
34
+ headers.append("Vary", "Accept-Encoding");
35
+ };
36
+
37
+ export const matchesIfNoneMatch = (ifNoneMatch: string | null, etag: string): boolean => {
38
+ if (!ifNoneMatch) return false;
39
+ const opaque = (tag: string) => tag.trim().replace(/^W\//, "");
40
+ return ifNoneMatch.split(",").some((tag) => tag.trim() === "*" || opaque(tag) === opaque(etag));
21
41
  };
22
42
 
23
43
  export const isCompressibleContentType = (contentType: string): boolean => {
@@ -34,43 +54,161 @@ const MIN_COMPRESS_BYTES = 1024;
34
54
 
35
55
  const MAX_COMPRESS_BYTES = 4 * 1024 * 1024;
36
56
 
57
+ const brotliCompressAsync = promisify(brotliCompress);
58
+ const gzipAsync = promisify(gzip);
59
+
37
60
  const compressBody = (bytes: Uint8Array, encoding: ContentEncoding) =>
38
61
  encoding === "br"
39
- ? brotliCompressSync(bytes, {
62
+ ? brotliCompressAsync(bytes, {
40
63
  params: {
41
64
  [constants.BROTLI_PARAM_QUALITY]: BROTLI_QUALITY,
42
65
  [constants.BROTLI_PARAM_SIZE_HINT]: bytes.byteLength,
43
66
  },
44
67
  })
45
- : gzipSync(bytes, { level: GZIP_LEVEL });
68
+ : gzipAsync(bytes, { level: GZIP_LEVEL });
46
69
 
47
- const isWholeBody = (req: Request, response: Response) =>
70
+ const isWholeBody = (req: Request, response: Pick<Response, "status" | "headers">) =>
48
71
  !req.headers.has("range") &&
49
72
  !response.headers.has("content-range") &&
50
73
  response.status >= 200 &&
51
74
  response.status < 300 &&
52
75
  response.status !== 206;
53
76
 
77
+ const isCompressionOff = () => process.env.AKAN_HTTP_COMPRESS === "false" || process.env.AKAN_HTTP_COMPRESS === "0";
78
+
79
+ const negotiateEncoding = (req: Request, response: Pick<Response, "status" | "headers">): ContentEncoding | null => {
80
+ if (response.headers.has("content-encoding")) return null;
81
+ if (!isWholeBody(req, response)) return null;
82
+ if (Number(response.headers.get("content-length")) > MAX_COMPRESS_BYTES) return null;
83
+ if (!isCompressibleContentType(response.headers.get("content-type") ?? "")) return null;
84
+ if (isCompressionOff()) return null;
85
+ return acceptedEncodings(req.headers.get("accept-encoding") ?? "")[0] ?? null;
86
+ };
87
+
54
88
  export const compressResponse = async (req: Request, response: Response): Promise<Response> => {
55
- if (response.headers.has("content-encoding") || !response.body) return response;
56
- if (!isWholeBody(req, response)) return response;
57
- if (Number(response.headers.get("content-length")) > MAX_COMPRESS_BYTES) return response;
58
- if (!isCompressibleContentType(response.headers.get("content-type") ?? "")) return response;
59
- if (process.env.AKAN_HTTP_COMPRESS === "false" || process.env.AKAN_HTTP_COMPRESS === "0") return response;
60
- const acceptEncoding = req.headers.get("accept-encoding") ?? "";
61
- const encoding = CONTENT_ENCODINGS.find(({ accept }) => acceptsEncoding(acceptEncoding, accept))?.encoding;
62
- if (!encoding) return response;
89
+ const encoding = negotiateEncoding(req, response);
90
+ if (!encoding || !response.body) return response;
63
91
  const bytes = new Uint8Array(await response.arrayBuffer());
64
- if (bytes.byteLength < MIN_COMPRESS_BYTES) return new Response(bytes, response);
65
- const compressed = compressBody(bytes, encoding);
92
+
93
+ if (bytes.byteLength < MIN_COMPRESS_BYTES || bytes.byteLength > MAX_COMPRESS_BYTES)
94
+ return new Response(bytes, response);
95
+ const compressed = await compressBody(bytes, encoding);
66
96
  const headers = new Headers(response.headers);
67
97
  headers.set("Content-Encoding", encoding);
68
98
  headers.set("Content-Length", String(compressed.byteLength));
69
-
70
- headers.append("Vary", "Accept-Encoding");
99
+ varyOnEncoding(headers);
71
100
  return new Response(compressed, { status: response.status, statusText: response.statusText, headers });
72
101
  };
73
102
 
103
+ const STREAM_BROTLI_LGWIN = 18;
104
+ const STREAM_FLUSH = { br: constants.BROTLI_OPERATION_FLUSH, gzip: constants.Z_SYNC_FLUSH } as const;
105
+
106
+ const createStreamCompressor = (encoding: ContentEncoding) => {
107
+ const compressor =
108
+ encoding === "br"
109
+ ? createBrotliCompress({
110
+ params: {
111
+ [constants.BROTLI_PARAM_QUALITY]: BROTLI_QUALITY,
112
+ [constants.BROTLI_PARAM_LGWIN]: STREAM_BROTLI_LGWIN,
113
+ },
114
+ })
115
+ : createGzip({ level: GZIP_LEVEL });
116
+ let pendingFlush: ReturnType<typeof setImmediate> | null = null;
117
+ let isCancelled = false;
118
+ const stop = () => {
119
+ isCancelled = true;
120
+ if (pendingFlush) clearImmediate(pendingFlush);
121
+ compressor.destroy();
122
+ };
123
+
124
+ const transformer: Transformer<Uint8Array, Uint8Array> & { cancel: () => void } = {
125
+ start: (controller) => {
126
+ compressor.on("data", (chunk: Uint8Array) => {
127
+ if (isCancelled) return;
128
+
129
+ try {
130
+ controller.enqueue(chunk);
131
+ } catch {
132
+ stop();
133
+ }
134
+ });
135
+ compressor.on("error", (error) => {
136
+ if (!isCancelled) controller.error(error);
137
+ });
138
+ },
139
+ transform: (chunk) => {
140
+ compressor.write(chunk);
141
+ pendingFlush ??= setImmediate(() => {
142
+ pendingFlush = null;
143
+ compressor.flush(STREAM_FLUSH[encoding]);
144
+ });
145
+ },
146
+ flush: () =>
147
+ new Promise<void>((resolve, reject) => {
148
+ if (pendingFlush) clearImmediate(pendingFlush);
149
+ compressor.once("end", resolve).once("close", resolve).once("error", reject).end();
150
+ }),
151
+ cancel: stop,
152
+ };
153
+ return new TransformStream<Uint8Array, Uint8Array>(transformer);
154
+ };
155
+
156
+ export const compressStreamResponse = (req: Request, response: Response): Response => {
157
+ const encoding = negotiateEncoding(req, response);
158
+ if (!encoding || !response.body) return response;
159
+ const headers = new Headers(response.headers);
160
+ headers.set("Content-Encoding", encoding);
161
+ headers.delete("Content-Length");
162
+ varyOnEncoding(headers);
163
+ const etag = headers.get("etag");
164
+
165
+ if (etag && !etag.startsWith("W/")) headers.set("ETag", `W/${etag}`);
166
+ return new Response(response.body.pipeThrough(createStreamCompressor(encoding)), {
167
+ status: response.status,
168
+ statusText: response.statusText,
169
+ headers,
170
+ });
171
+ };
172
+
173
+ export interface EncodedBody {
174
+ bytes: Uint8Array<ArrayBuffer>;
175
+ encodings: Partial<Record<ContentEncoding, Uint8Array<ArrayBuffer>>>;
176
+ /** The raw bytes and every encoding, which is what keeping it costs. */
177
+ byteLength: number;
178
+ }
179
+
180
+ export const encodeBody = async (body: string | Uint8Array<ArrayBuffer>): Promise<EncodedBody> => {
181
+ const bytes = typeof body === "string" ? new TextEncoder().encode(body) : body;
182
+ const encodings: EncodedBody["encodings"] = {};
183
+ let byteLength = bytes.byteLength;
184
+ if (isCompressionOff() || bytes.byteLength < MIN_COMPRESS_BYTES || bytes.byteLength > MAX_COMPRESS_BYTES)
185
+ return { bytes, encodings, byteLength };
186
+ await Promise.all(
187
+ CONTENT_ENCODINGS.map(async ({ encoding }) => {
188
+ const encoded = await compressBody(bytes, encoding);
189
+ encodings[encoding] = encoded;
190
+ byteLength += encoded.byteLength;
191
+ }),
192
+ );
193
+ return { bytes, encodings, byteLength };
194
+ };
195
+
196
+ export const encodedBodyResponse = (
197
+ req: Request,
198
+ body: EncodedBody,
199
+ init: { status?: number; headers: Headers },
200
+ ): Response => {
201
+ const status = init.status ?? 200;
202
+ const encoding = negotiateEncoding(req, { status, headers: init.headers });
203
+ const encoded = encoding ? body.encodings[encoding] : undefined;
204
+ if (!encoding || !encoded) return new Response(body.bytes, { status, headers: init.headers });
205
+ const headers = new Headers(init.headers);
206
+ headers.set("Content-Encoding", encoding);
207
+ headers.set("Content-Length", String(encoded.byteLength));
208
+ varyOnEncoding(headers);
209
+ return new Response(encoded, { status, headers });
210
+ };
211
+
74
212
  export interface EncodedSidecar {
75
213
  bytes: ArrayBuffer;
76
214
  encoding: string;
@@ -82,9 +220,9 @@ export const resolveEncodedSidecar = async (
82
220
  contentType: string,
83
221
  ): Promise<EncodedSidecar | null> => {
84
222
  if (!isCompressibleContentType(contentType)) return null;
85
- const acceptEncoding = req.headers.get("accept-encoding") ?? "";
86
- for (const { encoding, ext, accept } of CONTENT_ENCODINGS) {
87
- if (!acceptsEncoding(acceptEncoding, accept)) continue;
223
+ const accepted = acceptedEncodings(req.headers.get("accept-encoding") ?? "");
224
+ for (const { encoding, ext } of CONTENT_ENCODINGS) {
225
+ if (!accepted.includes(encoding)) continue;
88
226
  const file = Bun.file(`${filePath}${ext}`);
89
227
  if (!(await file.exists())) continue;
90
228
  const bytes = await file.bytes();
@@ -100,10 +238,10 @@ export const encodedFileResponse = async (
100
238
  contentType: string,
101
239
  headers: Headers,
102
240
  ): Promise<Response> => {
241
+ if (isCompressibleContentType(contentType)) varyOnEncoding(headers);
103
242
  const sidecar = await resolveEncodedSidecar(req, filePath, contentType);
104
243
  if (!sidecar) return new Response(Bun.file(filePath).stream(), { headers });
105
244
  headers.set("Content-Encoding", sidecar.encoding);
106
245
  headers.set("Content-Length", String(sidecar.bytes.byteLength));
107
- headers.set("Vary", "Accept-Encoding");
108
246
  return new Response(sidecar.bytes, { headers });
109
247
  };
@@ -1,7 +1,7 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { Logger } from "akanjs/common";
4
- import type { CssAsset } from "../types";
4
+ import type { BaseBuildArtifact, CssAsset } from "../types";
5
5
 
6
6
  export class DevArtifactPruner {
7
7
 
@@ -37,6 +37,15 @@ export class DevArtifactPruner {
37
37
  });
38
38
  }
39
39
 
40
+ pruneDictionaries(): Promise<void> {
41
+ return this.#queue(async () => {
42
+ const keep = new Set((await this.#baseArtifact()).dicts);
43
+ if (keep.size === 0) return;
44
+ const dictDir = path.join(this.#artifactDir, "dict");
45
+ await this.#removeOlder(dictDir, (name) => name.endsWith(".js"), keep, Date.now() - this.#graceMs);
46
+ });
47
+ }
48
+
40
49
  #queue(prune: () => Promise<void>): Promise<void> {
41
50
  this.#pruning = this.#pruning.then(prune).catch((error: unknown) => {
42
51
  this.#logger.verbose(`[dev-artifact] prune skipped: ${String(error)}`);
@@ -64,14 +73,15 @@ export class DevArtifactPruner {
64
73
  if (removed > 0) this.#logger.verbose(`[dev-artifact] removed ${removed} superseded file(s) from ${dir}`);
65
74
  }
66
75
 
67
- async #baseArtifact(): Promise<{ pages: string[]; styles: string[] }> {
76
+ async #baseArtifact(): Promise<{ pages: string[]; styles: string[]; dicts: string[] }> {
68
77
  const text = await fs.readFile(path.join(this.#artifactDir, "base-artifact.json"), "utf8").catch(() => null);
69
78
  const artifact = text
70
- ? (JSON.parse(text) as { pagesBundlePath?: string; cssAssets?: Record<string, CssAsset> })
79
+ ? (JSON.parse(text) as Partial<Pick<BaseBuildArtifact, "pagesBundlePath" | "cssAssets" | "dictAssets">>)
71
80
  : null;
72
81
  return {
73
82
  pages: artifact?.pagesBundlePath ? [path.resolve(this.#artifactDir, artifact.pagesBundlePath)] : [],
74
83
  styles: Object.values(artifact?.cssAssets ?? {}).map((asset) => path.join(this.#artifactDir, asset.cssRelPath)),
84
+ dicts: Object.values(artifact?.dictAssets ?? {}).map((asset) => path.join(this.#artifactDir, asset.relPath)),
75
85
  };
76
86
  }
77
87
  }
@@ -104,6 +104,7 @@ export class DevHmrController {
104
104
  this.#artifactDir = artifactDir;
105
105
  this.#runningBundlePath = pagesBundlePath ? path.resolve(pagesBundlePath) : null;
106
106
  this.#pruner = new DevArtifactPruner(artifactDir);
107
+ void this.#pruner.pruneDictionaries();
107
108
  this.#renderState = renderState;
108
109
  this.#rsc = rsc;
109
110
  this.#seedIndex = seedIndex;
@@ -3,6 +3,7 @@ import fs from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { Logger } from "akanjs/common";
5
5
  import { isAnimatedImage } from "./animatedImage";
6
+ import { matchesIfNoneMatch } from "./contentEncoding";
6
7
  import { ImageOptimizerError } from "./imageOptimizerError";
7
8
  import { Semaphore } from "./semaphore";
8
9
  import { resolveStaticPath } from "./staticPath";
@@ -381,7 +382,8 @@ export class ImageOptimizer {
381
382
  ETag: image.etag,
382
383
  Vary: "Accept",
383
384
  });
384
- if (req.headers.get("if-none-match") === image.etag) return new Response(null, { status: 304, headers });
385
+ if (matchesIfNoneMatch(req.headers.get("if-none-match"), image.etag))
386
+ return new Response(null, { status: 304, headers });
385
387
  return new Response(ImageOptimizer.#toArrayBuffer(image.buffer), { headers });
386
388
  }
387
389
 
@@ -6,6 +6,7 @@ export const AKAN_RSC_CURRENT_ROUTE_HEADER = "X-Akan-Rsc-Current-Route";
6
6
  export const AKAN_RSC_CURRENT_STATE_HEADER = "X-Akan-Rsc-Current-State";
7
7
  export const AKAN_RSC_RESPONSE_STATE_HEADER = "X-Akan-Rsc-State";
8
8
  export const AKAN_RSC_NOT_FOUND_HEADER = "X-Akan-Rsc-Not-Found";
9
+ export const AKAN_RSC_DICTIONARY_HEADER = "X-Akan-Dict";
9
10
  export const AKAN_RSC_PATCH_START_INDEX_HEADER = "X-Akan-Rsc-Patch-Start-Index";
10
11
  export const AKAN_RSC_PATCH_SEGMENT_PATH_HEADER = "X-Akan-Rsc-Patch-Segment-Path";
11
12
  export const AKAN_RSC_PATCH_START_SEGMENT_HEADER = "X-Akan-Rsc-Patch-Start-Segment";
@@ -2,7 +2,7 @@ import { dayjs } from "akanjs/base";
2
2
  import { type Logger, websocketAuthContract, websocketHeartbeatContract } from "akanjs/common";
3
3
  import type { InjectRegistry, LiveRegistry } from "akanjs/service";
4
4
  import { CrossSiteGuard, isExceptionLike, SignalContext, SignalFailure, type WebsocketReqData } from "akanjs/signal";
5
- import { compressResponse } from "../contentEncoding";
5
+ import { compressResponse, compressStreamResponse } from "../contentEncoding";
6
6
  import type { HmrWsData, HmrWsHub } from "../hmr/wsHub";
7
7
  import { copyBunRequestFields, type WebProxyRunner } from "../proxy";
8
8
  import { SignalResolver } from "../resolver";
@@ -93,9 +93,10 @@ export class ApiRouter {
93
93
  ...Object.fromEntries(builtinEntries),
94
94
  ...(renderEnvRoutes ?? {}),
95
95
  } as NonNullHttpRoutes;
96
- const served = webProxyRunner
96
+ const proxied = webProxyRunner
97
97
  ? ApiRouter.#wrapRoutesWithWebProxy(routeTable, webProxyRunner, prefix, endpointPaths)
98
98
  : routeTable;
99
+ const served = ApiRouter.#compressRenderRoutes(proxied, new Set(Object.keys(renderEnvRoutes ?? {})));
99
100
  return hostAllowlist ? ApiRouter.#guardHosts(served, hostAllowlist) : served;
100
101
  }
101
102
 
@@ -252,6 +253,20 @@ export class ApiRouter {
252
253
  });
253
254
  }
254
255
 
256
+ static #compressRenderRoutes(routes: NonNullHttpRoutes, renderPaths: Set<string>): NonNullHttpRoutes {
257
+ return Object.fromEntries(
258
+ Object.entries(routes).map(([path, route]) => [
259
+ path,
260
+ renderPaths.has(path)
261
+ ? ApiRouter.#mapRoute(route, (handler) => async (req) => {
262
+ const response = await handler(req);
263
+ return response ? compressStreamResponse(req, response) : response;
264
+ })
265
+ : route,
266
+ ]),
267
+ ) as NonNullHttpRoutes;
268
+ }
269
+
255
270
  static #corsRoute(route: RouteValue): RouteValue {
256
271
  if (!route || typeof route !== "object" || route instanceof Response || "OPTIONS" in route) return route;
257
272
  const methods = Object.keys(route);
@@ -1,9 +1,11 @@
1
1
  import { createElement, type ReactNode, startTransition, type Usable, use, useLayoutEffect, useState } from "react";
2
2
  import { hydrateRoot } from "react-dom/client";
3
3
  import { createFromReadableStream } from "react-server-dom-webpack/client.browser";
4
+ import { DictionaryAsset } from "../client/dictionaryAsset";
4
5
  import type {} from "../client/rscNavigation";
5
6
  import { isAuthTokenKey } from "../common/authCookie";
6
7
  import {
8
+ AKAN_RSC_DICTIONARY_HEADER,
7
9
  type AkanHeadSnapshotV1,
8
10
  type AkanRouterStateV1,
9
11
  type AkanRscPatchMetadata,
@@ -44,6 +46,7 @@ declare global {
44
46
  var __RSC_PUSH__: ((type: InlineRscChunk[0], data: string) => void) | undefined;
45
47
  var __RSC_CLOSE__: (() => void) | undefined;
46
48
  var __AKAN_RSC_INITIAL_STATE__: string | undefined;
49
+ var __AKAN_DICT_URL__: string | undefined;
47
50
  }
48
51
 
49
52
  function decodeInlineRscChunk([type, data]: InlineRscChunk): Uint8Array {
@@ -236,18 +239,23 @@ async function fetchRsc(
236
239
  shouldApplyNavigation,
237
240
  });
238
241
  if (responseResult.type === "redirected" || responseResult.type === "not-found") return responseResult;
242
+
243
+ const dictionaryReady = DictionaryAsset.load(responseResult.response.headers.get(AKAN_RSC_DICTIONARY_HEADER));
239
244
  if (responseResult.type === "patch") {
240
- const patchResult = await validateRscPatchForGuardedCommit({
241
- partialCommitEnabled: isAkanRscPartialCommitEnabled(),
242
- currentTree: currentSegmentTree,
243
- response: responseResult.response,
244
- patch: responseResult.patch,
245
- href,
246
- createThenable: createRscThenable,
247
- navId: options.navId,
248
- getCurrentNavId: () => navigationSeq,
249
- getHeadSnapshotPatchFailureReason: getAkanHeadSnapshotPatchFailureReason,
250
- });
245
+ const [patchResult] = await Promise.all([
246
+ validateRscPatchForGuardedCommit({
247
+ partialCommitEnabled: isAkanRscPartialCommitEnabled(),
248
+ currentTree: currentSegmentTree,
249
+ response: responseResult.response,
250
+ patch: responseResult.patch,
251
+ href,
252
+ createThenable: createRscThenable,
253
+ navId: options.navId,
254
+ getCurrentNavId: () => navigationSeq,
255
+ getHeadSnapshotPatchFailureReason: getAkanHeadSnapshotPatchFailureReason,
256
+ }),
257
+ dictionaryReady,
258
+ ]);
251
259
  if (patchResult.status === "patched") {
252
260
  if (!patchResult.headSnapshot) throw new Error("[rscClient] validated RSC patch is missing a head snapshot");
253
261
  return {
@@ -284,6 +292,7 @@ async function fetchRsc(
284
292
  routerState: responseResult.notFound ? null : readAkanRouterStateResponseHeader(res.headers),
285
293
  });
286
294
  nodeRef.current = node;
295
+ await dictionaryReady;
287
296
  return {
288
297
  type: "rsc",
289
298
  node,
@@ -604,4 +613,8 @@ const hydrate = () => {
604
613
  hydrateRoot(document, createElement(Root));
605
614
  scrollRestoration.restoreDocumentLoad();
606
615
  };
607
- void Promise.resolve(initialThenable).then(hydrate, hydrate);
616
+
617
+ const initialDictionary = DictionaryAsset.load(globalThis.__AKAN_DICT_URL__).catch((error: unknown) =>
618
+ console.error(`[rscClient] the page's dictionary failed to load: ${String(error)}`),
619
+ );
620
+ void Promise.allSettled([initialThenable, initialDictionary]).then(hydrate);
@@ -610,7 +610,7 @@ export class SsrFromRscRenderer {
610
610
 
611
611
  const withHeadScripts = SsrFromRscRenderer.#injectHeadScriptsIntoHead(htmlStream, {
612
612
  importmap: input.importmap,
613
- bootstrapModules: input.bootstrapModules,
613
+ modulePreloads: [...(input.bootstrapModules ?? []), ...(input.modulePreloads ?? [])],
614
614
  theme: input.theme,
615
615
  injectThemeInitScript: input.injectThemeInitScript,
616
616
  });
@@ -714,20 +714,20 @@ export class SsrFromRscRenderer {
714
714
  stream: ReadableStream<Uint8Array>,
715
715
  options: {
716
716
  importmap?: Record<string, string>;
717
- bootstrapModules?: string[];
717
+ modulePreloads: string[];
718
718
  theme?: AkanTheme;
719
719
  injectThemeInitScript?: boolean;
720
720
  },
721
721
  ): ReadableStream<Uint8Array> {
722
722
  const encoder = new TextEncoder();
723
723
  const decoder = new TextDecoder();
724
- const { importmap, bootstrapModules, theme, injectThemeInitScript } = options;
724
+ const { importmap, modulePreloads, theme, injectThemeInitScript } = options;
725
725
  const htmlTheme = theme && theme !== "css" && theme !== "system" ? theme : undefined;
726
726
  const importmapTag =
727
727
  importmap && Object.keys(importmap).length > 0
728
728
  ? `<script type="importmap">${JSON.stringify({ imports: importmap })}</script>`
729
729
  : "";
730
- const modulePreloadTags = SsrFromRscRenderer.#createBootstrapModulePreloadTags(bootstrapModules);
730
+ const modulePreloadTags = SsrFromRscRenderer.#createModulePreloadTags(modulePreloads);
731
731
  const shouldInjectThemeScript = theme === "system" || (theme === undefined && injectThemeInitScript);
732
732
  const themeInitTag = shouldInjectThemeScript ? SsrFromRscRenderer.#themeInitScript : "";
733
733
  const tags = `${themeInitTag}${importmapTag}${modulePreloadTags}`;
@@ -777,8 +777,8 @@ export class SsrFromRscRenderer {
777
777
  );
778
778
  }
779
779
 
780
- static #createBootstrapModulePreloadTags(bootstrapModules: string[] = []): string {
781
- return bootstrapModules.map((src) => `<link rel="modulepreload" href="${escapeHtmlAttr(src)}">`).join("");
780
+ static #createModulePreloadTags(modules: string[]): string {
781
+ return modules.map((src) => `<link rel="modulepreload" href="${escapeHtmlAttr(src)}">`).join("");
782
782
  }
783
783
 
784
784
  static #createBootstrapModuleScriptTags(bootstrapModules: string[] = []): string {
@@ -54,6 +54,8 @@ export interface SsrFromRscInput {
54
54
  rscStream: ReadableStream<Uint8Array>;
55
55
  ssrManifest: SsrManifest;
56
56
  bootstrapModules?: string[];
57
+ /** Modules a bootstrap module imports at load: preloaded in the head, never run as scripts. */
58
+ modulePreloads?: string[];
57
59
  extraBootstrapInline?: string;
58
60
 
59
61
  importmap?: Record<string, string>;
package/server/types.tsx CHANGED
@@ -101,6 +101,8 @@ export type BaseBuildArtifact = {
101
101
  pagesBundlePath: string;
102
102
  pagesBundleBuildId: number;
103
103
  cssAssets: Record<string, CssAsset>;
104
+ /** Per locale; absent in an artifact older than the asset, whose pages carried their dictionary in the flight. */
105
+ dictAssets?: Record<string, DictAsset>;
104
106
  domains: string[];
105
107
  subRoutes: { [key: string]: string[] };
106
108
  basePaths: string[];
@@ -140,6 +142,11 @@ export interface CssAsset {
140
142
  cssRelPath: string;
141
143
  }
142
144
 
145
+ export interface DictAsset {
146
+ url: string;
147
+ relPath: string;
148
+ }
149
+
143
150
  export interface RenderState {
144
151
  buildId: number;
145
152
  cssAssets: Record<string, CssAsset>;
@@ -0,0 +1 @@
1
+ export * from "tailwind-merge";