@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 +3 -31
- package/applicationBuildRunner.ts +1 -1
- package/capacitorApp.test.ts +0 -8
- package/capacitorApp.ts +18 -112
- package/frontendBuild/allRoutesBuilder.ts +27 -8
- package/frontendBuild/cssCandidateCache.ts +3 -8
- package/frontendBuild/precompressArtifacts.ts +35 -7
- package/frontendBuild/routeClientBuilder.ts +14 -2
- package/integration/devResourceProbe.ts +7 -2
- package/integration/ssrMemoryProbe.ts +542 -0
- package/lint/no-bang-comment-in-client.grit +20 -0
- package/package.json +2 -2
- package/qualityScanner.test.ts +154 -1
- package/qualityScanner.ts +44 -27
- package/scanInfo.ts +2 -43
- package/spinner.test.ts +81 -0
- package/spinner.ts +22 -2
- package/ssrScanner.ts +409 -0
- package/workspaceLayout.test.ts +26 -0
- package/workspaceLayout.ts +61 -0
- package/frontendBuild/cssCandidateCache.test.ts +0 -27
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
|
-
|
|
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
|
);
|
package/capacitorApp.test.ts
CHANGED
|
@@ -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"
|
|
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
|
|
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 }
|
|
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
|
|
785
|
+
["cap", "run", "ios", "--target", runTarget.id],
|
|
800
786
|
{ operation, env },
|
|
801
|
-
{ stdio: "inherit"
|
|
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 =
|
|
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"
|
|
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:
|
|
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
|
-
...
|
|
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
|
-
|
|
1449
|
+
if (settings.some((setting) => setting.includes("CODE_SIGN_ENTITLEMENTS"))) continue;
|
|
1527
1450
|
const indent = line.match(/^\s*/)?.[0] ?? "";
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
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
|
|
53
|
-
|
|
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 #
|
|
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
|
-
|
|
85
|
-
|
|
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] ${
|
|
111
|
+
this.#app.verbose(`[build-all] bundled ${delta.newEntries.length} entries (${Date.now() - started}ms)`);
|
|
92
112
|
return delta;
|
|
93
113
|
}
|
|
94
114
|
|
|
95
|
-
#
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
16
|
-
|
|
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}
|
|
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
|
|
36
|
-
|
|
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
|
|
75
|
-
|
|
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
|
-
|
|
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(
|
|
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> {
|