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

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
  );
@@ -11,7 +11,6 @@ import {
11
11
  clearRootCapacitorConfigs,
12
12
  formatAndroidReleaseSigningError,
13
13
  getAdbDeviceStateIssues,
14
- getAndroidLocalServerHost,
15
14
  getMissingAndroidReleaseSigningKeys,
16
15
  isPlaceholderAppId,
17
16
  materializeCapacitorConfig,
@@ -340,13 +339,6 @@ describe("Android signing diagnostics", () => {
340
339
  "Android device abc123 is unauthorized. Confirm USB debugging authorization on the device.",
341
340
  "Android device xyz is offline. Reconnect the device or restart adb.",
342
341
  ]);
343
- expect(getAndroidLocalServerHost("List of devices attached\nemulator-5554 device\n", "192.168.0.5")).toBe(
344
- "10.0.2.2",
345
- );
346
- expect(getAndroidLocalServerHost("List of devices attached\nabc123 device\n", "192.168.0.5")).toBe("192.168.0.5");
347
- expect(
348
- getAndroidLocalServerHost("List of devices attached\nemulator-5554 device\nabc123 device\n", "192.168.0.5"),
349
- ).toBe("192.168.0.5");
350
342
  });
351
343
  });
352
344
 
package/capacitorApp.ts CHANGED
@@ -24,9 +24,7 @@ interface RunIosConfig extends RunConfig {
24
24
  iosDeviceId?: string;
25
25
  }
26
26
 
27
- interface PrepareConfig extends RunConfig {
28
- iosRunTargetKind?: IosRunTargetKind;
29
- }
27
+ interface PrepareConfig extends RunConfig {}
30
28
 
31
29
  type MobileCommandEnv = Record<string, string | undefined>;
32
30
 
@@ -121,11 +119,10 @@ interface MaterializeCapacitorConfigOptions {
121
119
  localServerUrl?: string;
122
120
  localIp?: string;
123
121
  }
124
- type MobilePlatform = "ios" | "android";
125
122
 
126
123
  export interface LocalDevHostResolution {
127
124
  host: string;
128
- source: "override" | "detected" | "loopback" | "platform";
125
+ source: "override" | "detected" | "loopback";
129
126
  candidates: { name: string; address: string }[];
130
127
  }
131
128
 
@@ -568,16 +565,6 @@ export function getAdbDeviceStateIssues(output: string) {
568
565
  });
569
566
  }
570
567
 
571
- export function getAndroidLocalServerHost(adbDevicesOutput: string | undefined, fallbackHost: string) {
572
- const onlineDeviceIds =
573
- adbDevicesOutput
574
- ?.split(/\r?\n/)
575
- .map((line) => line.trim().split(/\s+/))
576
- .filter(([id, state]) => id && state === "device")
577
- .map(([id]) => id) ?? [];
578
- return onlineDeviceIds.length === 1 && onlineDeviceIds[0]?.startsWith("emulator-") ? "10.0.2.2" : fallbackHost;
579
- }
580
-
581
568
  export const ANDROID_MIN_SDK_VERSION = 26;
