@akanjs/devkit 3.0.0-alpha.3 → 3.0.0-alpha.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.ko.md +1 -1
  2. package/README.md +1 -1
  3. package/agentsIndex.test.ts +10 -0
  4. package/agentsIndex.ts +47 -1
  5. package/aiEditor.ts +1 -1
  6. package/akanConfig/akanConfig.test.ts +0 -10
  7. package/akanConfig/akanConfig.ts +1 -11
  8. package/akanContext.ts +43 -31
  9. package/applicationBuildRunner.test.ts +1 -1
  10. package/applicationBuildRunner.ts +1 -2
  11. package/biome.base.json +317 -0
  12. package/biomeBase.ts +9 -0
  13. package/capacitorApp.test.ts +8 -0
  14. package/capacitorApp.ts +112 -18
  15. package/executors.test.ts +29 -5
  16. package/executors.ts +11 -4
  17. package/frontendBuild/allRoutesBuilder.ts +27 -8
  18. package/frontendBuild/buildRouteClient.test.ts +28 -2
  19. package/frontendBuild/clientBuildTypes.ts +4 -0
  20. package/frontendBuild/clientEntriesBundler.ts +4 -1
  21. package/frontendBuild/cssCandidateCache.test.ts +38 -0
  22. package/frontendBuild/cssCandidateCache.ts +15 -3
  23. package/frontendBuild/cssCompiler.ts +122 -11
  24. package/frontendBuild/cssImportResolver.ts +8 -7
  25. package/frontendBuild/frontendBuild.test.ts +88 -1
  26. package/frontendBuild/precompressArtifacts.ts +35 -7
  27. package/frontendBuild/routeClientBuilder.ts +26 -7
  28. package/frontendBuild/ssrBaseArtifactBuilder.ts +20 -4
  29. package/frontendBuild/vendorSpecifiers.ts +1 -0
  30. package/getCredentials.ts +1 -3
  31. package/incrementalBuilder/devWatchBatch.test.ts +18 -20
  32. package/incrementalBuilder/incrementalBuilder.host.ts +1 -1
  33. package/incrementalBuilder/incrementalBuilder.proc.ts +2 -2
  34. package/integration/devStabilityHarness.ts +1 -5
  35. package/lint/no-bang-comment-in-client.grit +20 -0
  36. package/lint/no-import-client-in-server.grit +48 -0
  37. package/lint/no-import-server-in-client.grit +45 -0
  38. package/lint/no-model-type-in-util-zone.grit +16 -0
  39. package/lint/no-return-in-store-action.grit +35 -0
  40. package/linter.ts +17 -12
  41. package/package.json +4 -4
  42. package/qualityScanner.test.ts +23 -0
  43. package/qualityScanner.ts +5 -44
  44. package/repoIdentity.ts +42 -0
  45. package/routeSourceValidator.test.ts +41 -0
  46. package/routeSourceValidator.ts +2 -44
  47. package/scanInfo.ts +2 -43
  48. package/transforms/externalizeFrameworkPlugin.ts +0 -1
  49. package/workspaceLayout.test.ts +26 -0
  50. package/workspaceLayout.ts +60 -0
@@ -17,6 +17,12 @@ interface CssDiscovery {
17
17
  sourcePaths: string[];
18
18
  }
19
19
 
