@akanjs/devkit 3.0.0-alpha.4 → 3.0.0-alpha.6

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/akanContext.ts CHANGED
@@ -14,6 +14,7 @@ import {
14
14
  workflowRunArtifactPath,
15
15
  workflowSyncDir,
16
16
  } from "./workflow";
17
+ import { appRootAllowedDirs, appRootAllowedFiles, isScannedAppRootEntry } from "./workspaceLayout";
17
18
 
18
19
  export type AkanContextFormat = "json" | "markdown";
19
20
  export type AkanModuleKind = "domain" | "service" | "scalar";
@@ -313,36 +314,6 @@ const moduleShapeFiles = (module: AkanModuleContext) => {
313
314
  const constantFieldNames = (content: string) =>
314
315
  [...content.matchAll(/\b([A-Za-z_$][\w$]*)\s*:\s*field\(/g)].map((match) => match[1]).filter(Boolean);
315
316
 
316
- const appRootAllowFiles = new Set([
317
- // 스코프 에이전트 가이드 — scan(write) 이 유지 (agentsIndex.ts); scanInfo.ts 의 appRootAllowedFiles 와 동기
318
- "AGENTS.md",
319
- "CLAUDE.md",
320
- "akan.app.json",
321
- "akan.config.ts",
322
- "capacitor.config.ts",
323
- "client.ts",
324
- "main.ts",
325
- "package.json",
326
- "server.ts",
327
- "tsconfig.json",
328
- ]);
329
-
330
- const appRootAllowDirs = new Set([
331
- ".akan",
332
- "android",
333
- "common",
334
- "env",
335
- "ios",
336
- "lib",
337
- "page",
338
- "private",
339
- "public",
340
- "script",
341
- "srvkit",
342
- "ui",
343
- "webkit",
344
- ]);
345
-
346
317
  const safeReadDir = async (dirPath: string) => {
347
318
  try {
348
319
  return (await readdir(dirPath, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name));
@@ -671,7 +642,8 @@ export class AkanContextAnalyzer {
671
642
  for (const app of context.apps) {
672
643
  const appPath = path.join(workspace.workspaceRoot, app.path);
673
644
  for (const entry of await safeReadDir(appPath)) {
674
- const allowed = entry.isDirectory() ? appRootAllowDirs.has(entry.name) : appRootAllowFiles.has(entry.name);
645
+ if (!isScannedAppRootEntry(entry.name)) continue;
646
+ const allowed = entry.isDirectory() ? appRootAllowedDirs.has(entry.name) : appRootAllowedFiles.has(entry.name);
675
647
  if (!allowed) {
676
648
  const action = repairAction(
677
649
  "module-shape",
@@ -112,7 +112,7 @@ export class ApplicationBuildRunner {
112
112
  () => precompressArtifacts(this.#app),
113
113
  (result) =>
114
114
  result.files > 0
115
- ? `${result.files} files, ${ApplicationBuildRunner.formatBytes(result.inputBytes)} -> ${ApplicationBuildRunner.formatBytes(result.outputBytes)}`
115
+ ? `${result.files} files, ${ApplicationBuildRunner.formatBytes(result.inputBytes)} -> gzip ${ApplicationBuildRunner.formatBytes(result.outputBytes)} / br ${ApplicationBuildRunner.formatBytes(result.brotliBytes)}`
116
116
  : "no files",
117
117
  phaseOptions,
118
118
  );
@@ -47,11 +47,24 @@ export class AllRoutesBuilder {
47
47
  this.#app.verbose(`[build-all] discovered ${seedIndex.entries.length} routes`);
48
48
  this.#discovery = await GraphClientEntryDiscovery.create(this.#app);
49
49
 
50
+ // Discovery first, bundling second. Chunk splitting only dedupes within one `Bun.build`, so a
51
+ // dependency shared by entries from different routes was emitted once per route that reached it.
52
+ // Discovery is cached and does no bundling, so collecting every entry up front costs almost nothing.
53
+ const allEntries: string[] = [];
54
+ const seen = new Set<string>();
50
55
  for (const entry of seedIndex.entries) {
51
56
  const seeds = Array.from(new Set([...seedIndex.globalLayoutFiles, ...entry.seeds]));
52
- const delta = await this.#buildRoute(entry.routeId, seeds);
53
- this.#mergeRoute(entry.routeId, delta);
57
+ for (const discovered of await this.#discovery.discover(seeds)) {
58
+ if (seen.has(discovered)) continue;
59
+ seen.add(discovered);
60
+ allEntries.push(discovered);
61
+ }
62
+ this.#routeIds.push(entry.routeId);
54
63
  }
64
+ this.#app.verbose(`[build-all] ${allEntries.length} client entries across ${this.#routeIds.length} routes`);
65
+
66
+ const delta = await this.#buildEntries(allEntries);
67
+ this.#mergeDelta(delta);
55
68
  this.#merged.knownEntries = Array.from(this.#knownSet);
56
69
 
57
70
  const manifest: RoutesManifest = {
@@ -76,28 +89,34 @@ export class AllRoutesBuilder {
76
89
  return { manifest, manifestPath, seedIndex };
77
90
  }
78
91
 
79
- async #buildRoute(routeId: string, seeds: string[]): Promise<BuildRouteClientResult> {
92
+ async #buildEntries(entries: string[]): Promise<BuildRouteClientResult> {
80
93
  if (!this.#discovery) throw new Error("[build-all] client entry discovery is not initialized");
94
+ if (entries.length === 0)
95
+ return {
96
+ manifestDelta: {},
97
+ ssrManifestDelta: { moduleLoading: null, moduleMap: {} },
98
+ newEntries: [],
99
+ clientDeps: [],
100
+ };
81
101
  const started = Date.now();
82
102
  const delta = await new RouteClientBuilder({
83
103
  app: this.#app,
84
- routeId,
85
- seeds,
104
+ seeds: [],
105
+ entries,
86
106
  artifact: this.#artifact,
87
107
  knownEntries: this.#knownSet,
88
108
  discovery: this.#discovery,
89
109
  command: this.#command,
90
110
  }).build();
91
- this.#app.verbose(`[build-all] ${routeId} +${delta.newEntries.length} entries (${Date.now() - started}ms)`);
111
+ this.#app.verbose(`[build-all] bundled ${delta.newEntries.length} entries (${Date.now() - started}ms)`);
92
112
  return delta;
93
113
  }
94
114
 
95
- #mergeRoute(routeId: string, delta: BuildRouteClientResult): void {
115
+ #mergeDelta(delta: BuildRouteClientResult): void {
96
116
  for (const [key, row] of Object.entries(delta.manifestDelta)) this.#merged.clientManifest[key] = row;
97
117
  for (const [url, byName] of Object.entries(delta.ssrManifestDelta.moduleMap)) {
98
118
  this.#merged.ssrManifest.moduleMap[url] = byName;
99
119
  }
100
120
  for (const abs of delta.newEntries) this.#knownSet.add(abs);
101
- this.#routeIds.push(routeId);
102
121
  }
103
122
  }
@@ -24,4 +24,15 @@ describe("CssCandidateCache token extraction", () => {
24
24
  expect(candidates).toEqual(expect.arrayContaining(["[&_td]:px-3", "[&>*]:gap-2", "[&_tbody_tr]:border-t"]));
25
25
  expect(candidates).not.toContain("_td");
26
26
  });
27
+
28
+ // A labelled tuple type looks like an arbitrary property, and compiles to a rule whose declaration is
29
+ // TypeScript. `optimize()` cannot parse that, and drops far more than the offending rule on its way
30
+ // out — every variant in the build went missing, so nothing hovered, focused or responded to a
31
+ // breakpoint. Tailwind writes a space in an arbitrary value as `_`, so whitespace rules the token out.
32
+ test("ignores a labelled tuple type that only looks like an arbitrary property", async () => {
33
+ const candidates = await scan(
34
+ `type A = [key: string, make: () => unknown][];\ntype B = [setQueryArgs: (a: Args) => Args, options?: Policy];`,
35
+ );
36
+ expect(candidates.filter((candidate) => candidate.startsWith("["))).toEqual([]);
37
+ });
27
38
  });
@@ -5,8 +5,15 @@ import { stat } from "node:fs/promises";
5
5
  * matters: an arbitrary variant starts with `[` (`[&_td]:px-3`, `[&>*]:gap-2`), and a pattern anchored
6
6
  * on a word character tears it into `_td` plus `px-3` instead — both meaningless, so the rule compiles
7
7
  * to nothing and the element silently renders unstyled.
8
+ *
9
+ * Brackets must hold no whitespace. Tailwind spells a space in an arbitrary value `_`, so a bracket
10
+ * group containing one is never a class — but it is exactly the shape of a labelled tuple type
11
+ * (`[key: string, make: () => unknown]`). Scanned as a candidate, that compiles to an arbitrary
12
+ * *property* rule carrying TypeScript as its declaration, which the lightningcss pass in `optimize()`
13
+ * cannot parse; its error recovery then drops a whole span of the stylesheet — in practice every
14
+ * variant rule in the build, leaving hover, focus, responsive and dark styles silently absent.
8
15
  */
9
- const CANDIDATE_RE = /-?(?:[\w@]|\[[^\]]+\])[\w:/.-]*(?:\[[^\]]+\][\w:/.-]*)*/g;
16
+ const CANDIDATE_RE = /-?(?:[\w@]|\[[^\]\s]+\])[\w:/.-]*(?:\[[^\]\s]+\][\w:/.-]*)*/g;
10
17
 
11
18
  interface CachedFile {
12
19
  mtimeMs: number;
@@ -32,7 +39,7 @@ interface CacheFile {
32
39
  */
33
40
  export class CssCandidateCache {
34
41
  /** Bump when the token regex or the entry shape changes, so stale extractions are not reused. */
35
- static readonly #version = 2;
42
+ static readonly #version = 3;
36
43
  readonly #path: string;
37
44
  readonly #entries = new Map<string, CachedFile>();
38
45
  #dirty = false;
@@ -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;
@@ -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
 
@@ -285,9 +285,14 @@ export class DevResourceProbe {
285
285
  "rscWorkerRecycleCount",
286
286
  "httpFullSsrCount",
287
287
  ];
288
- const rssMb = (Number(child.rssBytes ?? 0) / 1024 / 1024).toFixed(0);
288
+ // `rssBytes` is the replica's own; the RSC worker is a separate process reporting under
289
+ // `rscWorker*`. These used to be the same field, because the worker's report shadowed the
290
+ // replica's — so this line printed the worker's RSS labelled as the child's.
291
+ const toMb = (bytes: unknown) => (Number(bytes ?? 0) / 1024 / 1024).toFixed(0);
289
292
  const parts = keys.map((key) => `${key}=${child[key] ?? "?"}`);
290
- console.info(`[metrics ${label}] rsc rss=${rssMb}MB ${parts.join(" ")}`);
293
+ console.info(
294
+ `[metrics ${label}] replicaRss=${toMb(child.rssBytes)}MB rscWorkerRss=${toMb(child.rscWorkerRssBytes)}MB ${parts.join(" ")}`,
295
+ );
291
296
  }
292
297
 
293
298
  async #waitForLog(pattern: RegExp, timeoutMs: number): Promise<boolean> {