@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.
@@ -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}
@@ -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
- try {
217
- return this.#authorize(await this.#api.get<AccessTokenDto>(`/getRemoteAuthToken/${remoteId}`));
218
- } catch (_) {
219
- return null;
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
- try {
246
- return await this.#api.get<{ id: string; nickname: string }>(`/getRemoteSelf`);
247
- } catch {
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, 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];
package/index.ts CHANGED
@@ -31,5 +31,4 @@ export type * from "./stripAnsi";
31
31
  export type * from "./transforms";
32
32
  export type * from "./typeChecker";
33
33
  export type * from "./types";
34
- export type * from "./uploadRelease";
35
34
  export type * from "./workflow";
@@ -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.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.0",
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",