582
569
  export function raiseGradleMinSdkVersion(content: string, floor: number = ANDROID_MIN_SDK_VERSION): string | null {
583
570
  const match = content.match(/minSdkVersion\s*=\s*(\d+)/);
@@ -761,7 +748,7 @@ export class CapacitorApp {
761
748
  async save() {
762
749
  await this.project.commit();
763
750
  }
764
- async #prepareIos({ operation, env, regenerate = false, iosRunTargetKind }: PrepareConfig) {
751
+ async #prepareIos({ operation, env, regenerate = false }: PrepareConfig) {
765
752
  await this.init({ platform: "ios", operation, env, regenerate });
766
753
  await this.#prepareTargetAssets();
767
754
  await this.#prepareExternalFiles("ios");
@@ -770,10 +757,9 @@ export class CapacitorApp {
770
757
  await this.#applyDeepLinks("ios", { operation, env });
771
758
  await this.#flushIosEntitlements();
772
759
  await this.project.commit();
773
- await this.#setCodeSignEntitlementsInIosIfExists("App/App.entitlements");
774
760
  await this.#generateAssets({ operation, env });
775
761
  this.app.verbose(`syncing iOS`);
776
- await this.#spawnMobile("npx", ["cap", "sync", "ios"], { operation, env }, { iosRunTargetKind });
762
+ await this.#spawnMobile("npx", ["cap", "sync", "ios"], { operation, env });
777
763
  this.app.verbose(`sync completed.`);
778
764
  }
779
765
  async buildIos({ env = "debug", regenerate = false }: { env?: RunConfig["env"]; regenerate?: boolean } = {}) {
@@ -791,14 +777,14 @@ export class CapacitorApp {
791
777
  }
792
778
  async runIos({ operation, env, regenerate = false, noAllowProvisioningUpdates = false, iosDeviceId }: RunIosConfig) {
793
779
  if (operation === "release") await this.prepareWww();
780
+ await this.#prepareIos({ operation, env, regenerate });
794
781
  const runTarget = await this.#selectIosRunTarget(iosDeviceId);
795
- await this.#prepareIos({ operation, env, regenerate, iosRunTargetKind: runTarget.kind });
796
782
  if (runTarget.kind === "simulator") {
797
783
  await this.#spawnMobile(
798
784
  "npx",
799
- ["cap", "run", "ios", "--target", runTarget.id, "--no-sync"],
785
+ ["cap", "run", "ios", "--target", runTarget.id],
800
786
  { operation, env },
801
- { stdio: "inherit", platform: "ios", iosRunTargetKind: runTarget.kind },
787
+ { stdio: "inherit" },
802
788
  );
803
789
  return;
804
790
  }
@@ -892,10 +878,7 @@ export class CapacitorApp {
892
878
  noAllowProvisioningUpdates: boolean;
893
879
  }) {
894
880
  const mobileEnv = sanitizeIosNativeRunEnv(await this.#commandEnv(operation, env));
895
- const configContent = await this.#writeCapacitorConfig(
896
- { operation, platform: "ios", iosRunTargetKind: runTarget.kind },
897
- mobileEnv,
898
- );
881
+ const configContent = await this.#writeCapacitorConfig({ operation }, mobileEnv);
899
882
  await this.#writeRootCapacitorConfig(configContent);
900
883
  const scheme = this.#iosScheme();
901
884
  const command = buildIosNativeRunCommand({
@@ -960,7 +943,6 @@ export class CapacitorApp {
960
943
  await this.#applyAndroidMinSdkVersion();
961
944
  await this.#applyPermissions({ operation, env });
962
945
  await this.#applyDeepLinks("android", { operation, env });
963
- await this.#disableNativeKeyboardResizeInAndroid();
964
946
  await this.project.commit();
965
947
  await this.#generateAssets({ operation, env });
966
948
  await this.#ensureAndroidAssetsDir();
@@ -1028,24 +1010,6 @@ export class CapacitorApp {
1028
1010
  async openAndroid() {
1029
1011
  await this.#spawnMobile("npx", ["cap", "open", "android"], { operation: "local", env: "local" });
1030
1012
  }
1031
- async #disableNativeKeyboardResizeInAndroid() {
1032
- const manifestPath = path.join(this.app.cwdPath, this.androidRootPath, "app/src/main/AndroidManifest.xml");
1033
- let manifest = await readFile(manifestPath, "utf8");
1034
- let changed = false;
1035
- manifest = manifest.replace(/<activity\b[^>]*android:name="\.MainActivity"[^>]*>/, (activityTag) => {
1036
- if (activityTag.includes("android:windowSoftInputMode=")) {
1037
- const nextTag = activityTag.replace(
1038
- /android:windowSoftInputMode="[^"]*"/,
1039
- 'android:windowSoftInputMode="adjustNothing"',
1040
- );
1041
- changed ||= nextTag !== activityTag;
1042
- return nextTag;
1043
- }
1044
- changed = true;
1045
- return activityTag.replace(/>$/, '\n android:windowSoftInputMode="adjustNothing">');
1046
- });
1047
- if (changed) await writeFile(manifestPath, manifest);
1048
- }
1049
1013
  async #ensureAndroidAssetsDir() {
1050
1014
  await mkdir(path.join(this.app.cwdPath, this.androidAssetsPath), { recursive: true });
1051
1015
  }
@@ -1136,19 +1100,12 @@ export class CapacitorApp {
1136
1100
  if (html.includes("window.__AKAN_MOBILE_TARGET__")) return html;
1137
1101
  return html.replace(/<\/head\s*>/i, `${script}\n</head>`);
1138
1102
  }
1139
- async #writeCapacitorConfig(
1140
- {
1141
- operation,
1142
- platform,
1143
- iosRunTargetKind,
1144
- }: Pick<RunConfig, "operation"> & { platform?: MobilePlatform; iosRunTargetKind?: IosRunTargetKind },
1145
- commandEnv: MobileCommandEnv,
1146
- ) {
1103
+ async #writeCapacitorConfig({ operation }: Pick<RunConfig, "operation">, commandEnv: MobileCommandEnv) {
1147
1104
  await mkdir(this.targetRoot, { recursive: true });
1148
1105
  let localIp: string | undefined;
1149
1106
  if (operation === "local") {
1150
1107
  const override = commandEnv.AKAN_PUBLIC_CLIENT_HOST ?? process.env.AKAN_PUBLIC_CLIENT_HOST;
1151
- const resolution = await this.#resolveLocalDevHost({ override, platform, iosRunTargetKind });
1108
+ const resolution = selectLocalDevHost(os.networkInterfaces(), { override });
1152
1109
  localIp = resolution.host;
1153
1110
  this.#logDevHostResolution(resolution, commandEnv);
1154
1111
  }
@@ -1161,36 +1118,11 @@ export class CapacitorApp {
1161
1118
  await Bun.write(path.join(this.targetRoot, "capacitor.config.json"), content);
1162
1119
  return content;
1163
1120
  }
1164
- // An explicit override always wins; an emulator/simulator run reaches the host machine through a
1165
- // loopback alias (10.0.2.2 / localhost), so LAN detection only applies to physical-device runs.
1166
- async #resolveLocalDevHost({
1167
- override,
1168
- platform,
1169
- iosRunTargetKind,
1170
- }: {
1171
- override?: string;
1172
- platform?: MobilePlatform;
1173
- iosRunTargetKind?: IosRunTargetKind;
1174
- }): Promise<LocalDevHostResolution> {
1175
- const resolution = selectLocalDevHost(os.networkInterfaces(), { override });
1176
- if (resolution.source === "override") return resolution;
1177
- if (platform === "ios" && iosRunTargetKind === "simulator")
1178
- return { ...resolution, host: "localhost", source: "platform" };
1179
- if (platform === "android") {
1180
- try {
1181
- const host = getAndroidLocalServerHost(await this.#spawn("adb", ["devices"]), resolution.host);
1182
- return host === resolution.host ? resolution : { ...resolution, host, source: "platform" };
1183
- } catch {
1184
- return resolution;
1185
- }
1186
- }
1187
- return resolution;
1188
- }
1189
1121
  // Surface the live-reload URL a physical device must reach, and warn when auto-detection landed on
1190
1122
  // a likely-unreachable host so a blank WebView is not mistaken for an app bug.
1191
1123
  #logDevHostResolution(resolution: LocalDevHostResolution, commandEnv: MobileCommandEnv) {
1192
1124
  this.app.log(`Mobile live-reload server: ${this.#localCsrUrl(resolution.host, commandEnv)}`);
1193
- if (resolution.source === "override" || resolution.source === "platform") return;
1125
+ if (resolution.source === "override") return;
1194
1126
  const suspicious = resolution.host === "127.0.0.1" || resolution.host.startsWith("169.254.");
1195
1127
  const alternatives = resolution.candidates.filter((candidate) => candidate.address !== resolution.host);
1196
1128
  if (!suspicious && alternatives.length === 0) return;
@@ -1400,29 +1332,20 @@ export class CapacitorApp {
1400
1332
  command: string,
1401
1333
  args: string[] = [],
1402
1334
  { operation, env }: Pick<RunConfig, "operation" | "env">,
1403
- options: SpawnMobileOptions = {},
1335
+ options: Parameters<AppExecutor["spawn"]>[2] = {},
1404
1336
  ) {
1405
- const { iosRunTargetKind, platform, ...spawnOptions } = options;
1406
1337
  const mobileEnv = { ...(await this.#commandEnv(operation, env)), ...options.env };
1407
- const configContent = await this.#writeCapacitorConfig(
1408
- { operation, platform: platform ?? this.#inferMobilePlatform(args), iosRunTargetKind },
1409
- mobileEnv,
1410
- );
1338
+ const configContent = await this.#writeCapacitorConfig({ operation }, mobileEnv);
1411
1339
  await this.#writeRootCapacitorConfig(configContent);
1412
1340
  try {
1413
1341
  return await this.#spawn(command, args, {
1414
- ...spawnOptions,
1342
+ ...options,
1415
1343
  env: mobileEnv,
1416
1344
  });
1417
1345
  } finally {
1418
1346
  await this.#clearRootCapacitorConfigs();
1419
1347
  }
1420
1348
  }
1421
- #inferMobilePlatform(args: string[]): MobilePlatform | undefined {
1422
- if (args.includes("android")) return "android";
1423
- if (args.includes("ios")) return "ios";
1424
- return undefined;
1425
- }
1426
1349
  async addCamera() {
1427
1350
  await this.#setPermissionInIos({
1428
1351
  cameraUsageDescription: "$(PRODUCT_NAME) requires access to the camera to take photos.",
@@ -1523,31 +1446,14 @@ export class CapacitorApp {
1523
1446
  let end = index;
1524
1447
  while (end < lines.length && !/^\s*\};\s*$/.test(lines[end] ?? "")) end++;
1525
1448
  const settings = lines.slice(start, end + 1);
1526
- const configName = lines.slice(end + 1, end + 8).find((setting) => setting.includes("name = ")) ?? "";
1449
+ if (settings.some((setting) => setting.includes("CODE_SIGN_ENTITLEMENTS"))) continue;
1527
1450
  const indent = line.match(/^\s*/)?.[0] ?? "";
1528
- const insertSettings = [];
1529
- if (!settings.some((setting) => setting.includes("CODE_SIGN_ENTITLEMENTS"))) {
1530
- insertSettings.push(`${indent}CODE_SIGN_ENTITLEMENTS = ${entitlementsRelPath};`);
1531
- }
1532
- if (
1533
- configName.includes("name = Debug;") &&
1534
- !settings.some((setting) => setting.includes("CODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION"))
1535
- ) {
1536
- insertSettings.push(`${indent}CODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION = YES;`);
1537
- }
1538
- if (insertSettings.length > 0) {
1539
- lines.splice(index, 0, ...insertSettings);
1540
- index += insertSettings.length;
1541
- changed = true;
1542
- }
1451
+ lines.splice(index, 0, `${indent}CODE_SIGN_ENTITLEMENTS = ${entitlementsRelPath};`);
1452
+ index++;
1453
+ changed = true;
1543
1454
  }
1544
1455
  if (changed) await writeFile(pbxprojPath, lines.join("\n"));
1545
1456
  }
1546
- async #setCodeSignEntitlementsInIosIfExists(entitlementsRelPath: string) {
1547
- const entitlementsPath = path.join(this.app.cwdPath, this.iosProjectPath, entitlementsRelPath);
1548
- if (!(await Bun.file(entitlementsPath).exists())) return;
1549
- await this.#setCodeSignEntitlementsInIos(entitlementsRelPath);
1550
- }
1551
1457
  async #setUrlSchemesInAndroid(schemes: string[]) {