20
+ /** One `@import` target and the custom properties it declares, which is what proves it arrived downstream. */
21
+ export interface ImportedStylesheet {
22
+ cssPath: string;
23
+ declaredNames: string[];
24
+ }
25
+
20
26
  export class CssCompiler {
21
27
  #logger = new Logger("CssCompiler");
22
28
  #transpiler = new Bun.Transpiler({ loader: "tsx" });
@@ -46,6 +52,13 @@ export class CssCompiler {
46
52
  #fileExistsCache = new Map<string, Promise<boolean>>();
47
53
  #resolvedFileCache = new Map<string, Promise<string | null>>();
48
54
  #resolvedSpecifierCache = new Map<string, Promise<string | null>>();
55
+ /** Every stylesheet this compile reached, entry points and `@import` targets alike. */
56
+ #discoveredCssPaths = new Set<string>();
57
+ /**
58
+ * `@import` targets per base path, so whoever writes the asset can check the file it actually wrote — the
59
+ * compiled text and the written artifact are two different places a declaration can go missing.
60
+ */
61
+ importedStylesheetsByBasePath: Record<string, ImportedStylesheet[]> = {};
49
62
 
50
63
  #fileExists(absPath: string): Promise<boolean> {
51
64
  let cached = this.#fileExistsCache.get(absPath);
@@ -76,13 +89,20 @@ export class CssCompiler {
76
89
  }
77
90
  async getCss({ refresh }: { refresh?: boolean } = {}) {
78
91
  if (this.#cssText !== null && !refresh) return this.#cssText;
92
+ this.#discoveredCssPaths.clear();
93
+ this.importedStylesheetsByBasePath = {};
79
94
  const { cssPaths, sourcePaths } = await this.discoverCssAndSources({ refresh });
80
- this.#cssText = await this.compileCss(cssPaths, sourcePaths);
95
+ const { css, imported } = await this.#compileWithImports(cssPaths, sourcePaths);
96
+ this.#cssText = css;
97
+ this.importedStylesheetsByBasePath = { "": imported };
98
+ await this.#warnUnreachableStylesheets();
81
99
  return this.#cssText;
82
100
  }
83
101
 
84
102
  async getCssByBasePath({ refresh }: { refresh?: boolean } = {}): Promise<Record<string, string>> {
85
103
  if (this.#cssTextByBasePath !== null && !refresh) return this.#cssTextByBasePath;
104
+ this.#discoveredCssPaths.clear();
105
+ this.importedStylesheetsByBasePath = {};
86
106
  const akanConfig = await this.#app.getConfig({ refresh });
87
107
  const pageKeys = await this.#app.getPageKeys({ refresh });
88
108
  const basePaths = [...akanConfig.basePaths];
@@ -92,7 +112,8 @@ export class CssCompiler {
92
112
  if (rootPageKeys.length === 0) return ["", ""] as const;
93
113
  const started = Date.now();
94
114
  const { cssPaths, sourcePaths } = await this.discoverCssAndSources({ refresh, pageKeys: rootPageKeys });
95
- const css = await this.compileCss(cssPaths, sourcePaths);
115
+ const { css, imported } = await this.#compileWithImports(cssPaths, sourcePaths);
116
+ this.importedStylesheetsByBasePath[""] = imported;
96
117
  this.#logger.verbose(
97
118
  `css base=root paths=${cssPaths.length} sources=${sourcePaths.length} in ${Date.now() - started}ms`,
98
119
  );
@@ -103,7 +124,8 @@ export class CssCompiler {
103
124
  if (basePathPageKeys.length === 0) return [basePath, ""] as const;
104
125
  const started = Date.now();
105
126
  const { cssPaths, sourcePaths } = await this.discoverCssAndSources({ refresh, pageKeys: basePathPageKeys });
106
- const css = await this.compileCss(cssPaths, sourcePaths);
127
+ const { css, imported } = await this.#compileWithImports(cssPaths, sourcePaths);
128
+ this.importedStylesheetsByBasePath[basePath] = imported;
107
129
  this.#logger.verbose(
108
130
  `css base=${basePath} paths=${cssPaths.length} sources=${sourcePaths.length} in ${Date.now() - started}ms`,
109
131
  );
@@ -111,9 +133,26 @@ export class CssCompiler {
111
133
  }),
112
134
  ]);
113
135
  this.#cssTextByBasePath = Object.fromEntries(cssEntries);
136
+ await this.#warnUnreachableStylesheets();
114
137
  return this.#cssTextByBasePath;
115
138
  }
116
139
 
140
+ /**
141
+ * A stylesheet under `page/` reaches the build only by being imported from a route source. One that nothing
142
+ * imports compiles to nothing and reports success, which is indistinguishable from an empty theme — so say it
143
+ * out loud once per compile rather than leaving it to be noticed as unstyled elements in the browser.
144
+ */
145
+ async #warnUnreachableStylesheets() {
146
+ const pageDir = path.join(this.#app.cwdPath, "page");
147
+ const glob = new Bun.Glob("**/*.css");
148
+ for await (const cssPath of glob.scan({ cwd: pageDir, absolute: true })) {
149
+ // `(libs)` is a link farm: the same file is discovered under its real path in `libs/`, never this one.
150
+ if (cssPath.includes(`${path.sep}(libs)${path.sep}`)) continue;
151
+ if (this.#discoveredCssPaths.has(cssPath)) continue;
152
+ this.#logger.warn(`css ${path.relative(this.#app.cwdPath, cssPath)} is imported by no route and never compiled`);
153
+ }
154
+ }
155
+
117
156
  async discoverCss({ refresh }: { refresh?: boolean } = {}): Promise<string[]> {
118
157
  const { cssPaths } = await this.discoverCssAndSources({ refresh });
119
158
  return cssPaths;
@@ -180,22 +219,61 @@ export class CssCompiler {
180
219
  }
181
220
  }
182
221
 
183
- return { cssPaths: [...cssFiles], sourcePaths: [...sourceFiles] };
222
+ const tokenPaths = await this.#libTokenStylesheets(sourceFiles);
223
+ const cssPaths = [...new Set([...tokenPaths, ...cssFiles])];
224
+ for (const cssPath of cssPaths) this.#discoveredCssPaths.add(cssPath);
225
+ return { cssPaths, sourcePaths: [...sourceFiles] };
226
+ }
227
+
228
+ /**
229
+ * `libs/<lib>/ui/tokens.css` of every lib the page graph reached, so a lib can own the fixed colours its own
230
+ * components need instead of each consuming app re-declaring them. Ordered ahead of the app's stylesheets:
231
+ * the app is the last word on any variable both declare.
232
+ */
233
+ async #libTokenStylesheets(sourceFiles: Set<string>): Promise<string[]> {
234
+ const libsRoot = path.join(this.#app.workspace.workspaceRoot, "libs");
235
+ const libNames = new Set<string>();
236
+ for (const filePath of sourceFiles) {
237
+ const relPath = path.relative(libsRoot, filePath);
238
+ if (relPath.startsWith("..") || path.isAbsolute(relPath)) continue;
239
+ const [libName] = relPath.split(path.sep);
240
+ if (libName) libNames.add(libName);
241
+ }
242
+ const tokenPaths = await Promise.all(
243
+ [...libNames].sort().map(async (libName) => {
244
+ const tokensPath = path.join(libsRoot, libName, "ui/tokens.css");
245
+ return (await this.#fileExists(tokensPath)) ? tokensPath : null;
246
+ }),
247
+ );
248
+ return tokenPaths.filter((tokensPath): tokensPath is string => !!tokensPath);
184
249
  }
185
250
  async compileCss(cssPaths: string[], sourcePaths: string[]): Promise<string> {
186
- if (cssPaths.length === 0) return "";
251
+ const { css } = await this.#compileWithImports(cssPaths, sourcePaths);
252
+ return css;
253
+ }
254
+
255
+ /**
256
+ * The collector is per compile rather than per compiler instance: `getCssByBasePath` compiles every base path
257
+ * concurrently, so instance state would mix one base path's imports into another's check.
258
+ */
259
+ async #compileWithImports(
260
+ cssPaths: string[],
261
+ sourcePaths: string[],
262
+ ): Promise<{ css: string; imported: ImportedStylesheet[] }> {
263
+ if (cssPaths.length === 0) return { css: "", imported: [] };
187
264
 
188
265
  const compileStarted = Date.now();
189
266
  const compilers = await Promise.all(
190
267
  cssPaths.map(async (cssPath) => {
191
268
  const css = await Bun.file(cssPath).text();
192
269
  const base = path.dirname(cssPath);
270
+ const imported = new Map<string, string>();
193
271
  const compiler = await compile(css, {
194
272
  base,
195
- loadStylesheet: (id, fromBase) => this.#loadStylesheet(id, fromBase),
273
+ loadStylesheet: (id, fromBase) => this.#loadStylesheet(id, fromBase, imported),
196
274
  loadModule: (id, fromBase) => this.#loadModule(id, fromBase),
197
275
  });
198
- return { cssPath, compiler };
276
+ return { cssPath, compiler, imported };
199
277
  }),
200
278
  );
201
279
 
@@ -210,24 +288,46 @@ export class CssCompiler {
210
288
  `css candidates scanned count=${candidates.length} sources=${sourcePaths.length} dirs=${sourceDirs.size} in ${Date.now() - scanStarted}ms`,
211
289
  );
212
290
  const parts: string[] = [];
291
+ const imported: ImportedStylesheet[] = [];
213
292
  for (const entry of compilers) {
214
293
  if (!entry) continue;
215
- parts.push(entry.compiler.build(candidates));
294
+ const part = entry.compiler.build(candidates);
295
+ parts.push(part);
296
+ for (const [cssPath, content] of entry.imported) {
297
+ const declaredNames = declaredCustomProperties(content);
298
+ imported.push({ cssPath, declaredNames });
299
+ if (declaredNames.length === 0 || declaredNames.some((name) => part.includes(`${name}:`))) continue;
300
+ this.#logger.warn(
301
+ `css @import ${cssPath} was loaded by ${entry.cssPath} but none of its ${declaredNames.length} declaration(s) reached the compiled CSS`,
302
+ );
303
+ }
216
304
  }
217
305
  this.#logger.verbose(
218
306
  `css compiled paths=${cssPaths.length} candidates=${candidates.length} in ${Date.now() - compileStarted}ms`,
219
307
  );
