@akanjs/devkit 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.
@@ -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 ?? true}
247
+ wsConnect={userLayout.wsConnect ?? inheritedLayout.wsConnect}
248
248
  allDictionary={process.env.AKAN_PUBLIC_RENDER_ENV === "ssr" ? allDictionary : undefined}
249
249
  >
250
250
  ${layoutChild}
@@ -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, not for hundreds of JS chunks.
10
- const BROTLI_QUALITY_BY_EXT = { ".css": 11 } as const;
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 styles/ through the same sidecar-aware #fileResponse.
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(app.dist.cwdPath, ".akan/artifact/client"),
26
- path.join(app.dist.cwdPath, ".akan/artifact/styles"),
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(root: string, result: PrecompressArtifactsResult): Promise<void> {
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 brotliCompress(buffer: ArrayBuffer, ext: string): Buffer {
59
- const quality = BROTLI_QUALITY_BY_EXT[ext as keyof typeof BROTLI_QUALITY_BY_EXT] ?? DEFAULT_BROTLI_QUALITY;
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)
@@ -12,6 +12,7 @@ export const VENDOR_SPECIFIERS = [
12
12
  "akanjs/common",
13
13
  "akanjs/constant",
14
14
  "akanjs/fetch",
15
+ "tailwind-merge",
15
16
  ] as const;
16
17
 
17
18
  export type VendorSpecifier = (typeof VENDOR_SPECIFIERS)[number];
@@ -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";
@@ -1,5 +1,5 @@
1
1
  import path from "node:path";
2
- import type * as NativeBuildApi from "@akanjs/native/api";
2
+ import type * as NativeBuildApi from "akanjs/native/api";
3
3
 
4
4
  export type NativeBuildApiModule = typeof NativeBuildApi;
5
5
 
@@ -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";
@@ -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
 
@@ -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.0",
3
+ "version": "3.1.1-rc.0",
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.0",
51
+ "akanjs": "3.1.1-rc.0",
52
52
  "chalk": "^6.0.1",
53
53
  "commander": "^15.0.0",
54
54
  "dayjs": "^1.11.23",
@@ -10,14 +10,16 @@ export interface BarrelImportsPluginOptions {
10
10
  skipPath?: (absPath: string) => boolean;
11
11
  /** Runs after the rewrite in the same onLoad (Bun's onLoad cannot fall through); null means no change. */
12
12
  pipeAfter?: (source: string, args: { path: string }) => string | Promise<string | null> | null;
13
+ /** Barrels the bundle leaves external: a rewritten import would inline the modules the import map already serves. */
14
+ external?: readonly string[];
13
15
  }
14
16
 
15
17
  export const createBarrelImportsPlugin = async (
16
18
  app: App,
17
- { skipPath = defaultSkipPath, pipeAfter }: BarrelImportsPluginOptions = {},
19
+ { skipPath = defaultSkipPath, pipeAfter, external = [] }: BarrelImportsPluginOptions = {},
18
20
  ): Promise<BunPlugin> => {
19
21
  const akanConfig = await app.getConfig();
20
- const barrels = [...new Set(akanConfig.barrelImports)].filter(Boolean);
22
+ const barrels = [...new Set(akanConfig.barrelImports)].filter((barrel) => barrel && !external.includes(barrel));
21
23
  const analyzer = new BarrelAnalyzer({
22
24
  resolvePackage: await createTsconfigPackageResolver(app),
23
25
  });