@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 +3 -31
- package/applicationBuildRunner.ts +1 -1
- package/frontendBuild/allRoutesBuilder.ts +27 -8
- package/frontendBuild/cssCandidateCache.test.ts +11 -0
- package/frontendBuild/cssCandidateCache.ts +9 -2
- 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/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
|
);
|
|
@@ -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
|
}
|
|
@@ -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 =
|
|
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
|
-
|
|
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> {
|