220
- return parts.join("\n");
308
+ return { css: parts.join("\n"), imported };
221
309
  }
222
310
 
223
- async #loadStylesheet(id: string, fromBase: string) {
311
+ async #loadStylesheet(id: string, fromBase: string, imported?: Map<string, string>) {
224
312
  const p = await this.#resolveCssImport(id, fromBase);
313
+ this.#discoveredCssPaths.add(p);
225
314
  const content = await Bun.file(p).text();
315
+ imported?.set(p, content);
316
+ this.#logger.verbose(`css import "${id}" from ${fromBase} -> ${p} (${content.length} bytes)`);
226
317
  return { path: p, base: path.dirname(p), content };
227
318
  }
228
319
 
320
+ /**
321
+ * Every specifier is verified here, path-shaped ones included. An `@import` the pipeline cannot resolve is
322
+ * a build error and never a no-op: the vocabulary closure means a component whose token declaration failed
323
+ * to load renders unstyled, which nothing downstream can distinguish from a design choice.
324
+ */
229
325
  async #resolveCssImport(id: string, fromBase: string): Promise<string> {
230
- if (id.startsWith(".") || id.startsWith("/")) return path.resolve(fromBase, id);
326
+ if (id.startsWith(".") || id.startsWith("/")) {
327
+ const filePath = path.resolve(fromBase, id);
328
+ if (await this.#fileExists(filePath)) return filePath;
329
+ throw new Error(`[css] failed to resolve stylesheet import "${id}" from ${fromBase} (no file at ${filePath})`);
330
+ }
231
331
  const resolver = await this.#getCssImportResolver();
232
332
  const resolved = await resolver.resolve(id, fromBase);
233
333
  if (resolved) return resolved;
@@ -330,6 +430,17 @@ export function isIgnoredNodeModuleSource(filePath: string): boolean {
330
430
  return NODE_MODULES_RE.test(filePath) && !AKANJS_NODE_MODULE_RE.test(filePath);
331
431
  }
332
432
 
433
+ /**
434
+ * `@theme` blocks are stripped first: those variables are emitted only when a utility uses one, so their
435
+ * absence from a build says nothing about whether the stylesheet arrived.
436
+ */
437
+ export function declaredCustomProperties(css: string): string[] {
438
+ const withoutThemeBlocks = css.replace(/@theme[^{]*\{[^}]*\}/g, "");
439
+ return [...new Set([...withoutThemeBlocks.matchAll(/(?:^|[\s;{])(--[\w-]+)\s*:/g)].map(([, name]) => name))].filter(
440
+ (name): name is string => !!name,
441
+ );
442
+ }
443
+
333
444
  function getPageKeyBasePath(pageKey: string, basePaths: string[]): string | null {
334
445
  const normalized = pageKey.split(path.sep).join("/").replace(/^\.\//, "");
335
446
  const segments = normalized.split("/");
@@ -106,14 +106,15 @@ export class CssImportResolver {
106
106
  const pkg = await Bun.file(pkgPath).json();
107
107
  const subpath = id === pkgName ? "." : `.${id.slice(pkgName.length)}`;
108
108
  const exportValue = pkg.exports?.[subpath];
109
- const styleEntry =
110
- (typeof exportValue === "string"
109
+ const exportedEntry =
110
+ typeof exportValue === "string"
111
111
  ? exportValue
112
- : exportValue?.style || exportValue?.import || exportValue?.default) ||
113
- pkg.exports?.["."]?.style ||
114
- pkg.style ||
115
- "index.css";
116
- return await this.#firstExisting(path.resolve(pkgDir, styleEntry));
112
+ : exportValue?.style || exportValue?.import || exportValue?.default;
113
+ if (exportedEntry) return await this.#firstExisting(path.resolve(pkgDir, exportedEntry));
114
+ //* A subpath names a file inside the package, so it resolves literally. Falling back to the package's own
115
+ //* style entry here would load a different stylesheet than the author asked for and report success.
116
+ if (subpath !== ".") return await this.#firstExisting(path.resolve(pkgDir, subpath));
117
+ return await this.#firstExisting(path.resolve(pkgDir, pkg.exports?.["."]?.style || pkg.style || "index.css"));
117
118
  } catch {
118
119
  return null;
119
120
  }
@@ -5,7 +5,7 @@ import path from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import type { RoutesManifest } from "akanjs/server";
7
7
  import { CsrArtifactBuilder } from "./csrArtifactBuilder";
8
- import { CssCompiler, isIgnoredNodeModuleSource } from "./cssCompiler";
8
+ import { CssCompiler, declaredCustomProperties, isIgnoredNodeModuleSource } from "./cssCompiler";
9
9
  import { CssImportResolver } from "./cssImportResolver";
10
10
  import { DevChangePlanner } from "./devChangePlanner";
11
11
  import { DevGeneratedIndexSync } from "./devGeneratedIndexSync";
@@ -391,6 +391,20 @@ describe("CssImportResolver", () => {
391
391
  expect(await resolver.resolve("@libs/ui/missing", root)).toBeNull();
392
392
  });
393
393
 
394
+ test("never substitutes the package stylesheet for a subpath that does not exist", async () => {
395
+ const root = await makeTempRoot();
396
+ await write(
397
+ path.join(root, "node_modules/vendor/package.json"),
398
+ JSON.stringify({ name: "vendor", style: "index.css" }),
399
+ );
400
+ await write(path.join(root, "node_modules/vendor/index.css"), ".vendor {}\n");
401
+
402
+ const resolver = new CssImportResolver(root, {});
403
+
404
+ expect(await resolver.resolve("vendor", root)).toBe(path.join(root, "node_modules/vendor/index.css"));
405
+ expect(await resolver.resolve("vendor/ui/tokens.css", root)).toBeNull();
406
+ });
407
+
394
408
  test("resolves css from single-package Akan workspace subpaths", async () => {
395
409
  const root = await makeTempRoot();
396
410
  await write(path.join(root, "pkgs/akanjs/ui/styles.css"), "body {}\n");
@@ -428,4 +442,77 @@ describe("CssCompiler", () => {
428
442
 
429
443
  expect(css).toContain(".text-fuchsia-500");
430
444
  });
445
+
446
+ test("reads declarations that prove a stylesheet arrived, ignoring theme variables that may not", () => {
447
+ expect(declaredCustomProperties(":root { --kakao: #fee500; --naver: #1ec800; }")).toEqual(["--kakao", "--naver"]);
448
+ expect(declaredCustomProperties("@theme inline {\n --color-brand: var(--brand);\n}\n")).toEqual([]);
449
+ expect(declaredCustomProperties("@theme { --color-x: initial; }\n:root { --brand: #111; }")).toEqual(["--brand"]);
450
+ expect(declaredCustomProperties(".a { color: var(--kakao); }")).toEqual([]);
451
+ expect(declaredCustomProperties(":root{--a:#111}\n@media (min-width:1px){:root{--a:#222;--b:#333}}")).toEqual([
452
+ "--a",
453
+ "--b",
454
+ ]);
455
+ });
456
+
457
+ test("fails loudly on a stylesheet import that resolves to nothing", async () => {
458
+ const root = await makeTempRoot();
459
+ const cssPath = path.join(root, "apps/demo/page/styles.css");
460
+ await write(cssPath, '@import "../../../libs/shared/ui/tokens.css";\n');
461
+
462
+ const compiler = new CssCompiler({
463
+ workspace: { workspaceRoot: root },
464
+ cwdPath: path.join(root, "apps/demo"),
465
+ getTsConfig: async () => ({ compilerOptions: { paths: {} } }),
466
+ } as never);
467
+
468
+ await expect(compiler.compileCss([cssPath], [])).rejects.toThrow(
469
+ /failed to resolve stylesheet import "\.\.\/\.\.\/\.\.\/libs\/shared\/ui\/tokens\.css"/,
470
+ );
471
+ });
472
+
473
+ test("reports every @import per base path so the written asset can be checked against it", async () => {
474
+ const root = await makeTempRoot();
475
+ const appDir = path.join(root, "apps/demo");
476
+ await write(path.join(appDir, "page/_index.tsx"), 'import "./styles.css";\nexport default () => null;\n');
477
+ await write(path.join(appDir, "page/styles.css"), '@import "../../../libs/shared/ui/brand.css";\n');
478
+ await write(path.join(root, "libs/shared/ui/brand.css"), ":root { --kakao: #fee500; --naver: #1ec800; }\n");
479
+
480
+ const compiler = new CssCompiler({
481
+ workspace: { workspaceRoot: root },
482
+ cwdPath: appDir,
483
+ getPageKeys: async () => ["./_index.tsx"],
484
+ getConfig: async () => ({ barrelImports: [], basePaths: [] }),
485
+ getTsConfig: async () => ({ compilerOptions: { paths: {} } }),
486
+ } as never);
487
+ const cssByBasePath = await compiler.getCssByBasePath();
488
+
489
+ expect(cssByBasePath[""]).toContain("--kakao");
490
+ expect(compiler.importedStylesheetsByBasePath[""]).toEqual([
491
+ { cssPath: path.join(root, "libs/shared/ui/brand.css"), declaredNames: ["--kakao", "--naver"] },
492
+ ]);
493
+ });
494
+
495
+ test("compiles lib-owned tokens ahead of the app stylesheets that may override them", async () => {
496
+ const root = await makeTempRoot();
497
+ const appDir = path.join(root, "apps/demo");
498
+ await write(
499
+ path.join(appDir, "page/_index.tsx"),
500
+ 'import "./styles.css";\nimport { Card } from "@libs/shared/ui";\nexport default () => <Card />;\n',
501
+ );
502
+ await write(path.join(appDir, "page/styles.css"), ":root { --brand: #111111; }\n");
503
+ await write(path.join(root, "libs/shared/ui/index.ts"), "export const Card = () => null;\n");
504
+ await write(path.join(root, "libs/shared/ui/tokens.css"), ":root { --kakao: #fee500; }\n");
505
+ await write(path.join(root, "libs/unused/ui/tokens.css"), ":root { --unused: #000000; }\n");
506
+
507
+ const compiler = new CssCompiler({
508
+ workspace: { workspaceRoot: root },
509
+ cwdPath: appDir,
510
+ getPageKeys: async () => ["./_index.tsx"],
511
+ getConfig: async () => ({ barrelImports: [] }),
512
+ getTsConfig: async () => ({ compilerOptions: { paths: { "@libs/*": ["./libs/*"] } } }),
513
+ } as never);
514
+ const { cssPaths } = await compiler.discoverCssAndSources();
515
+
516
+ expect(cssPaths).toEqual([path.join(root, "libs/shared/ui/tokens.css"), path.join(appDir, "page/styles.css")]);
517
+ });
431
518
  });
@@ -1,26 +1,41 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
+ import zlib from "node:zlib";
3
4
  import type { App } from "../commandDecorators";
4
5
 
5
6
  const COMPRESSIBLE_EXTS = new Set([".css", ".html", ".js", ".json", ".svg"]);
6
7
  const MIN_COMPRESS_BYTES = 1024;
8
+ /**
9
+ * Quality 11 is roughly 100x slower than gzip, so it is spent only where it pays: there is one CSS asset
10
+ * per basePath and it is the largest single file the app ships, worth ~20% over gzip for ~0.2s. Raising the
11
+ * several hundred JS chunks from 9 to 11 costs ~12s of build time to save a few hundred KB, so they stay at 9.
12
+ */
13
+ const BROTLI_QUALITY_BY_EXT = { ".css": 11 } as const;
14
+ const DEFAULT_BROTLI_QUALITY = 9;
15
+ const GZIP_LEVEL = 9;
7
16
 
8
17
  export interface PrecompressArtifactsResult {
9
18
  files: number;
10
19
  inputBytes: number;
11
20
  outputBytes: number;
21
+ brotliBytes: number;
12
22
  }
13
23
 
14
24
  export async function precompressArtifacts(app: App): Promise<PrecompressArtifactsResult> {
15
- const roots = [path.join(app.dist.cwdPath, ".akan/artifact/client")];
16
- const result: PrecompressArtifactsResult = { files: 0, inputBytes: 0, outputBytes: 0 };
25
+ //* styles too: WebRouter serves both prefixes through the same sidecar-aware #fileResponse, and the
26
+ //* one CSS asset per basePath is the largest uncompressed payload the app ships.
27
+ const roots = [
28
+ path.join(app.dist.cwdPath, ".akan/artifact/client"),
29
+ path.join(app.dist.cwdPath, ".akan/artifact/styles"),
30
+ ];
31
+ const result: PrecompressArtifactsResult = { files: 0, inputBytes: 0, outputBytes: 0, brotliBytes: 0 };
17
32
 
18
33
  await Promise.all(roots.map((root) => precompressRoot(root, result)));
19
34
  if (result.files > 0) {
20
35
  app.verbose(
21
- `[precompress] wrote ${result.files} gzip sidecars (${formatBytes(result.inputBytes)} -> ${formatBytes(
36
+ `[precompress] wrote ${result.files} sidecars (${formatBytes(result.inputBytes)} -> gzip ${formatBytes(
22
37
  result.outputBytes,
23
- )})`,
38
+ )} / br ${formatBytes(result.brotliBytes)})`,
24
39
  );
25
40
  }
26
41
  return result;
@@ -32,16 +47,29 @@ async function precompressRoot(root: string, result: PrecompressArtifactsResult)
32
47
  for await (const filePath of glob.scan({ cwd: root, absolute: true })) {
33
48
  if (!(await shouldPrecompress(filePath))) continue;
34
49
  const bytes = await Bun.file(filePath).bytes();
35
- const gz = Bun.gzipSync(toArrayBuffer(bytes));
36
- await Bun.write(`${filePath}.gz`, gz);
50
+ const buffer = toArrayBuffer(bytes);
51
+ const gz = Bun.gzipSync(buffer, { level: GZIP_LEVEL });
52
+ const br = brotliCompress(buffer, path.extname(filePath).toLowerCase());
53
+ await Promise.all([Bun.write(`${filePath}.gz`, gz), Bun.write(`${filePath}.br`, br)]);
37
54
  result.files += 1;
38
55
  result.inputBytes += bytes.byteLength;
39
56
  result.outputBytes += gz.byteLength;
57
+ result.brotliBytes += br.byteLength;
40
58
  }
41
59
  }
42
60
 
61
+ function brotliCompress(buffer: ArrayBuffer, ext: string): Buffer {
62
+ const quality = BROTLI_QUALITY_BY_EXT[ext as keyof typeof BROTLI_QUALITY_BY_EXT] ?? DEFAULT_BROTLI_QUALITY;
63
+ return zlib.brotliCompressSync(buffer, {
64
+ params: {
65
+ [zlib.constants.BROTLI_PARAM_QUALITY]: quality,
66
+ [zlib.constants.BROTLI_PARAM_SIZE_HINT]: buffer.byteLength,
67
+ },
68
+ });
69
+ }
70
+
43
71
  async function shouldPrecompress(filePath: string): Promise<boolean> {
44
- if (filePath.endsWith(".gz")) return false;
72
+ if (filePath.endsWith(".gz") || filePath.endsWith(".br")) return false;
45
73
  if (!COMPRESSIBLE_EXTS.has(path.extname(filePath).toLowerCase())) return false;
46
74
  const file = Bun.file(filePath);
47
75
  if (!(await file.exists())) return false;
@@ -4,7 +4,7 @@ import type { BaseBuildArtifact, ClientManifest, SsrManifest } from "akanjs/serv
4
4
  import type { App } from "../commandDecorators";
5
5
  import { createBarrelImportsPlugin } from "../transforms/barrelImportsPlugin";
6
6
  import { toClientReferencePath } from "../transforms/rscUseClientTransform";
7
- import type { ClientEntryDiscovery } from "./clientBuildTypes";
7
+ import type { ClientBundleTarget, ClientEntryDiscovery } from "./clientBuildTypes";
8
8
  import { ClientEntriesBundler } from "./clientEntriesBundler";
9
9
  import { GraphClientEntryDiscovery } from "./clientEntryDiscovery";
10
10
  import { VENDOR_SPECIFIERS } from "./vendorSpecifiers";
@@ -33,6 +33,15 @@ export interface BuildRouteClientOptions {
33
33
  routeId?: string;
34
34
  command?: "build" | "start";
35
35
  discovery?: ClientEntryDiscovery;
36
+ /**
37
+ * Client entries resolved by the caller, which skips discovery and bundles exactly this list.
38
+ *
39
+ * `AllRoutesBuilder` passes every route's entries at once so they share one `Bun.build`. Chunk
40
+ * splitting is scoped to a single invocation, so a dependency reachable from entries spread across
41
+ * several invocations is emitted once per invocation — mermaid landed in `apps/akan` four times that
42
+ * way. Dev keeps one build per route and leaves this unset.
43
+ */
44
+ entries?: string[];
36
45
  }
37
46
 
38
47
  export interface BuildRouteClientResult {
@@ -61,6 +70,7 @@ export class RouteClientBuilder {
61
70
  #knownEntries: Set<string>;
62
71
  #command: "build" | "start";
63
72
  #discovery?: ClientEntryDiscovery;
73
+ #entries?: string[];
64
74
 
65
75
  constructor(options: BuildRouteClientOptions) {
66
76
  this.#app = options.app;
@@ -68,11 +78,13 @@ export class RouteClientBuilder {
68
78
  this.#knownEntries = options.knownEntries ?? new Set<string>();
69
79
  this.#command = options.command ?? "start";
70
80
  this.#discovery = options.discovery;
81
+ this.#entries = options.entries;
71
82
  }
72
83
 
73
84
  async build(): Promise<BuildRouteClientResult> {
74
- const discovery = this.#discovery ?? (await GraphClientEntryDiscovery.create(this.#app));
75
- const discovered = await discovery.discover(this.#seeds);
85
+ const discovered =
86
+ this.#entries ??
87
+ (await (this.#discovery ?? (await GraphClientEntryDiscovery.create(this.#app))).discover(this.#seeds));
76
88
  const entries = discovered.filter((e) => !this.#knownEntries.has(e));
77
89
  if (entries.length === 0) return this.#emptyResult(discovered);
78
90
 
@@ -151,12 +163,11 @@ export class RouteClientBuilder {
151
163
  }
152
164
 
153
165
  async #buildSsrBundle(bootstrapEntries: BootstrapEntries) {
154
- const externalOptions = RouteClientBuilder.resolveSsrClientExternalOptions(this.#command);
155
166
  return new ClientEntriesBundler({
156
167
  app: this.#app,
157
168
  entries: bootstrapEntries.buildEntries,
158
169
  plugins: [await createBarrelImportsPlugin(this.#app)],
159
- ...externalOptions,
170
+ ...RouteClientBuilder.resolveSsrClientBundleOptions(this.#command),
160
171
  outputSubdir: "client-ssr",
161
172
  command: this.#command,
162
173
  }).bundle();
@@ -227,20 +238,28 @@ export class RouteClientBuilder {
227
238
  return { [Bun.resolveSync("akanjs/fetch", serverEntry)]: "akanjs/fetch" };
228
239
  }
229
240
 
230
- static resolveSsrClientExternalOptions(command: "build" | "start"): {
241
+ /**
242
+ * `target: "bun"` is load-bearing: these chunks are `await import()`-ed by the SSR renderer, so a dependency
243
+ * resolved through its `browser` export condition can touch `document` at module scope and throw mid-render,
244
+ * degrading the whole document to client rendering. Bun's `conditions` only adds to the target's defaults —
245
+ * `browser` still wins — so the target itself has to say server.
246
+ */
247
+ static resolveSsrClientBundleOptions(command: "build" | "start"): {
248
+ target: ClientBundleTarget;
231
249
  external: readonly string[];
232
250
  externalSubpaths?: readonly string[];
233
251
  externalAliases?: Record<string, string>;
234
252
  } {
235
253
  if (command === "start") {
236
254
  return {
255
+ target: "bun",
237
256
  external: SSR_CLIENT_EXTERNALS,
238
257
  externalSubpaths: ["akanjs/fetch"],
239
258
  externalAliases: RouteClientBuilder.resolveSsrClientRuntimeAliases(),
240
259
  };
241
260
  }
242
261
 
243
- return { external: SSR_CLIENT_ALIAS_EXTERNALS };
262
+ return { target: "bun", external: SSR_CLIENT_ALIAS_EXTERNALS };
244
263
  }
245
264
 
246
265
  static resolveAkanServerEntry(): string {
@@ -5,7 +5,7 @@ import { resolveSsrPageEntriesForApp } from "../artifact/implicitRootLayout";
5
5
  import { computeRouteSeedIndex, type RouteSeedIndex, saveRouteSeedIndex } from "../artifact/routeSeedIndex";
6
6
  import type { App } from "../commandDecorators";
7
7
  import { ClientEntriesBundler } from "./clientEntriesBundler";
8
- import { CssCompiler } from "./cssCompiler";
8
+ import { CssCompiler, type ImportedStylesheet } from "./cssCompiler";
9
9
  import { FontOptimizer } from "./fontOptimizer";
10
10
  import { PagesBundleBuilder } from "./pagesBundleBuilder";
11
11
  import { RouteClientBuilder } from "./routeClientBuilder";
@@ -110,7 +110,7 @@ export class SsrBaseArtifactBuilder {
110
110
  const ssrBundle = await new ClientEntriesBundler({
111
111
  app: this.#app,
112
112
  entries: [rscSegmentOutletEntry],
113
- ...RouteClientBuilder.resolveSsrClientExternalOptions(this.#command),
113
+ ...RouteClientBuilder.resolveSsrClientBundleOptions(this.#command),
114
114
  outputSubdir: "client-ssr",
115
115
  command: this.#command,
116
116
  }).bundle();
@@ -192,7 +192,7 @@ export class SsrBaseArtifactBuilder {
192
192
  Object.entries(cssByBasePath).flatMap(([basePath, baseCssText]) => {
193
193
  const cssText = [baseCssText, optimizedFonts.css].filter(Boolean).join("\n");
194
194
  if (!cssText) return [];
195
- return [this.#writeCssAsset(basePath, cssText)];
195
+ return [this.#writeCssAsset(basePath, cssText, cssCompiler.importedStylesheetsByBasePath[basePath] ?? [])];
196
196
  }),
197
197
  ),
198
198
  );
@@ -201,7 +201,7 @@ export class SsrBaseArtifactBuilder {
201
201
  return { cssCompiler, optimizedFonts, cssAssets };
202
202
  }
203
203
 
204
- async #writeCssAsset(basePath: string, cssText: string) {
204
+ async #writeCssAsset(basePath: string, cssText: string, imported: ImportedStylesheet[]) {
205
205
  const cssAssetName = basePath || "root";
206
206
  const preparedCssText = await prepareCssAsset(this.#command, basePath, cssText);
207
207
  const cssHash = Bun.hash(`${basePath}\n${preparedCssText}`).toString(36);
@@ -210,7 +210,23 @@ export class SsrBaseArtifactBuilder {
210
210
  `/_akan/styles/${cssAssetName}-${cssHash}.css`,
211
211
  ];
212
212
  await Bun.write(path.join(this.#absArtifactDir, cssRelPath), preparedCssText);
213
+ SsrBaseArtifactBuilder.#warnDroppedImports(this.#app, cssRelPath, preparedCssText, imported);
213
214
  this.#app.verbose(`[base-artifact] wrote ${preparedCssText.length} bytes of CSS for ${basePath} -> ${cssRelPath}`);
214
215
  return [basePath, { cssUrl, cssRelPath }] as const;
215
216
  }
217
+
218
+ /**
219
+ * Checked against the file written here rather than against the compiled text, because this is the stylesheet
220
+ * `base-artifact.json` points at and therefore the only one an SSR render serves. A declaration can survive
221
+ * the compile and still be missing from the asset — a build that ships CSS to the CSR bundle and not to the
222
+ * server is indistinguishable, in the browser, from a theme that was never written.
223
+ */
224
+ static #warnDroppedImports(app: App, cssRelPath: string, css: string, imported: ImportedStylesheet[]) {
225
+ for (const { cssPath, declaredNames } of imported) {
226
+ if (declaredNames.length === 0 || declaredNames.some((name) => css.includes(`${name}:`))) continue;
227
+ app.logger.warn(
228
+ `[base-artifact] @import ${cssPath} declares ${declaredNames.length} custom propert${declaredNames.length === 1 ? "y" : "ies"} and none of them are in ${cssRelPath}`,
229
+ );
230
+ }
231
+ }
216
232
  }
@@ -11,6 +11,7 @@ export const VENDOR_SPECIFIERS = [
11
11
  "akanjs/base",
12
12
  "akanjs/common",
13
13
  "akanjs/constant",
14
+ "akanjs/fetch",
14
15
  ] as const;
15
16
 
16
17
  export type VendorSpecifier = (typeof VENDOR_SPECIFIERS)[number];
package/getCredentials.ts CHANGED
@@ -1,5 +1,3 @@
1
- import yaml from "js-yaml";
2
-
3
1
  import type { AppExecutor } from "./executors";
4
2
  import { FileSys } from "./fileSys";
5
3
 
@@ -12,7 +10,7 @@ interface Secret {
12
10
 
13
11
  export const getCredentials = async (app: AppExecutor, environment: string): Promise<AppSecret> => {
14
12
  const content = await FileSys.readText(`${app.workspace.workspaceRoot}/infra/app/values/${app.name}-secret.yaml`);
15
- const secret = yaml.load(content) as Secret;
13
+ const secret = Bun.YAML.parse(content) as Secret;
16
14
  const appSecret = secret[environment];
17
15
  if (!appSecret) throw new Error(`No secret found for ${app.name} in ${environment}`);
18
16
  return appSecret;