@akanjs/devkit 3.1.0 → 3.1.1-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/akanConfig/akanConfig.ts +1 -1
- package/artifact/implicitRootLayout.ts +1 -1
- package/cloud/cloudApi.test.ts +46 -0
- package/cloud/cloudApi.ts +14 -10
- package/frontendBuild/dictionaryAssetBuilder.test.ts +77 -0
- package/frontendBuild/dictionaryAssetBuilder.ts +49 -0
- package/frontendBuild/precompressArtifacts.test.ts +42 -0
- package/frontendBuild/precompressArtifacts.ts +21 -10
- package/frontendBuild/routeClientBuilder.ts +1 -0
- package/frontendBuild/ssrBaseArtifactBuilder.ts +3 -0
- package/frontendBuild/vendorSpecifiers.ts +1 -0
- package/index.ts +0 -1
- package/mobile/desktopServerStage.ts +1 -1
- package/mobile/nativeApi.ts +1 -1
- package/mobile/nativeApp.ts +1 -1
- package/mobile/nativeConfig.ts +1 -1
- package/mobile/updatePackFolder.ts +1 -1
- package/mobile/updateUploader.ts +1 -1
- package/package.json +2 -2
- package/renderMutationScanner.test.ts +223 -0
- package/renderMutationScanner.ts +209 -0
- package/signalMutationIndex.test.ts +120 -0
- package/signalMutationIndex.ts +127 -0
- package/ssrScanner.test.ts +31 -0
- package/ssrScanner.ts +31 -12
- package/transforms/barrelImportsPlugin.ts +4 -2
- package/uploadRelease.ts +0 -80
package/akanConfig/akanConfig.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
|
-
import type { ServerPolicy } from "@akanjs/native/api";
|
|
5
4
|
import type { AkanPlugin } from "akanjs";
|
|
6
5
|
import { DatabaseModes, normalizeRoutePrefix } from "akanjs/base";
|
|
7
6
|
import { type AkanI18nConfig, resolveAkanI18nConfig, trimSlashes } from "akanjs/common";
|
|
7
|
+
import type { ServerPolicy } from "akanjs/native/api";
|
|
8
8
|
import type { AkanImageConfig } from "akanjs/server";
|
|
9
9
|
import type { App, Lib } from "../commandDecorators";
|
|
10
10
|
import { LibExecutor, WorkspaceExecutor } from "../executors";
|
|
@@ -244,7 +244,7 @@ ${layoutExports}(
|
|
|
244
244
|
className={defaultFontClassName}
|
|
245
245
|
layoutStyle={userLayout.layoutStyle ?? inheritedLayout.layoutStyle}
|
|
246
246
|
reconnect={userLayout.reconnect ?? inheritedLayout.reconnect ?? false}
|
|
247
|
-
wsConnect={userLayout.wsConnect ?? inheritedLayout.wsConnect
|
|
247
|
+
wsConnect={userLayout.wsConnect ?? inheritedLayout.wsConnect}
|
|
248
248
|
allDictionary={process.env.AKAN_PUBLIC_RENDER_ENV === "ssr" ? allDictionary : undefined}
|
|
249
249
|
>
|
|
250
250
|
${layoutChild}
|
package/cloud/cloudApi.test.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
2
|
import { readFile, rm } from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
|
+
import type { Workspace } from "../commandDecorators";
|
|
4
5
|
import { tempDirs, writeJson, writeText } from "../testHelpers";
|
|
6
|
+
import { CloudApi } from "./cloudApi";
|
|
5
7
|
|
|
6
8
|
// The cloud rotates a refresh token on every use and treats a second use as theft, revoking every session.
|
|
7
9
|
class FakeCloud {
|
|
@@ -10,6 +12,8 @@ class FakeCloud {
|
|
|
10
12
|
reused = false;
|
|
11
13
|
delayMs = 0;
|
|
12
14
|
hang: boolean = false;
|
|
15
|
+
readonly remoteAuthPolls: string[] = [];
|
|
16
|
+
remoteAuthAnswers: (Response | { jwt: string; refreshToken?: string } | null)[] = [];
|
|
13
17
|
readonly #refreshTokens = new Set(["refresh-0"]);
|
|
14
18
|
readonly #spent = new Set<string>();
|
|
15
19
|
readonly #jwts = new Set(["jwt-0"]);
|
|
@@ -29,6 +33,14 @@ class FakeCloud {
|
|
|
29
33
|
const jwt = request.headers.get("authorization")?.replace(/^Bearer /, "") ?? "";
|
|
30
34
|
return Response.json(this.#jwts.has(jwt) ? { id: "u1", nickname: "akan" } : null);
|
|
31
35
|
}
|
|
36
|
+
const remoteAuthPrefix = "/api/getRemoteAuthToken/";
|
|
37
|
+
if (url.pathname.startsWith(remoteAuthPrefix)) {
|
|
38
|
+
this.remoteAuthPolls.push(decodeURIComponent(url.pathname.slice(remoteAuthPrefix.length)));
|
|
39
|
+
const answer = this.remoteAuthAnswers.shift() ?? null;
|
|
40
|
+
if (answer instanceof Response) return answer;
|
|
41
|
+
if (answer) this.#jwts.add(answer.jwt);
|
|
42
|
+
return Response.json(answer);
|
|
43
|
+
}
|
|
32
44
|
if (url.pathname !== "/api/refreshAuthToken") return new Response("not found", { status: 404 });
|
|
33
45
|
const { refreshToken } = (await request.json()) as { refreshToken: string };
|
|
34
46
|
this.refreshes.push(refreshToken);
|
|
@@ -200,3 +212,37 @@ describe("CloudApi.fromHost token refresh", () => {
|
|
|
200
212
|
expect(await storedToken(home)).toMatchObject({ jwt: "jwt-0", refreshToken: "refresh-0" });
|
|
201
213
|
}, 30_000);
|
|
202
214
|
});
|
|
215
|
+
|
|
216
|
+
describe("CloudApi remote sign-in", () => {
|
|
217
|
+
const remoteId = "0f6a1d2e-8c43-4b7a-9e11-5d2c3b4a6f70";
|
|
218
|
+
const apiOf = () => new CloudApi({ workspaceRoot: "/unused" } as unknown as Workspace, { host: cloud.host });
|
|
219
|
+
|
|
220
|
+
test("reads the server's null as pending, then takes the session it hands out and calls with it", async () => {
|
|
221
|
+
cloud = new FakeCloud();
|
|
222
|
+
cloud.remoteAuthAnswers = [null, { jwt: "jwt-remote", refreshToken: "refresh-remote" }];
|
|
223
|
+
const api = apiOf();
|
|
224
|
+
|
|
225
|
+
expect(await api.getRemoteAuthToken(remoteId)).toBeNull();
|
|
226
|
+
expect(await api.getRemoteSelf()).toBeNull();
|
|
227
|
+
expect(await api.getRemoteAuthToken(remoteId)).toMatchObject({ jwt: "jwt-remote", refreshToken: "refresh-remote" });
|
|
228
|
+
expect(await api.getRemoteSelf()).toEqual({ id: "u1", nickname: "akan" });
|
|
229
|
+
expect(cloud.remoteAuthPolls).toEqual([remoteId, remoteId]);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test("throws a refusal, a server error, an answer that is no session, and an unreachable server", async () => {
|
|
233
|
+
cloud = new FakeCloud();
|
|
234
|
+
cloud.remoteAuthAnswers = [
|
|
235
|
+
new Response("<html>Not Found</html>", { status: 404 }),
|
|
236
|
+
Response.json({ error: "base.error.serverUnavailable" }, { status: 503 }),
|
|
237
|
+
Response.json({ token: "not-a-session" }),
|
|
238
|
+
];
|
|
239
|
+
const api = apiOf();
|
|
240
|
+
|
|
241
|
+
await expect(api.getRemoteAuthToken(remoteId)).rejects.toMatchObject({ name: "CloudHttpError", status: 404 });
|
|
242
|
+
await expect(api.getRemoteAuthToken(remoteId)).rejects.toMatchObject({ name: "CloudHttpError", status: 503 });
|
|
243
|
+
await expect(api.getRemoteAuthToken(remoteId)).rejects.toThrow("something other than a session");
|
|
244
|
+
cloud.server.stop(true);
|
|
245
|
+
await expect(api.getRemoteAuthToken(remoteId)).rejects.toThrow();
|
|
246
|
+
await expect(api.getRemoteSelf()).rejects.toThrow();
|
|
247
|
+
});
|
|
248
|
+
});
|
package/cloud/cloudApi.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isRecord } from "akanjs/common";
|
|
1
2
|
import type { Workspace } from "../commandDecorators";
|
|
2
3
|
import { CloudHttpError } from "./cloudHttpError";
|
|
3
4
|
import type { AccessToken, AccessTokenDto, HostConfig } from "./constants";
|
|
@@ -127,6 +128,7 @@ export class CloudApi {
|
|
|
127
128
|
return new Error(`${unavailable}: ${errKey}.`, { cause: error });
|
|
128
129
|
return error;
|
|
129
130
|
}
|
|
131
|
+
static readonly #remoteAuthTimeoutMs = 20_000;
|
|
130
132
|
readonly #api: HttpClient;
|
|
131
133
|
#accessToken: AccessToken | null = null;
|
|
132
134
|
#workspace: Workspace;
|
|
@@ -212,12 +214,15 @@ export class CloudApi {
|
|
|
212
214
|
await this.#api.getFile(`/downloadEnv/${devProjectId}`, localPath);
|
|
213
215
|
return localPath;
|
|
214
216
|
}
|
|
217
|
+
//* The server answers `null` until the login is approved, so a failed request throws rather than read as "not yet".
|
|
215
218
|
async getRemoteAuthToken(remoteId: string): Promise<AccessToken | null> {
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
|
|
219
|
+
const answer = await this.#api.get<unknown>(`/getRemoteAuthToken/${encodeURIComponent(remoteId)}`, {
|
|
220
|
+
signal: AbortSignal.timeout(CloudApi.#remoteAuthTimeoutMs),
|
|
221
|
+
});
|
|
222
|
+
if (answer === null) return null;
|
|
223
|
+
if (!isRecord(answer) || typeof answer.jwt !== "string" || !answer.jwt)
|
|
224
|
+
throw new Error(`${this.host} answered the sign-in poll with something other than a session.`);
|
|
225
|
+
return this.#authorize(answer as unknown as AccessTokenDto);
|
|
221
226
|
}
|
|
222
227
|
async refreshAuthToken(refreshToken: string): Promise<AccessToken> {
|
|
223
228
|
return this.#authorize(
|
|
@@ -241,11 +246,10 @@ export class CloudApi {
|
|
|
241
246
|
async revokeTunnel(code: string): Promise<boolean> {
|
|
242
247
|
return await this.#api.post<boolean>(`/tunnel/revokeTunnel`, { code }, { signal: AbortSignal.timeout(5_000) });
|
|
243
248
|
}
|
|
249
|
+
/** `null` when the server takes no sign-in from this session; a request that fails throws. */
|
|
244
250
|
async getRemoteSelf(): Promise<{ id: string; nickname: string } | null> {
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
}
|
|
248
|
-
return null;
|
|
249
|
-
}
|
|
251
|
+
return await this.#api.get<{ id: string; nickname: string } | null>(`/getRemoteSelf`, {
|
|
252
|
+
signal: AbortSignal.timeout(CloudApi.#remoteAuthTimeoutMs),
|
|
253
|
+
});
|
|
250
254
|
}
|
|
251
255
|
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdir, readdir, writeFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import type { App } from "../commandDecorators";
|
|
5
|
+
import { tempDirs } from "../testHelpers";
|
|
6
|
+
import { DictionaryAssetBuilder } from "./dictionaryAssetBuilder";
|
|
7
|
+
|
|
8
|
+
const tempRoot = tempDirs("akan-devkit-dict-asset-");
|
|
9
|
+
|
|
10
|
+
const allDictionary = {
|
|
11
|
+
en: { app: { title: { t: "Title" }, partial: { t: "Partial" } }, libOnly: { signin: { t: "Sign In" } } },
|
|
12
|
+
ko: { app: { title: { t: "제목" } } },
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const makeApp = async (dictSource?: string) => {
|
|
16
|
+
const root = await tempRoot();
|
|
17
|
+
const cwdPath = path.join(root, "apps/demo");
|
|
18
|
+
await mkdir(path.join(cwdPath, "lib"), { recursive: true });
|
|
19
|
+
if (dictSource) await writeFile(path.join(cwdPath, "lib/dict.ts"), dictSource);
|
|
20
|
+
const app = { cwdPath, verbose: () => undefined } as unknown as App;
|
|
21
|
+
return { app, artifactDir: path.join(root, "artifact") };
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const dictSourceOf = (dictionary: object) => `export const getAllDictionary = () => (${JSON.stringify(dictionary)});\n`;
|
|
25
|
+
|
|
26
|
+
describe("DictionaryAssetBuilder", () => {
|
|
27
|
+
test("writes one module per locale, the default locale's text filled into the gaps", async () => {
|
|
28
|
+
const { app, artifactDir } = await makeApp(dictSourceOf(allDictionary));
|
|
29
|
+
const assets = await new DictionaryAssetBuilder(app, artifactDir).build({
|
|
30
|
+
defaultLocale: "en",
|
|
31
|
+
locales: ["en", "ko"],
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
expect(Object.keys(assets)).toEqual(["en", "ko"]);
|
|
35
|
+
expect(assets.ko.url).toMatch(/^\/_akan\/dict\/ko-[a-z0-9]+\.js$/);
|
|
36
|
+
expect(assets.ko.url).toBe(`/_akan/${assets.ko.relPath}`);
|
|
37
|
+
const ko = (await import(path.join(artifactDir, assets.ko.relPath))) as { lang: string; default: object };
|
|
38
|
+
expect(ko.lang).toBe("ko");
|
|
39
|
+
expect(ko.default).toEqual({
|
|
40
|
+
app: { title: { t: "제목" }, partial: { t: "Partial" } },
|
|
41
|
+
libOnly: { signin: { t: "Sign In" } },
|
|
42
|
+
});
|
|
43
|
+
const en = (await import(path.join(artifactDir, assets.en.relPath))) as { default: object };
|
|
44
|
+
expect(en.default).toEqual(allDictionary.en);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("takes the default locale from the config it is handed", async () => {
|
|
48
|
+
const { app, artifactDir } = await makeApp(dictSourceOf(allDictionary));
|
|
49
|
+
const assets = await new DictionaryAssetBuilder(app, artifactDir).build({
|
|
50
|
+
defaultLocale: "ko",
|
|
51
|
+
locales: ["en", "ko"],
|
|
52
|
+
});
|
|
53
|
+
const en = (await import(path.join(artifactDir, assets.en.relPath))) as { default: object };
|
|
54
|
+
expect(en.default).toEqual(allDictionary.en);
|
|
55
|
+
const ko = (await import(path.join(artifactDir, assets.ko.relPath))) as { default: object };
|
|
56
|
+
expect(ko.default).toEqual(allDictionary.ko);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("names a module by its content, so a rebuild of the same dictionary keeps the URL", async () => {
|
|
60
|
+
const { app, artifactDir } = await makeApp(dictSourceOf(allDictionary));
|
|
61
|
+
const i18n = { defaultLocale: "en", locales: ["en", "ko"] };
|
|
62
|
+
const first = await new DictionaryAssetBuilder(app, artifactDir).build(i18n);
|
|
63
|
+
const second = await new DictionaryAssetBuilder(app, artifactDir).build(i18n);
|
|
64
|
+
expect(second).toEqual(first);
|
|
65
|
+
expect((await readdir(path.join(artifactDir, "dict"))).length).toBe(2);
|
|
66
|
+
expect(DictionaryAssetBuilder.source("en", { a: { t: "A" } })).not.toBe(
|
|
67
|
+
DictionaryAssetBuilder.source("en", { a: { t: "B" } }),
|
|
68
|
+
);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("writes nothing for an app without a dictionary", async () => {
|
|
72
|
+
const { app, artifactDir } = await makeApp();
|
|
73
|
+
expect(await new DictionaryAssetBuilder(app, artifactDir).build({ defaultLocale: "en", locales: ["en"] })).toEqual(
|
|
74
|
+
{},
|
|
75
|
+
);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { type AllDictionary, Translator } from "akanjs/client";
|
|
3
|
+
import type { AkanI18nConfig } from "akanjs/common";
|
|
4
|
+
import type { DictAsset } from "akanjs/server";
|
|
5
|
+
import type { App } from "../commandDecorators";
|
|
6
|
+
|
|
7
|
+
export class DictionaryAssetBuilder {
|
|
8
|
+
static readonly urlPrefix = "/_akan/dict";
|
|
9
|
+
readonly #app: App;
|
|
10
|
+
readonly #absArtifactDir: string;
|
|
11
|
+
|
|
12
|
+
constructor(app: App, absArtifactDir: string) {
|
|
13
|
+
this.#app = app;
|
|
14
|
+
this.#absArtifactDir = absArtifactDir;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async build(i18n: AkanI18nConfig): Promise<Record<string, DictAsset>> {
|
|
18
|
+
const allDictionary = await this.#loadAllDictionary();
|
|
19
|
+
if (!allDictionary) return {};
|
|
20
|
+
const assets = await Promise.all(
|
|
21
|
+
i18n.locales.map((lang) =>
|
|
22
|
+
this.#write(lang, Translator.fillFromDefault(allDictionary, lang, i18n.defaultLocale)),
|
|
23
|
+
),
|
|
24
|
+
);
|
|
25
|
+
this.#app.verbose(`[base-artifact] dictionaries -> ${assets.map(([, asset]) => asset.relPath).join(", ")}`);
|
|
26
|
+
return Object.fromEntries(assets);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
//? The same module the root layout's macro evaluates in this process, so the asset and the RSC worker's copy agree.
|
|
30
|
+
async #loadAllDictionary(): Promise<AllDictionary | null> {
|
|
31
|
+
const dictPath = path.resolve(this.#app.cwdPath, "lib", "dict.ts");
|
|
32
|
+
if (!(await Bun.file(dictPath).exists())) return null;
|
|
33
|
+
const { getAllDictionary } = (await import(dictPath)) as { getAllDictionary?: () => AllDictionary };
|
|
34
|
+
return getAllDictionary ? getAllDictionary() : null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async #write(lang: string, dictionary: object): Promise<readonly [string, DictAsset]> {
|
|
38
|
+
const source = DictionaryAssetBuilder.source(lang, dictionary);
|
|
39
|
+
const name = `${lang}-${Bun.hash(source).toString(36)}.js`;
|
|
40
|
+
const relPath = `dict/${name}`;
|
|
41
|
+
await Bun.write(path.join(this.#absArtifactDir, relPath), source);
|
|
42
|
+
return [lang, { url: `${DictionaryAssetBuilder.urlPrefix}/${name}`, relPath }] as const;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
//? A string for JSON.parse, not an object literal: engines parse the JSON grammar faster than a JS expression.
|
|
46
|
+
static source(lang: string, dictionary: object): string {
|
|
47
|
+
return `export const lang = ${JSON.stringify(lang)};\nexport default JSON.parse(${JSON.stringify(JSON.stringify(dictionary))});\n`;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import zlib from "node:zlib";
|
|
5
|
+
import type { App } from "../commandDecorators";
|
|
6
|
+
import { tempDirs } from "../testHelpers";
|
|
7
|
+
import { precompressArtifacts } from "./precompressArtifacts";
|
|
8
|
+
|
|
9
|
+
const tempRoot = tempDirs("akan-devkit-precompress-");
|
|
10
|
+
|
|
11
|
+
const brotliAt = (bytes: Buffer, quality: number) =>
|
|
12
|
+
zlib.brotliCompressSync(bytes, {
|
|
13
|
+
params: {
|
|
14
|
+
[zlib.constants.BROTLI_PARAM_QUALITY]: quality,
|
|
15
|
+
[zlib.constants.BROTLI_PARAM_SIZE_HINT]: bytes.byteLength,
|
|
16
|
+
},
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
describe("precompressArtifacts", () => {
|
|
20
|
+
test("writes sidecars for client chunks, stylesheets and dictionaries, a dictionary's brotli at quality 11", async () => {
|
|
21
|
+
const distPath = await tempRoot();
|
|
22
|
+
const artifactDir = path.join(distPath, ".akan/artifact");
|
|
23
|
+
const files = {
|
|
24
|
+
"client/chunk.js": `export const x = ${JSON.stringify("chunk ".repeat(400))};`,
|
|
25
|
+
"styles/root.css": `.a{color:red}${".b{margin:0}".repeat(200)}`,
|
|
26
|
+
"dict/en-abc.js": `export default JSON.parse(${JSON.stringify(JSON.stringify({ a: { t: "word ".repeat(400) } }))});`,
|
|
27
|
+
};
|
|
28
|
+
for (const [rel, content] of Object.entries(files)) {
|
|
29
|
+
await mkdir(path.dirname(path.join(artifactDir, rel)), { recursive: true });
|
|
30
|
+
await writeFile(path.join(artifactDir, rel), content);
|
|
31
|
+
}
|
|
32
|
+
const app = { dist: { cwdPath: distPath }, verbose: () => undefined } as unknown as App;
|
|
33
|
+
|
|
34
|
+
expect((await precompressArtifacts(app)).files).toBe(3);
|
|
35
|
+
const dictPath = path.join(artifactDir, "dict/en-abc.js");
|
|
36
|
+
const dictBytes = await readFile(dictPath);
|
|
37
|
+
expect(await readFile(`${dictPath}.br`)).toEqual(brotliAt(dictBytes, 11));
|
|
38
|
+
const chunkPath = path.join(artifactDir, "client/chunk.js");
|
|
39
|
+
expect(await readFile(`${chunkPath}.br`)).toEqual(brotliAt(await readFile(chunkPath), 9));
|
|
40
|
+
expect(await Bun.file(`${dictPath}.gz`).exists()).toBe(true);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
@@ -6,8 +6,10 @@ import { FontPruner } from "./fontPruner";
|
|
|
6
6
|
|
|
7
7
|
const COMPRESSIBLE_EXTS = new Set([".css", ".html", ".js", ".json", ".svg"]);
|
|
8
8
|
const MIN_COMPRESS_BYTES = 1024;
|
|
9
|
-
// Brotli 11 is ~100x slower than gzip: worth it for the one CSS asset per basePath
|
|
10
|
-
|
|
9
|
+
// Brotli 11 is ~100x slower than gzip: worth it for the one CSS asset per basePath and one dictionary per locale, not
|
|
10
|
+
// for hundreds of JS chunks.
|
|
11
|
+
const MAX_BROTLI_QUALITY = 11;
|
|
12
|
+
const BROTLI_QUALITY_BY_EXT = { ".css": MAX_BROTLI_QUALITY } as const;
|
|
11
13
|
const DEFAULT_BROTLI_QUALITY = 9;
|
|
12
14
|
const GZIP_LEVEL = 9;
|
|
13
15
|
const { formatBytes } = FontPruner;
|
|
@@ -20,14 +22,16 @@ export interface PrecompressArtifactsResult {
|
|
|
20
22
|
}
|
|
21
23
|
|
|
22
24
|
export async function precompressArtifacts(app: App): Promise<PrecompressArtifactsResult> {
|
|
23
|
-
//* WebRouter serves client/ and
|
|
25
|
+
//* WebRouter serves client/, styles/ and dict/ through the same sidecar-aware #fileResponse.
|
|
26
|
+
const artifactDir = path.join(app.dist.cwdPath, ".akan/artifact");
|
|
24
27
|
const roots = [
|
|
25
|
-
path.join(
|
|
26
|
-
path.join(
|
|
28
|
+
{ root: path.join(artifactDir, "client") },
|
|
29
|
+
{ root: path.join(artifactDir, "styles") },
|
|
30
|
+
{ root: path.join(artifactDir, "dict"), brotliQuality: MAX_BROTLI_QUALITY },
|
|
27
31
|
];
|
|
28
32
|
const result: PrecompressArtifactsResult = { files: 0, inputBytes: 0, outputBytes: 0, brotliBytes: 0 };
|
|
29
33
|
|
|
30
|
-
await Promise.all(roots.map((root) => precompressRoot(root, result)));
|
|
34
|
+
await Promise.all(roots.map(({ root, brotliQuality }) => precompressRoot(root, result, brotliQuality)));
|
|
31
35
|
if (result.files > 0) {
|
|
32
36
|
app.verbose(
|
|
33
37
|
`[precompress] wrote ${result.files} sidecars (${formatBytes(result.inputBytes)} -> gzip ${formatBytes(
|
|
@@ -38,7 +42,11 @@ export async function precompressArtifacts(app: App): Promise<PrecompressArtifac
|
|
|
38
42
|
return result;
|
|
39
43
|
}
|
|
40
44
|
|
|
41
|
-
async function precompressRoot(
|
|
45
|
+
async function precompressRoot(
|
|
46
|
+
root: string,
|
|
47
|
+
result: PrecompressArtifactsResult,
|
|
48
|
+
brotliQuality?: number,
|
|
49
|
+
): Promise<void> {
|
|
42
50
|
if (!fs.existsSync(root) || !fs.statSync(root).isDirectory()) return;
|
|
43
51
|
const glob = new Bun.Glob("**/*");
|
|
44
52
|
for await (const filePath of glob.scan({ cwd: root, absolute: true })) {
|
|
@@ -46,7 +54,7 @@ async function precompressRoot(root: string, result: PrecompressArtifactsResult)
|
|
|
46
54
|
const bytes = await Bun.file(filePath).bytes();
|
|
47
55
|
const buffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
|
|
48
56
|
const gz = Bun.gzipSync(buffer, { level: GZIP_LEVEL });
|
|
49
|
-
const br = brotliCompress(buffer, path.extname(filePath).toLowerCase());
|
|
57
|
+
const br = brotliCompress(buffer, brotliQuality ?? brotliQualityOf(path.extname(filePath).toLowerCase()));
|
|
50
58
|
await Promise.all([Bun.write(`${filePath}.gz`, gz), Bun.write(`${filePath}.br`, br)]);
|
|
51
59
|
result.files += 1;
|
|
52
60
|
result.inputBytes += bytes.byteLength;
|
|
@@ -55,8 +63,11 @@ async function precompressRoot(root: string, result: PrecompressArtifactsResult)
|
|
|
55
63
|
}
|
|
56
64
|
}
|
|
57
65
|
|
|
58
|
-
function
|
|
59
|
-
|
|
66
|
+
function brotliQualityOf(ext: string): number {
|
|
67
|
+
return BROTLI_QUALITY_BY_EXT[ext as keyof typeof BROTLI_QUALITY_BY_EXT] ?? DEFAULT_BROTLI_QUALITY;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function brotliCompress(buffer: ArrayBuffer, quality: number): Buffer {
|
|
60
71
|
return zlib.brotliCompressSync(buffer, {
|
|
61
72
|
params: {
|
|
62
73
|
[zlib.constants.BROTLI_PARAM_QUALITY]: quality,
|
|
@@ -169,6 +169,7 @@ export class RouteClientBuilder {
|
|
|
169
169
|
plugins: [
|
|
170
170
|
await createBarrelImportsPlugin(this.#app, {
|
|
171
171
|
pipeAfter: reactFastRefresh ? RouteClientBuilder.normalizeNamedDefaultFunctionForFastRefresh : undefined,
|
|
172
|
+
external: VENDOR_SPECIFIERS,
|
|
172
173
|
}),
|
|
173
174
|
],
|
|
174
175
|
external: VENDOR_SPECIFIERS,
|
|
@@ -8,6 +8,7 @@ import { computeRouteSeedIndex, type RouteSeedIndex, saveRouteSeedIndex } from "
|
|
|
8
8
|
import type { App } from "../commandDecorators";
|
|
9
9
|
import { ClientEntriesBundler } from "./clientEntriesBundler";
|
|
10
10
|
import { CssCompiler, type ImportedStylesheet } from "./cssCompiler";
|
|
11
|
+
import { DictionaryAssetBuilder } from "./dictionaryAssetBuilder";
|
|
11
12
|
import { FontOptimizer } from "./fontOptimizer";
|
|
12
13
|
import { PagesBundleBuilder } from "./pagesBundleBuilder";
|
|
13
14
|
import { RouteClientBuilder } from "./routeClientBuilder";
|
|
@@ -56,6 +57,7 @@ export class SsrBaseArtifactBuilder {
|
|
|
56
57
|
|
|
57
58
|
const pageEntries = await resolveSsrPageEntriesForApp(this.#app, pageKeys);
|
|
58
59
|
const { cssCompiler, optimizedFonts, cssAssets } = await this.#buildStyleAssets();
|
|
60
|
+
const dictAssets = await new DictionaryAssetBuilder(this.#app, this.#absArtifactDir).build(akanConfig.i18n);
|
|
59
61
|
const pagesBundle = await new PagesBundleBuilder(this.#app, this.#command, pageEntries, this.#serverEnv).build();
|
|
60
62
|
this.#app.verbose(
|
|
61
63
|
`[base-artifact] pages bundle -> ${pagesBundle.bundlePath} (buildId=${pagesBundle.buildId}, splitting=${pagesBundle.splitting}, entry=${pagesBundle.entryBytes} bytes, outputs=${pagesBundle.outputCount}, chunks=${pagesBundle.chunkCount}, total=${pagesBundle.outputBytes} bytes)`,
|
|
@@ -75,6 +77,7 @@ export class SsrBaseArtifactBuilder {
|
|
|
75
77
|
rscRuntimeSsrManifest,
|
|
76
78
|
vendorMap,
|
|
77
79
|
cssAssets,
|
|
80
|
+
dictAssets,
|
|
78
81
|
pagesBundlePath:
|
|
79
82
|
this.#command === "build"
|
|
80
83
|
? path.relative(this.#absArtifactDir, pagesBundle.bundlePath)
|
package/index.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { cp, mkdir, readdir, rm, writeFile } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import type { ServerPolicy } from "@akanjs/native/api";
|
|
4
3
|
import { toError } from "akanjs/common";
|
|
4
|
+
import type { ServerPolicy } from "akanjs/native/api";
|
|
5
5
|
import type { AkanAppConfig, NativeEnv } from "../akanConfig";
|
|
6
6
|
import type { App } from "../commandDecorators";
|
|
7
7
|
import type { PackageJson } from "../types";
|
package/mobile/nativeApi.ts
CHANGED
package/mobile/nativeApp.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
|
-
import type { AkanNativeConfig } from "@akanjs/native/config";
|
|
3
2
|
import type { NativeEnv } from "akanjs";
|
|
4
3
|
import { toError } from "akanjs/common";
|
|
4
|
+
import type { AkanNativeConfig } from "akanjs/native/config";
|
|
5
5
|
import type { App } from "../commandDecorators";
|
|
6
6
|
import { Executor } from "../executors";
|
|
7
7
|
import { type DesktopArch, DesktopBin } from "./desktopBin";
|
package/mobile/nativeConfig.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import type { AkanNativeConfig } from "@akanjs/native/config";
|
|
4
3
|
import type {
|
|
5
4
|
AkanNativeDmgConfig,
|
|
6
5
|
AkanNativeTarget,
|
|
@@ -10,6 +9,7 @@ import type {
|
|
|
10
9
|
NativePermission,
|
|
11
10
|
} from "akanjs";
|
|
12
11
|
import { trimSlashes } from "akanjs/common";
|
|
12
|
+
import type { AkanNativeConfig } from "akanjs/native/config";
|
|
13
13
|
import type { DesktopServerBundle } from "./desktopServerStage";
|
|
14
14
|
import { type NativePlatform, resolveAppId } from "./mobileTarget";
|
|
15
15
|
import type { NativePluginFolder } from "./nativePluginFolders";
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { mkdtemp, rm } from "node:fs/promises";
|
|
2
2
|
import os from "node:os";
|
|
3
3
|
import path from "node:path";
|
|
4
|
-
import type { BundleInfo, UpdateManifest } from "@akanjs/native/api";
|
|
5
4
|
import type { NativeEnv } from "akanjs";
|
|
5
|
+
import type { BundleInfo, UpdateManifest } from "akanjs/native/api";
|
|
6
6
|
import { ExitCodeError } from "../commandDecorators/exitCodeError";
|
|
7
7
|
import type { MobilePlatform } from "./mobileTarget";
|
|
8
8
|
|
package/mobile/updateUploader.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { readdir } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import type { UpdateEnvelope, UpdateManifest } from "@akanjs/native/api";
|
|
4
3
|
import { isRecord, trimTrailingSlashes } from "akanjs/common";
|
|
4
|
+
import type { UpdateEnvelope, UpdateManifest } from "akanjs/native/api";
|
|
5
5
|
import { UpdatePackFolder } from "./updatePackFolder";
|
|
6
6
|
|
|
7
7
|
/** What the uploader needs of an S3 client; Bun.S3Client is one. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@akanjs/devkit",
|
|
3
|
-
"version": "3.1.
|
|
3
|
+
"version": "3.1.1-rc.1",
|
|
4
4
|
"sourceType": "module",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"publishConfig": {
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
"@earendil-works/pi-tui": "1.3.0",
|
|
49
49
|
"@inquirer/prompts": "^8.7.3",
|
|
50
50
|
"@tailwindcss/node": "^4.3.3",
|
|
51
|
-
"akanjs": "3.1.
|
|
51
|
+
"akanjs": "3.1.1-rc.1",
|
|
52
52
|
"chalk": "^6.0.1",
|
|
53
53
|
"commander": "^15.0.0",
|
|
54
54
|
"dayjs": "^1.11.23",
|