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.
- package/base/primitiveRegistry.ts +3 -0
- package/client/dictionaryAsset.ts +32 -0
- package/client/index.ts +1 -0
- package/client/makePageProto.tsx +9 -15
- package/client/native.ts +1 -22
- package/client/nativeHost.ts +22 -0
- package/client/translator.ts +20 -60
- package/client/translatorState.ts +44 -0
- package/client/types.ts +1 -0
- package/common/index.ts +1 -0
- package/common/requestLocale.ts +28 -0
- package/dictionary/base.dictionary.ts +1 -0
- package/fetch/client/fetchClient.ts +4 -0
- package/fetch/client/wsClient.ts +21 -4
- package/native/api.ts +1 -0
- package/native/config.ts +1 -0
- package/package.json +11 -1
- package/server/akanApp.ts +8 -9
- package/server/akanAppHeaders.ts +1 -1
- package/server/contentEncoding.ts +164 -26
- package/server/hmr/devArtifactPruner.ts +13 -3
- package/server/hmr/devHmrController.ts +1 -0
- package/server/imageOptimizer.ts +3 -1
- package/server/routeState.ts +1 -0
- package/server/routing/apiRouter.ts +17 -2
- package/server/rscClient.tsx +25 -12
- package/server/ssrFromRscRenderer.tsx +6 -6
- package/server/ssrTypes.ts +2 -0
- package/server/types.tsx +7 -0
- package/server/vendor/tailwind-merge.ts +1 -0
- package/server/webRouter.ts +77 -20
- package/store/storeInstance.ts +6 -5
- package/types/base/primitiveRegistry.d.ts +1 -0
- package/types/client/dictionaryAsset.d.ts +4 -0
- package/types/client/index.d.ts +1 -0
- package/types/client/native.d.ts +1 -8
- package/types/client/nativeHost.d.ts +8 -0
- package/types/client/translator.d.ts +4 -9
- package/types/client/translatorState.d.ts +21 -0
- package/types/client/types.d.ts +1 -0
- package/types/common/index.d.ts +1 -0
- package/types/common/requestLocale.d.ts +13 -0
- package/types/dictionary/base.dictionary.d.ts +1 -1
- package/types/dictionary/dictionary.d.ts +8 -8
- package/types/fetch/client/fetchClient.d.ts +1 -0
- package/types/fetch/client/wsClient.d.ts +2 -0
- package/types/native/api.d.ts +1 -0
- package/types/native/config.d.ts +1 -0
- package/types/server/contentEncoding.d.ts +16 -4
- package/types/server/hmr/devArtifactPruner.d.ts +1 -0
- package/types/server/routeState.d.ts +1 -0
- package/types/server/rscClient.d.ts +1 -0
- package/types/server/ssrTypes.d.ts +2 -0
- package/types/server/types.d.ts +6 -0
- package/types/server/vendor/tailwind-merge.d.ts +1 -0
- package/types/ui/System/Client.d.ts +7 -5
- package/types/ui/System/Common.d.ts +1 -4
- package/types/ui/System/SSR.d.ts +2 -2
- package/types/ui/index.d.ts +2 -0
- package/types/ui/recipe/badgeRecipe.d.ts +2 -2
- package/types/ui/recipe/buttonRecipe.d.ts +2 -2
- package/types/ui/recipe/factory.d.ts +2 -1
- package/types/ui/recipe/inputRecipe.d.ts +2 -2
- package/ui/Clipboard.tsx +3 -1
- package/ui/Link/Anchor.tsx +1 -2
- package/ui/System/CSR.tsx +4 -2
- package/ui/System/Client.tsx +23 -14
- package/ui/System/Common.tsx +1 -5
- package/ui/System/SSR.tsx +4 -5
- package/ui/fontCss.ts +1 -1
- package/ui/index.ts +2 -0
- package/ui/recipe/factory.ts +17 -4
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
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"
|
|
12
|
-
{ encoding: "gzip", ext: ".gz"
|
|
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,
|
|
18
|
-
export const
|
|
19
|
-
const
|
|
20
|
-
|
|
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
|
-
?
|
|
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
|
-
:
|
|
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
|
-
|
|
56
|
-
if (!
|
|
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
|
-
|
|
65
|
-
|
|
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
|
|
86
|
-
for (const { encoding, ext
|
|
87
|
-
if (!
|
|
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
|
|
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;
|
package/server/imageOptimizer.ts
CHANGED
|
@@ -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")
|
|
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
|
|
package/server/routeState.ts
CHANGED
|
@@ -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
|
|
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);
|
package/server/rscClient.tsx
CHANGED
|
@@ -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
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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.#
|
|
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 #
|
|
781
|
-
return
|
|
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 {
|
package/server/ssrTypes.ts
CHANGED
|
@@ -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";
|