@akanjs/devkit 2.4.1-rc.6 → 2.4.1
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/CHANGELOG.md +30 -0
- package/DEV_RUNTIME_KNOBS.md +97 -0
- package/README.md +6 -0
- package/akanApp/akanApp.host.test.ts +75 -6
- package/akanApp/akanApp.host.ts +303 -25
- package/commandDecorators/command.ts +16 -1
- package/frontendBuild/clientEntryDiscovery.ts +91 -53
- package/frontendBuild/cssCandidateCache.ts +109 -0
- package/frontendBuild/cssCompiler.ts +70 -18
- package/frontendBuild/frontendBuild.test.ts +3 -0
- package/frontendBuild/hmrWatcher.ts +6 -0
- package/frontendBuild/sourceMtimeIndex.test.ts +51 -2
- package/frontendBuild/sourceMtimeIndex.ts +66 -5
- package/incrementalBuilder/buildBatchRunner.ts +10 -1
- package/incrementalBuilder/builderChannel.test.ts +16 -7
- package/incrementalBuilder/builderRequestRouter.test.ts +89 -0
- package/incrementalBuilder/builderRequestRouter.ts +66 -0
- package/incrementalBuilder/incrementalBuilder.host.test.ts +28 -1
- package/incrementalBuilder/incrementalBuilder.host.ts +24 -2
- package/incrementalBuilder/incrementalBuilder.proc.ts +14 -13
- package/integration/devResourceProbe.ts +319 -0
- package/integration/devStability.integration.test.ts +153 -4
- package/integration/devStabilityHarness.ts +30 -5
- package/package.json +2 -2
- package/transforms/barrelImportsPlugin.ts +20 -13
|
@@ -14,6 +14,16 @@ const AKANJS_NODE_MODULE_RE = /[\\/]node_modules[\\/]akanjs[\\/]/;
|
|
|
14
14
|
const NON_SOURCE_EXT_RE = /\.(css|scss|sass|less|json|svg|png|jpe?g|webp|gif|avif|ico|woff2?|ttf|otf|mp3|mp4|wav)$/i;
|
|
15
15
|
type PackageResolver = Awaited<ReturnType<typeof createTsconfigPackageResolver>>;
|
|
16
16
|
|
|
17
|
+
/**
|
|
18
|
+
* Everything the traversal ever asks of a file, which is why its text is not kept.
|
|
19
|
+
*
|
|
20
|
+
* `imports` is empty for a client entry: the walk stops there, so they are never scanned.
|
|
21
|
+
*/
|
|
22
|
+
interface FileFacts {
|
|
23
|
+
isClientEntry: boolean;
|
|
24
|
+
imports: ScannedImport[];
|
|
25
|
+
}
|
|
26
|
+
|
|
17
27
|
const shouldSkipNodeModule = (absPath: string) => NODE_MODULES_RE.test(absPath) && !AKANJS_NODE_MODULE_RE.test(absPath);
|
|
18
28
|
|
|
19
29
|
/**
|
|
@@ -29,12 +39,23 @@ export class GraphClientEntryDiscovery implements ClientEntryDiscovery {
|
|
|
29
39
|
#analyzer: BarrelAnalyzer;
|
|
30
40
|
#tsTranspiler = new Bun.Transpiler({ loader: "tsx" });
|
|
31
41
|
#fileExistsCache = new Map<string, Promise<boolean>>();
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
42
|
+
/**
|
|
43
|
+
* Derived facts per file, never the source text.
|
|
44
|
+
*
|
|
45
|
+
* This instance lives as long as the builder process does — it is rebuilt only when the akan config
|
|
46
|
+
* changes — so caching whole source texts (and a second, barrel-rewritten copy of each) meant the
|
|
47
|
+
* watcher held every source file it had ever walked for the whole dev session. Nothing downstream
|
|
48
|
+
* wanted the text: the walk needs one boolean and one import list per file, and both were already
|
|
49
|
+
* cached separately under the same key.
|
|
50
|
+
*/
|
|
51
|
+
#factsCache = new Map<string, Promise<FileFacts | null>>();
|
|
35
52
|
#resolvedFileCache = new Map<string, Promise<string | null>>();
|
|
36
53
|
#resolvedSpecifierCache = new Map<string, Promise<string | null>>();
|
|
37
54
|
#reachableEntriesCache = new Map<string, Set<string>>();
|
|
55
|
+
/** Keys of the three caches above whose answer was "not there" — see `#forgetMissing`. */
|
|
56
|
+
#missingFiles = new Set<string>();
|
|
57
|
+
#unresolvedPaths = new Set<string>();
|
|
58
|
+
#unresolvedSpecifiers = new Set<string>();
|
|
38
59
|
|
|
39
60
|
constructor(akanConfig: AkanConfig, resolvePackage: PackageResolver) {
|
|
40
61
|
this.#akanConfig = akanConfig;
|
|
@@ -57,38 +78,88 @@ export class GraphClientEntryDiscovery implements ClientEntryDiscovery {
|
|
|
57
78
|
invalidate(files: string[]): void {
|
|
58
79
|
for (const file of files) {
|
|
59
80
|
const absPath = path.resolve(file);
|
|
60
|
-
this.#
|
|
61
|
-
this.#
|
|
62
|
-
this.#importCache.delete(absPath);
|
|
81
|
+
this.#factsCache.delete(absPath);
|
|
82
|
+
this.#fileExistsCache.delete(absPath);
|
|
63
83
|
this.#reachableEntriesCache.delete(absPath);
|
|
64
84
|
}
|
|
85
|
+
if (files.length === 0) return;
|
|
65
86
|
// Parent files cache the transitive result of their imports, so a changed
|
|
66
87
|
// child can affect any reachable-entry cache above it.
|
|
67
|
-
|
|
88
|
+
this.#reachableEntriesCache.clear();
|
|
89
|
+
this.#forgetMissing();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Drop every "there is no such file" answer, because a batch may be what created it.
|
|
94
|
+
*
|
|
95
|
+
* These caches are keyed by extension-less path and by `dir\0specifier`, neither of which maps back
|
|
96
|
+
* to the path that just appeared, so a negative recorded before a module existed is unreachable any
|
|
97
|
+
* other way — and this instance lives as long as the builder process. Positive answers are kept:
|
|
98
|
+
* they are keyed by a real path, which arrives in `files` when it changes or goes away.
|
|
99
|
+
*/
|
|
100
|
+
#forgetMissing(): void {
|
|
101
|
+
for (const key of this.#missingFiles) this.#fileExistsCache.delete(key);
|
|
102
|
+
for (const key of this.#unresolvedPaths) this.#resolvedFileCache.delete(key);
|
|
103
|
+
for (const key of this.#unresolvedSpecifiers) this.#resolvedSpecifierCache.delete(key);
|
|
104
|
+
this.#missingFiles.clear();
|
|
105
|
+
this.#unresolvedPaths.clear();
|
|
106
|
+
this.#unresolvedSpecifiers.clear();
|
|
68
107
|
}
|
|
69
108
|
|
|
70
109
|
async #fileExists(p: string): Promise<boolean> {
|
|
71
110
|
const absPath = path.resolve(p);
|
|
72
111
|
let cached = this.#fileExistsCache.get(absPath);
|
|
73
112
|
if (!cached) {
|
|
74
|
-
cached = Bun.file(absPath)
|
|
113
|
+
cached = Bun.file(absPath)
|
|
114
|
+
.exists()
|
|
115
|
+
.then((exists) => {
|
|
116
|
+
if (!exists) this.#missingFiles.add(absPath);
|
|
117
|
+
return exists;
|
|
118
|
+
});
|
|
75
119
|
this.#fileExistsCache.set(absPath, cached);
|
|
76
120
|
}
|
|
77
121
|
return cached;
|
|
78
122
|
}
|
|
79
123
|
|
|
80
|
-
|
|
124
|
+
/**
|
|
125
|
+
* Read a file once and keep only what the traversal asks of it. The text itself is dropped as soon
|
|
126
|
+
* as the boolean and the import list are out of it — see `#factsCache`.
|
|
127
|
+
*/
|
|
128
|
+
#facts(file: string): Promise<FileFacts | null> {
|
|
81
129
|
const absPath = path.resolve(file);
|
|
82
|
-
let cached = this.#
|
|
130
|
+
let cached = this.#factsCache.get(absPath);
|
|
83
131
|
if (!cached) {
|
|
84
|
-
cached =
|
|
85
|
-
.
|
|
86
|
-
|
|
87
|
-
|
|
132
|
+
cached = (async () => {
|
|
133
|
+
const content = await Bun.file(absPath)
|
|
134
|
+
.text()
|
|
135
|
+
.catch(() => null);
|
|
136
|
+
if (content === null) return null;
|
|
137
|
+
// A client entry ends the walk, so its imports are never needed.
|
|
138
|
+
if (USE_CLIENT_RE.test(content)) return { isClientEntry: true, imports: [] };
|
|
139
|
+
return { isClientEntry: false, imports: this.#scanImports(await this.#rewrite(content)) };
|
|
140
|
+
})();
|
|
141
|
+
this.#factsCache.set(absPath, cached);
|
|
88
142
|
}
|
|
89
143
|
return cached;
|
|
90
144
|
}
|
|
91
145
|
|
|
146
|
+
async #rewrite(content: string): Promise<string> {
|
|
147
|
+
if (this.#akanConfig.barrelImports.length === 0) return content;
|
|
148
|
+
try {
|
|
149
|
+
return (await rewriteBarrelImports(content, this.#akanConfig.barrelImports, this.#analyzer)) ?? content;
|
|
150
|
+
} catch {
|
|
151
|
+
return content;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
#scanImports(source: string): ScannedImport[] {
|
|
156
|
+
try {
|
|
157
|
+
return this.#tsTranspiler.scanImports(source);
|
|
158
|
+
} catch {
|
|
159
|
+
return [];
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
92
163
|
async #resolveFileCandidate(absPathNoExt: string): Promise<string | null> {
|
|
93
164
|
const cacheKey = path.resolve(absPathNoExt);
|
|
94
165
|
let cached = this.#resolvedFileCache.get(cacheKey);
|
|
@@ -103,6 +174,7 @@ export class GraphClientEntryDiscovery implements ClientEntryDiscovery {
|
|
|
103
174
|
const f = path.join(cacheKey, `index${ext}`);
|
|
104
175
|
if (await this.#fileExists(f)) return f;
|
|
105
176
|
}
|
|
177
|
+
this.#unresolvedPaths.add(cacheKey);
|
|
106
178
|
return null;
|
|
107
179
|
})();
|
|
108
180
|
this.#resolvedFileCache.set(cacheKey, cached);
|
|
@@ -120,45 +192,13 @@ export class GraphClientEntryDiscovery implements ClientEntryDiscovery {
|
|
|
120
192
|
}
|
|
121
193
|
const pkg = await this.#resolvePackage(spec);
|
|
122
194
|
if (pkg) return pkg.entryFile;
|
|
195
|
+
this.#unresolvedSpecifiers.add(cacheKey);
|
|
123
196
|
return null;
|
|
124
197
|
})();
|
|
125
198
|
this.#resolvedSpecifierCache.set(cacheKey, cached);
|
|
126
199
|
return cached;
|
|
127
200
|
}
|
|
128
201
|
|
|
129
|
-
async #getRewrittenSource(file: string, content: string): Promise<string> {
|
|
130
|
-
const absPath = path.resolve(file);
|
|
131
|
-
let cached = this.#rewriteCache.get(absPath);
|
|
132
|
-
if (!cached) {
|
|
133
|
-
cached = (async () => {
|
|
134
|
-
if (this.#akanConfig.barrelImports.length === 0) return content;
|
|
135
|
-
try {
|
|
136
|
-
return (await rewriteBarrelImports(content, this.#akanConfig.barrelImports, this.#analyzer)) ?? content;
|
|
137
|
-
} catch {
|
|
138
|
-
return content;
|
|
139
|
-
}
|
|
140
|
-
})();
|
|
141
|
-
this.#rewriteCache.set(absPath, cached);
|
|
142
|
-
}
|
|
143
|
-
return cached;
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
async #getImports(file: string, source: string): Promise<ScannedImport[]> {
|
|
147
|
-
const absPath = path.resolve(file);
|
|
148
|
-
let cached = this.#importCache.get(absPath);
|
|
149
|
-
if (!cached) {
|
|
150
|
-
cached = Promise.resolve().then(() => {
|
|
151
|
-
try {
|
|
152
|
-
return this.#tsTranspiler.scanImports(source);
|
|
153
|
-
} catch {
|
|
154
|
-
return [];
|
|
155
|
-
}
|
|
156
|
-
});
|
|
157
|
-
this.#importCache.set(absPath, cached);
|
|
158
|
-
}
|
|
159
|
-
return cached;
|
|
160
|
-
}
|
|
161
|
-
|
|
162
202
|
async #discoverFromFile(file: string, visiting: Set<string>): Promise<Set<string>> {
|
|
163
203
|
const absPath = path.resolve(file);
|
|
164
204
|
const cached = this.#reachableEntriesCache.get(absPath);
|
|
@@ -167,18 +207,16 @@ export class GraphClientEntryDiscovery implements ClientEntryDiscovery {
|
|
|
167
207
|
|
|
168
208
|
visiting.add(absPath);
|
|
169
209
|
const entries = new Set<string>();
|
|
170
|
-
const
|
|
171
|
-
if (
|
|
210
|
+
const facts = await this.#facts(absPath);
|
|
211
|
+
if (!facts) return this.#finishDiscovery(absPath, visiting, entries);
|
|
172
212
|
|
|
173
|
-
if (
|
|
213
|
+
if (facts.isClientEntry) {
|
|
174
214
|
entries.add(absPath);
|
|
175
215
|
return this.#finishDiscovery(absPath, visiting, entries);
|
|
176
216
|
}
|
|
177
217
|
|
|
178
|
-
const source = await this.#getRewrittenSource(absPath, content);
|
|
179
|
-
const imports = await this.#getImports(absPath, source);
|
|
180
218
|
const importerDir = path.dirname(absPath);
|
|
181
|
-
for (const imp of imports) {
|
|
219
|
+
for (const imp of facts.imports) {
|
|
182
220
|
const spec = imp.path;
|
|
183
221
|
if (!spec) continue;
|
|
184
222
|
if (NON_SOURCE_EXT_RE.test(spec)) continue;
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { stat } from "node:fs/promises";
|
|
2
|
+
|
|
3
|
+
/** Every identifier-ish token in a source file is a potential tailwind class. */
|
|
4
|
+
const CANDIDATE_RE = /-?[\w@][\w:/.-]*(?:\[[^\]]+\][\w:/.-]*)*/g;
|
|
5
|
+
|
|
6
|
+
interface CachedFile {
|
|
7
|
+
mtimeMs: number;
|
|
8
|
+
size: number;
|
|
9
|
+
candidates: string[];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface CacheFile {
|
|
13
|
+
version: number;
|
|
14
|
+
files: Record<string, CachedFile>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Tailwind candidate tokens per source file, cached on disk across builds.
|
|
19
|
+
*
|
|
20
|
+
* The scan reads the **full text** of every source file on every CSS rebuild — measured at 385-508ms
|
|
21
|
+
* per save on `apps/akan`. Phase 2 moved css compilation into a per-generation batch worker, so an
|
|
22
|
+
* in-memory cache (what `03-phase3-topology-and-trim.md` §3.4 originally proposed) buys nothing: the
|
|
23
|
+
* process that would hold it exits before the next save. Disk is what survives the worker, a builder
|
|
24
|
+
* recycle and a dev-host restart alike, which is the same reasoning §3.3 used for the font cache.
|
|
25
|
+
*
|
|
26
|
+
* Keyed on (mtime, size) per file, so a save re-reads only the files in that batch.
|
|
27
|
+
*/
|
|
28
|
+
export class CssCandidateCache {
|
|
29
|
+
/** Bump when the token regex or the entry shape changes, so stale extractions are not reused. */
|
|
30
|
+
static readonly #version = 1;
|
|
31
|
+
readonly #path: string;
|
|
32
|
+
readonly #entries = new Map<string, CachedFile>();
|
|
33
|
+
#dirty = false;
|
|
34
|
+
#reused = 0;
|
|
35
|
+
#rescanned = 0;
|
|
36
|
+
|
|
37
|
+
constructor(cachePath: string) {
|
|
38
|
+
this.#path = cachePath;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
get reused(): number {
|
|
42
|
+
return this.#reused;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
get rescanned(): number {
|
|
46
|
+
return this.#rescanned;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** A cache that cannot be read or is a version behind simply starts empty — it is only an optimisation. */
|
|
50
|
+
async load(): Promise<this> {
|
|
51
|
+
const raw = (await Bun.file(this.#path)
|
|
52
|
+
.json()
|
|
53
|
+
.catch(() => null)) as CacheFile | null;
|
|
54
|
+
if (!raw || raw.version !== CssCandidateCache.#version || typeof raw.files !== "object") return this;
|
|
55
|
+
for (const [file, entry] of Object.entries(raw.files)) {
|
|
56
|
+
if (typeof entry?.mtimeMs !== "number" || !Array.isArray(entry.candidates)) continue;
|
|
57
|
+
this.#entries.set(file, entry);
|
|
58
|
+
}
|
|
59
|
+
return this;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The file's candidate tokens, read from disk only when its (mtime, size) no longer matches.
|
|
64
|
+
*
|
|
65
|
+
* Read errors propagate, as they did before this cache existed: a source file the compiler cannot
|
|
66
|
+
* read is a broken build, not a cache miss to paper over.
|
|
67
|
+
*/
|
|
68
|
+
async candidatesFor(file: string): Promise<string[]> {
|
|
69
|
+
const stats = await stat(file).catch(() => null);
|
|
70
|
+
const cached = this.#entries.get(file);
|
|
71
|
+
if (stats && cached && cached.mtimeMs === stats.mtimeMs && cached.size === stats.size) {
|
|
72
|
+
this.#reused += 1;
|
|
73
|
+
return cached.candidates;
|
|
74
|
+
}
|
|
75
|
+
const content = await Bun.file(file).text();
|
|
76
|
+
const candidates = [...new Set(Array.from(content.matchAll(CANDIDATE_RE), (m) => m[0]))];
|
|
77
|
+
this.#rescanned += 1;
|
|
78
|
+
if (stats) {
|
|
79
|
+
this.#entries.set(file, { mtimeMs: stats.mtimeMs, size: stats.size, candidates });
|
|
80
|
+
this.#dirty = true;
|
|
81
|
+
}
|
|
82
|
+
return candidates;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Persist, dropping files the scan no longer reaches so a renamed or deleted module does not keep
|
|
87
|
+
* feeding its classes to the compiler forever.
|
|
88
|
+
*
|
|
89
|
+
* A rebuild that re-read nothing writes nothing: the boot double-build and any CSS rebuild triggered
|
|
90
|
+
* by something other than a source edit would otherwise rewrite the whole file for no change.
|
|
91
|
+
*/
|
|
92
|
+
async save(present: Set<string>): Promise<void> {
|
|
93
|
+
for (const file of [...this.#entries.keys()]) {
|
|
94
|
+
if (present.has(file)) continue;
|
|
95
|
+
this.#entries.delete(file);
|
|
96
|
+
this.#dirty = true;
|
|
97
|
+
}
|
|
98
|
+
if (!this.#dirty) return;
|
|
99
|
+
this.#dirty = false;
|
|
100
|
+
const files: Record<string, CachedFile> = {};
|
|
101
|
+
for (const [file, entry] of this.#entries) files[file] = entry;
|
|
102
|
+
// A cache that cannot be written is a slow build, not a failed one — a read-only checkout must
|
|
103
|
+
// still compile.
|
|
104
|
+
await Bun.write(
|
|
105
|
+
this.#path,
|
|
106
|
+
JSON.stringify({ version: CssCandidateCache.#version, files } satisfies CacheFile),
|
|
107
|
+
).catch(() => undefined);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
@@ -4,6 +4,7 @@ import { compile } from "tailwindcss";
|
|
|
4
4
|
import type { App } from "../commandDecorators";
|
|
5
5
|
import { BarrelAnalyzer } from "../transforms/barrelAnalyzer";
|
|
6
6
|
import { createTsconfigPackageResolver, rewriteBarrelImports } from "../transforms/barrelImportsPlugin";
|
|
7
|
+
import { CssCandidateCache } from "./cssCandidateCache";
|
|
7
8
|
import { CssImportResolver } from "./cssImportResolver";
|
|
8
9
|
|
|
9
10
|
const SOURCE_EXTS = [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"] as const;
|
|
@@ -25,8 +26,54 @@ export class CssCompiler {
|
|
|
25
26
|
this.#app = app;
|
|
26
27
|
}
|
|
27
28
|
|
|
29
|
+
/**
|
|
30
|
+
* Beside the sources rather than in `dist`, because the cache is keyed on source mtimes: a build and
|
|
31
|
+
* the dev server describe the same files, so they should share it rather than each pay the first scan.
|
|
32
|
+
*/
|
|
33
|
+
get #candidateCachePath() {
|
|
34
|
+
return path.join(this.#app.cwdPath, ".akan/cache/cssCandidates.json");
|
|
35
|
+
}
|
|
36
|
+
|
|
28
37
|
#cssText: string | null = null;
|
|
29
38
|
#cssTextByBasePath: Record<string, string> | null = null;
|
|
39
|
+
/**
|
|
40
|
+
* Import resolution memoised for the life of one compiler, which is one rebuild.
|
|
41
|
+
*
|
|
42
|
+
* The discovery BFS resolves the same specifier from every directory that imports it, and each miss
|
|
43
|
+
* costs up to 13 sequential `exists()` calls — the bare path, six extensions, six `index.*`. Nothing
|
|
44
|
+
* here outlives the rebuild, so there is no invalidation to get wrong.
|
|
45
|
+
*/
|
|
46
|
+
#fileExistsCache = new Map<string, Promise<boolean>>();
|
|
47
|
+
#resolvedFileCache = new Map<string, Promise<string | null>>();
|
|
48
|
+
#resolvedSpecifierCache = new Map<string, Promise<string | null>>();
|
|
49
|
+
|
|
50
|
+
#fileExists(absPath: string): Promise<boolean> {
|
|
51
|
+
let cached = this.#fileExistsCache.get(absPath);
|
|
52
|
+
if (!cached) {
|
|
53
|
+
cached = Bun.file(absPath).exists();
|
|
54
|
+
this.#fileExistsCache.set(absPath, cached);
|
|
55
|
+
}
|
|
56
|
+
return cached;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
#resolveSourceFileCandidate(absPathNoExt: string): Promise<string | null> {
|
|
60
|
+
let cached = this.#resolvedFileCache.get(absPathNoExt);
|
|
61
|
+
if (cached) return cached;
|
|
62
|
+
cached = (async () => {
|
|
63
|
+
if (await this.#fileExists(absPathNoExt)) return isSourceFile(absPathNoExt) ? absPathNoExt : null;
|
|
64
|
+
for (const ext of SOURCE_EXTS) {
|
|
65
|
+
const filePath = `${absPathNoExt}${ext}`;
|
|
66
|
+
if (await this.#fileExists(filePath)) return filePath;
|
|
67
|
+
}
|
|
68
|
+
for (const ext of SOURCE_EXTS) {
|
|
69
|
+
const filePath = path.join(absPathNoExt, `index${ext}`);
|
|
70
|
+
if (await this.#fileExists(filePath)) return filePath;
|
|
71
|
+
}
|
|
72
|
+
return null;
|
|
73
|
+
})();
|
|
74
|
+
this.#resolvedFileCache.set(absPathNoExt, cached);
|
|
75
|
+
return cached;
|
|
76
|
+
}
|
|
30
77
|
async getCss({ refresh }: { refresh?: boolean } = {}) {
|
|
31
78
|
if (this.#cssText !== null && !refresh) return this.#cssText;
|
|
32
79
|
const { cssPaths, sourcePaths } = await this.discoverCssAndSources({ refresh });
|
|
@@ -197,14 +244,31 @@ export class CssCompiler {
|
|
|
197
244
|
const mod = await import(p);
|
|
198
245
|
return { path: p, base: path.dirname(p), module: mod.default ?? mod };
|
|
199
246
|
}
|
|
200
|
-
|
|
247
|
+
#resolveSourceImport(
|
|
248
|
+
id: string,
|
|
249
|
+
fromBase: string,
|
|
250
|
+
resolvePackage: Awaited<ReturnType<typeof createTsconfigPackageResolver>>,
|
|
251
|
+
): Promise<string | null> {
|
|
252
|
+
// Keyed by importer directory even for bare specifiers: the tsconfig resolver is
|
|
253
|
+
// directory-independent, but the `Bun.resolveSync` / `require.resolve` fallbacks below are not.
|
|
254
|
+
// The expensive part — the `exists()` probes — is deduplicated by absolute path instead, which is
|
|
255
|
+
// shared across every importer.
|
|
256
|
+
const cacheKey = `${fromBase}\0${id}`;
|
|
257
|
+
let cached = this.#resolvedSpecifierCache.get(cacheKey);
|
|
258
|
+
if (cached) return cached;
|
|
259
|
+
cached = this.#resolveSourceImportUncached(id, fromBase, resolvePackage);
|
|
260
|
+
this.#resolvedSpecifierCache.set(cacheKey, cached);
|
|
261
|
+
return cached;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async #resolveSourceImportUncached(
|
|
201
265
|
id: string,
|
|
202
266
|
fromBase: string,
|
|
203
267
|
resolvePackage: Awaited<ReturnType<typeof createTsconfigPackageResolver>>,
|
|
204
268
|
): Promise<string | null> {
|
|
205
269
|
if (id.startsWith(".") || id.startsWith("/")) {
|
|
206
270
|
const abs = id.startsWith("/") ? id : path.resolve(fromBase, id);
|
|
207
|
-
return resolveSourceFileCandidate(abs);
|
|
271
|
+
return this.#resolveSourceFileCandidate(abs);
|
|
208
272
|
}
|
|
209
273
|
|
|
210
274
|
const pkg = await resolvePackage(id);
|
|
@@ -217,7 +281,6 @@ export class CssCompiler {
|
|
|
217
281
|
return null;
|
|
218
282
|
}
|
|
219
283
|
async #scanCandidates(sourcePaths: string[], dirs: string[]): Promise<string[]> {
|
|
220
|
-
const CANDIDATE_RE = /-?[\w@][\w:/.-]*(?:\[[^\]]+\][\w:/.-]*)*/g;
|
|
221
284
|
const candidates = new Set<string>();
|
|
222
285
|
const glob = new Bun.Glob("**/*.{tsx,ts,jsx,js,html}");
|
|
223
286
|
const files = new Set<string>(sourcePaths);
|
|
@@ -229,29 +292,18 @@ export class CssCompiler {
|
|
|
229
292
|
}
|
|
230
293
|
}),
|
|
231
294
|
);
|
|
295
|
+
const cache = await new CssCandidateCache(this.#candidateCachePath).load();
|
|
232
296
|
await Promise.all(
|
|
233
297
|
[...files].map(async (file) => {
|
|
234
|
-
const
|
|
235
|
-
for (const m of content.matchAll(CANDIDATE_RE)) candidates.add(m[0]);
|
|
298
|
+
for (const candidate of await cache.candidatesFor(file)) candidates.add(candidate);
|
|
236
299
|
}),
|
|
237
300
|
);
|
|
301
|
+
await cache.save(files);
|
|
302
|
+
this.#logger.verbose(`css candidate cache reused=${cache.reused} rescanned=${cache.rescanned}`);
|
|
238
303
|
return [...candidates];
|
|
239
304
|
}
|
|
240
305
|
}
|
|
241
306
|
|
|
242
|
-
async function resolveSourceFileCandidate(absPathNoExt: string): Promise<string | null> {
|
|
243
|
-
if (await Bun.file(absPathNoExt).exists()) return isSourceFile(absPathNoExt) ? absPathNoExt : null;
|
|
244
|
-
for (const ext of SOURCE_EXTS) {
|
|
245
|
-
const filePath = `${absPathNoExt}${ext}`;
|
|
246
|
-
if (await Bun.file(filePath).exists()) return filePath;
|
|
247
|
-
}
|
|
248
|
-
for (const ext of SOURCE_EXTS) {
|
|
249
|
-
const filePath = path.join(absPathNoExt, `index${ext}`);
|
|
250
|
-
if (await Bun.file(filePath).exists()) return filePath;
|
|
251
|
-
}
|
|
252
|
-
return null;
|
|
253
|
-
}
|
|
254
|
-
|
|
255
307
|
function resolveSourceWithBun(id: string, fromBase: string): string | null {
|
|
256
308
|
try {
|
|
257
309
|
const resolved = Bun.resolveSync(id, fromBase);
|
|
@@ -417,6 +417,9 @@ describe("CssCompiler", () => {
|
|
|
417
417
|
|
|
418
418
|
const compiler = new CssCompiler({
|
|
419
419
|
workspace: { workspaceRoot: root },
|
|
420
|
+
// The candidate cache lands under `<cwdPath>/.akan/cache`, so point it at this test's temp root
|
|
421
|
+
// rather than wherever the suite happens to run from.
|
|
422
|
+
cwdPath: root,
|
|
420
423
|
getTsConfig: async () => ({ compilerOptions: { paths: {} } }),
|
|
421
424
|
} as never);
|
|
422
425
|
const css = await compiler.compileCss([cssPath], []);
|
|
@@ -264,10 +264,16 @@ export class HmrWatcher {
|
|
|
264
264
|
* Terminates rather than looping: a scan that finds nothing schedules nothing, and the build writes its
|
|
265
265
|
* artifacts under `.akan/` — which the classifier ignores — while codegen writes are content-guarded,
|
|
266
266
|
* so a rebuild does not move a tracked mtime.
|
|
267
|
+
*
|
|
268
|
+
* The one case that does schedule another is a directory the index could not date reliably (Linux stamps
|
|
269
|
+
* directory mtimes on a 1ms clock, see `SourceMtimeIndex`). That still terminates: the next scan is
|
|
270
|
+
* `verifyDelayMs` later, by which point the timestamp has settled unless something is writing to that
|
|
271
|
+
* directory right now — in which case looking again is the right answer anyway.
|
|
267
272
|
*/
|
|
268
273
|
async #verify(): Promise<void> {
|
|
269
274
|
if (this.#stopped || this.#flushing) return;
|
|
270
275
|
await this.#mergeDetectedChanges();
|
|
271
276
|
if (this.#pending.size > 0) await this.#drain();
|
|
277
|
+
else if (this.#index.hasUnsettledDirs) this.#scheduleVerify();
|
|
272
278
|
}
|
|
273
279
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
-
import { chmod, mkdir, mkdtemp, readdir, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { chmod, mkdir, mkdtemp, readdir, rm, stat, utimes, writeFile } from "node:fs/promises";
|
|
3
3
|
import os from "node:os";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { SourceMtimeIndex } from "./sourceMtimeIndex";
|
|
@@ -100,6 +100,51 @@ describe("SourceMtimeIndex", () => {
|
|
|
100
100
|
expect(await index.collectChanges()).toEqual([]);
|
|
101
101
|
});
|
|
102
102
|
|
|
103
|
+
/**
|
|
104
|
+
* Linux stamps directory mtimes from a coarse clock, so a mutation landing in the same tick as the
|
|
105
|
+
* value the index recorded leaves that value byte-identical: measured under Docker, 319 of 400
|
|
106
|
+
* back-to-back `mkdir`s never moved the parent's mtime on overlayfs and 324 of 400 on ext4, while APFS
|
|
107
|
+
* missed none. `utimes` reproduces that here rather than leaving it to the host's clock resolution —
|
|
108
|
+
* otherwise this passes on macOS for the wrong reason and is flaky on the Linux fleet.
|
|
109
|
+
*
|
|
110
|
+
* `dirSettleMs` is pinned wide so the assertion is about the mechanism, not about how many milliseconds
|
|
111
|
+
* the lines above happened to take.
|
|
112
|
+
*/
|
|
113
|
+
test("finds a created directory even when the clock never moves the parent's mtime", async () => {
|
|
114
|
+
const root = await makeRoot();
|
|
115
|
+
await seed(root, "lib/a.ts");
|
|
116
|
+
const dir = path.join(root, "lib");
|
|
117
|
+
// Pinned before priming too: `utimes` keeps whole milliseconds but drops APFS's sub-millisecond part,
|
|
118
|
+
// so stamping both sides is what makes "the mtime did not move" exact rather than 0.5ms apart.
|
|
119
|
+
const frozen = new Date();
|
|
120
|
+
await utimes(dir, frozen, frozen);
|
|
121
|
+
const index = new SourceMtimeIndex({ roots: [root], dirSettleMs: 60_000 });
|
|
122
|
+
await index.prime();
|
|
123
|
+
|
|
124
|
+
const created = await seed(root, "lib/user/user.constant.ts");
|
|
125
|
+
await utimes(dir, frozen, frozen);
|
|
126
|
+
expect((await stat(dir)).mtimeMs).toBe(frozen.getTime());
|
|
127
|
+
|
|
128
|
+
expect(index.hasUnsettledDirs).toBe(true);
|
|
129
|
+
expect(await index.collectChanges()).toEqual([created]);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("stops re-reading a directory once its mtime is old enough to trust", async () => {
|
|
133
|
+
const root = await makeRoot();
|
|
134
|
+
await seed(root, "lib/a.ts");
|
|
135
|
+
const index = new SourceMtimeIndex({ roots: [root], dirSettleMs: 60_000 });
|
|
136
|
+
await index.prime();
|
|
137
|
+
expect(index.hasUnsettledDirs).toBe(true);
|
|
138
|
+
|
|
139
|
+
// The retry compensates for a timestamp that is too fresh to trust; it must not become a standing
|
|
140
|
+
// full walk once the tree settles.
|
|
141
|
+
const settled = new Date(Date.now() - 120_000);
|
|
142
|
+
for (const dir of [root, path.join(root, "lib")]) await utimes(dir, settled, settled);
|
|
143
|
+
|
|
144
|
+
expect(await index.collectChanges()).toEqual([]);
|
|
145
|
+
expect(index.hasUnsettledDirs).toBe(false);
|
|
146
|
+
});
|
|
147
|
+
|
|
103
148
|
test("reports a deleted file and a deleted directory's files", async () => {
|
|
104
149
|
const root = await makeRoot();
|
|
105
150
|
const kept = await seed(root, "lib/a.ts");
|
|
@@ -256,7 +301,11 @@ describe("SourceMtimeIndex", () => {
|
|
|
256
301
|
// goes unreported.
|
|
257
302
|
expect(await index.collectChanges()).toEqual([]);
|
|
258
303
|
expect(index.trackedFileCount).toBe(1);
|
|
259
|
-
|
|
304
|
+
// The locked directory joins the list too whenever this scan happened to re-read it — which depends
|
|
305
|
+
// on how fresh its mtime still was (see `dirSettleMs`) — so assert what the gap is about rather than
|
|
306
|
+
// how many entries happen to describe it.
|
|
307
|
+
expect(index.coverageGaps.map((gap) => gap.path)).toContain(abs);
|
|
308
|
+
expect(index.coverageGaps.every((gap) => gap.code === "EACCES")).toBe(true);
|
|
260
309
|
|
|
261
310
|
await chmod(locked, 0o755);
|
|
262
311
|
await rewrite(abs, "1234");
|