1552
1458
  const manifestPath = path.join(this.app.cwdPath, this.androidRootPath, "app/src/main/AndroidManifest.xml");
1553
1459
  let manifest = await readFile(manifestPath, "utf8");
@@ -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
  }
@@ -1,12 +1,7 @@
1
1
  import { stat } from "node:fs/promises";
2
2
 
3
- /**
4
- * Every identifier-ish token in a source file is a potential tailwind class. The leading alternation
5
- * matters: an arbitrary variant starts with `[` (`[&_td]:px-3`, `[&>*]:gap-2`), and a pattern anchored
6
- * on a word character tears it into `_td` plus `px-3` instead — both meaningless, so the rule compiles
7
- * to nothing and the element silently renders unstyled.
8
- */
9
- const CANDIDATE_RE = /-?(?:[\w@]|\[[^\]]+\])[\w:/.-]*(?:\[[^\]]+\][\w:/.-]*)*/g;
3
+ /** Every identifier-ish token in a source file is a potential tailwind class. */
4
+ const CANDIDATE_RE = /-?[\w@][\w:/.-]*(?:\[[^\]]+\][\w:/.-]*)*/g;
10
5
 
11
6
  interface CachedFile {
12
7
  mtimeMs: number;
@@ -32,7 +27,7 @@ interface CacheFile {
32
27
  */
33
28
  export class CssCandidateCache {
34
29
  /** Bump when the token regex or the entry shape changes, so stale extractions are not reused. */
35
- static readonly #version = 2;
30
+ static readonly #version = 1;
36
31
  readonly #path: string;
37
32
  readonly #entries = new Map<string, CachedFile>();
38
33
  #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> {