@akanjs/devkit 2.4.1-rc.2 → 2.4.1-rc.4
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/akanApp/akanApp.host.test.ts +283 -2
- package/akanApp/akanApp.host.ts +578 -12
- package/executors.test.ts +60 -0
- package/executors.ts +11 -0
- package/frontendBuild/fontOptimizer.test.ts +111 -0
- package/frontendBuild/fontOptimizer.ts +102 -17
- package/frontendBuild/hmrWatcher.test.ts +191 -0
- package/frontendBuild/hmrWatcher.ts +176 -5
- package/frontendBuild/index.ts +1 -0
- package/frontendBuild/sourceMtimeIndex.test.ts +280 -0
- package/frontendBuild/sourceMtimeIndex.ts +326 -0
- package/incrementalBuilder/buildBatch.proc.ts +194 -0
- package/incrementalBuilder/buildBatchProtocol.ts +53 -0
- package/incrementalBuilder/buildBatchRunner.ts +85 -0
- package/incrementalBuilder/builderReply.test.ts +73 -0
- package/incrementalBuilder/builderReply.ts +30 -0
- package/incrementalBuilder/incrementalBuilder.host.test.ts +199 -9
- package/incrementalBuilder/incrementalBuilder.host.ts +119 -1
- package/incrementalBuilder/incrementalBuilder.proc.ts +260 -170
- package/integration/devStability.integration.test.ts +308 -78
- package/integration/devStabilityHarness.test.ts +111 -0
- package/integration/devStabilityHarness.ts +555 -40
- package/local/optimize-resource/ipcprobe/child.ts +1 -0
- package/local/optimize-resource/ipcprobe/parent.ts +11 -0
- package/package.json +2 -2
package/executors.test.ts
CHANGED
|
@@ -271,6 +271,66 @@ describe("Workspace and app executor environment contracts", () => {
|
|
|
271
271
|
expect((await stat(path.join(root, "dist/apps/demo/public"))).isDirectory()).toBe(true);
|
|
272
272
|
});
|
|
273
273
|
|
|
274
|
+
describe("getDevPort", () => {
|
|
275
|
+
const makeWorkspaceWithApps = async (names: string[]) => {
|
|
276
|
+
const root = await makeTempRoot();
|
|
277
|
+
process.env.AKAN_PUBLIC_REPO_NAME = "repo";
|
|
278
|
+
process.env.AKAN_PUBLIC_SERVE_DOMAIN = "example.com";
|
|
279
|
+
process.env.AKAN_PUBLIC_ENV = "local";
|
|
280
|
+
process.env.PORT_OFFSET = "0";
|
|
281
|
+
await writeJson(path.join(root, "package.json"), rootPackageJson());
|
|
282
|
+
for (const name of names) {
|
|
283
|
+
await mkdir(path.join(root, "apps", name), { recursive: true });
|
|
284
|
+
await writeFile(path.join(root, "apps", name, "akan.config.ts"), "export default {};\n");
|
|
285
|
+
}
|
|
286
|
+
// `AppExecutor.from` memoises by name, so each test needs names no other test has used.
|
|
287
|
+
return new WorkspaceExecutor({ workspaceRoot: root, repoName: "repo" });
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
test("derives the port from the app's position in the sorted apps listing", async () => {
|
|
291
|
+
const workspace = await makeWorkspaceWithApps(["port-a", "port-b"]);
|
|
292
|
+
|
|
293
|
+
expect(await AppExecutor.from(workspace, "port-a").getDevPort()).toBe(8282);
|
|
294
|
+
expect(await AppExecutor.from(workspace, "port-b").getDevPort()).toBe(8283);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
test("moves when another app appears before it, which is why pinning exists", async () => {
|
|
298
|
+
const workspace = await makeWorkspaceWithApps(["drift-b"]);
|
|
299
|
+
const app = AppExecutor.from(workspace, "drift-b");
|
|
300
|
+
expect(await app.getDevPort()).toBe(8282);
|
|
301
|
+
|
|
302
|
+
// Sorts ahead of `drift-b`, so the same app now answers with a different port — and a dev host
|
|
303
|
+
// recomputes this on every restart.
|
|
304
|
+
await mkdir(path.join(workspace.workspaceRoot, "apps/drift-a"), { recursive: true });
|
|
305
|
+
await writeFile(path.join(workspace.workspaceRoot, "apps/drift-a/akan.config.ts"), "export default {};\n");
|
|
306
|
+
|
|
307
|
+
expect(await app.getDevPort()).toBe(8283);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
test("AKAN_DEV_PORT pins it, and survives an app appearing before it", async () => {
|
|
311
|
+
const workspace = await makeWorkspaceWithApps(["pin-b"]);
|
|
312
|
+
const app = AppExecutor.from(workspace, "pin-b");
|
|
313
|
+
process.env.AKAN_DEV_PORT = "12345";
|
|
314
|
+
|
|
315
|
+
expect(await app.getDevPort()).toBe(12345);
|
|
316
|
+
|
|
317
|
+
await mkdir(path.join(workspace.workspaceRoot, "apps/pin-a"), { recursive: true });
|
|
318
|
+
await writeFile(path.join(workspace.workspaceRoot, "apps/pin-a/akan.config.ts"), "export default {};\n");
|
|
319
|
+
|
|
320
|
+
expect(await app.getDevPort()).toBe(12345);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
test("ignores an unusable AKAN_DEV_PORT rather than binding a nonsense port", async () => {
|
|
324
|
+
const workspace = await makeWorkspaceWithApps(["bad-a"]);
|
|
325
|
+
const app = AppExecutor.from(workspace, "bad-a");
|
|
326
|
+
|
|
327
|
+
for (const value of ["0", "-1", "nope", "", "70000", "8282.5"]) {
|
|
328
|
+
process.env.AKAN_DEV_PORT = value;
|
|
329
|
+
expect(await app.getDevPort()).toBe(8282);
|
|
330
|
+
}
|
|
331
|
+
});
|
|
332
|
+
});
|
|
333
|
+
|
|
274
334
|
test("accepts metadata route exports during page key discovery", async () => {
|
|
275
335
|
const root = await makeTempRoot();
|
|
276
336
|
process.env.AKAN_PUBLIC_REPO_NAME = "repo";
|
package/executors.ts
CHANGED
|
@@ -1256,7 +1256,18 @@ export class AppExecutor extends SysExecutor {
|
|
|
1256
1256
|
getEnv() {
|
|
1257
1257
|
return WorkspaceExecutor.getBaseDevEnv().env;
|
|
1258
1258
|
}
|
|
1259
|
+
/**
|
|
1260
|
+
* This app's dev port, derived from its position in the sorted `apps/` listing so several apps can run
|
|
1261
|
+
* at once without colliding.
|
|
1262
|
+
*
|
|
1263
|
+
* `AKAN_DEV_PORT` pins it instead, because the derived value *moves*: the index shifts whenever any other
|
|
1264
|
+
* app directory appears or disappears, and a dev host recomputes this on every restart. So a session that
|
|
1265
|
+
* adds an app relands its dev server on a different port, and anything that reserved a port relative to
|
|
1266
|
+
* the old one is left pointing at nothing.
|
|
1267
|
+
*/
|
|
1259
1268
|
async getDevPort() {
|
|
1269
|
+
const pinned = Number(process.env.AKAN_DEV_PORT);
|
|
1270
|
+
if (Number.isInteger(pinned) && pinned > 0 && pinned <= 65_535) return pinned;
|
|
1260
1271
|
const basePort = 8282;
|
|
1261
1272
|
const appNames = (await this.workspace.getApps()).sort((a, b) => a.localeCompare(b));
|
|
1262
1273
|
const appIndex = Math.max(appNames.indexOf(this.name), 0);
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdir, mkdtemp, rm, stat, writeFile } from "node:fs/promises";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import type { App } from "../commandDecorators";
|
|
6
|
+
import { FontOptimizer } from "./fontOptimizer";
|
|
7
|
+
|
|
8
|
+
const SOURCE_FONT = path.resolve(import.meta.dir, "../../../../libs/shared/public/fonts/Assistant-Regular.woff2");
|
|
9
|
+
|
|
10
|
+
const tempRoots: string[] = [];
|
|
11
|
+
|
|
12
|
+
const makeApp = async (layoutSource: string) => {
|
|
13
|
+
const root = await mkdtemp(path.join(os.tmpdir(), "akan-devkit-font-"));
|
|
14
|
+
tempRoots.push(root);
|
|
15
|
+
const cwdPath = path.join(root, "apps/demo");
|
|
16
|
+
await mkdir(path.join(cwdPath, "page"), { recursive: true });
|
|
17
|
+
await mkdir(path.join(cwdPath, "public/fonts"), { recursive: true });
|
|
18
|
+
await writeFile(path.join(cwdPath, "page/_layout.tsx"), layoutSource);
|
|
19
|
+
await Bun.write(path.join(cwdPath, "public/fonts/Assistant-Regular.woff2"), Bun.file(SOURCE_FONT));
|
|
20
|
+
const app = {
|
|
21
|
+
cwdPath,
|
|
22
|
+
dist: { cwdPath: path.join(root, "dist/apps/demo") },
|
|
23
|
+
workspace: { workspaceRoot: root },
|
|
24
|
+
getPageKeys: async () => ["./_layout.tsx"],
|
|
25
|
+
verbose: () => undefined,
|
|
26
|
+
logger: { warn: () => undefined },
|
|
27
|
+
} as unknown as App;
|
|
28
|
+
return { app, cwdPath };
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const layoutWith = (extra = "") => `
|
|
32
|
+
export const fonts = [
|
|
33
|
+
{
|
|
34
|
+
name: "Assistant",${extra}
|
|
35
|
+
paths: [{ src: "/fonts/Assistant-Regular.woff2", weight: 400 }],
|
|
36
|
+
},
|
|
37
|
+
];
|
|
38
|
+
export default function Layout() {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
`;
|
|
42
|
+
|
|
43
|
+
const optimize = (app: App) => new FontOptimizer(app, "start").optimize();
|
|
44
|
+
|
|
45
|
+
afterEach(async () => {
|
|
46
|
+
await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe("FontOptimizer cache", () => {
|
|
50
|
+
test("reuses subset output instead of resubsetting an unchanged font", async () => {
|
|
51
|
+
const { app } = await makeApp(layoutWith());
|
|
52
|
+
|
|
53
|
+
const first = await optimize(app);
|
|
54
|
+
expect(first.files).toHaveLength(1);
|
|
55
|
+
expect(first.css).toContain("@font-face");
|
|
56
|
+
const writtenAt = (await stat(first.files[0])).mtimeMs;
|
|
57
|
+
|
|
58
|
+
const second = await optimize(app);
|
|
59
|
+
expect(second.files).toEqual(first.files);
|
|
60
|
+
expect(second.css).toBe(first.css);
|
|
61
|
+
expect(second.fonts).toEqual(first.fonts);
|
|
62
|
+
// The output was reused, not rewritten — the whole point of the cache.
|
|
63
|
+
expect((await stat(second.files[0])).mtimeMs).toBe(writtenAt);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("resubsets when the source font file changes", async () => {
|
|
67
|
+
const { app, cwdPath } = await makeApp(layoutWith());
|
|
68
|
+
const first = await optimize(app);
|
|
69
|
+
const firstBytes = await Bun.file(first.files[0]).bytes();
|
|
70
|
+
|
|
71
|
+
// Swapped for a different real font rather than corrupted, so the resubset itself still succeeds.
|
|
72
|
+
const sourcePath = path.join(cwdPath, "public/fonts/Assistant-Regular.woff2");
|
|
73
|
+
await Bun.write(sourcePath, Bun.file(path.resolve(path.dirname(SOURCE_FONT), "Assistant-Bold.woff2")));
|
|
74
|
+
|
|
75
|
+
const second = await optimize(app);
|
|
76
|
+
expect(second.files).toEqual(first.files);
|
|
77
|
+
expect(await Bun.file(second.files[0]).bytes()).not.toEqual(firstBytes);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("resubsets when a config field changes without changing the output filename", async () => {
|
|
81
|
+
const { app, cwdPath } = await makeApp(layoutWith());
|
|
82
|
+
const first = await optimize(app);
|
|
83
|
+
const writtenAt = (await stat(first.files[0])).mtimeMs;
|
|
84
|
+
|
|
85
|
+
// `className` never feeds the output filename hash, so only the cache key can catch it.
|
|
86
|
+
await writeFile(path.join(cwdPath, "page/_layout.tsx"), layoutWith(`\n className: "font-brand",`));
|
|
87
|
+
|
|
88
|
+
const second = await optimize(app);
|
|
89
|
+
expect(second.files).toEqual(first.files);
|
|
90
|
+
expect(second.css).not.toBe(first.css);
|
|
91
|
+
expect(second.css).toContain(".font-brand");
|
|
92
|
+
expect((await stat(second.files[0])).mtimeMs).not.toBe(writtenAt);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("ignores a cache whose output file is gone", async () => {
|
|
96
|
+
const { app } = await makeApp(layoutWith());
|
|
97
|
+
const first = await optimize(app);
|
|
98
|
+
await rm(first.files[0]);
|
|
99
|
+
|
|
100
|
+
const second = await optimize(app);
|
|
101
|
+
expect(second.files).toEqual(first.files);
|
|
102
|
+
expect(await Bun.file(second.files[0]).exists()).toBe(true);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("skips route files that never mention fonts", async () => {
|
|
106
|
+
const { app } = await makeApp("export default function Layout() {\n return null;\n}\n");
|
|
107
|
+
const result = await optimize(app);
|
|
108
|
+
expect(result.fonts).toEqual([]);
|
|
109
|
+
expect(result.files).toEqual([]);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { mkdir } from "node:fs/promises";
|
|
1
|
+
import { mkdir, stat } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import type {
|
|
4
4
|
ReactFont,
|
|
@@ -8,15 +8,7 @@ import type {
|
|
|
8
8
|
ReactFontStyle,
|
|
9
9
|
ReactFontSubset,
|
|
10
10
|
} from "akanjs/client";
|
|
11
|
-
import {
|
|
12
|
-
type FontCategory,
|
|
13
|
-
generateFontFace,
|
|
14
|
-
getMetricsForFamily,
|
|
15
|
-
readMetrics,
|
|
16
|
-
resolveCategoryFallbacks,
|
|
17
|
-
} from "fontaine";
|
|
18
|
-
import { createFont, woff2 } from "fonteditor-core";
|
|
19
|
-
import subsetFont from "subset-font";
|
|
11
|
+
import type { FontCategory } from "fontaine";
|
|
20
12
|
import ts from "typescript";
|
|
21
13
|
import type { App } from "../commandDecorators";
|
|
22
14
|
|
|
@@ -29,6 +21,17 @@ export interface OptimizeAppFontsResult {
|
|
|
29
21
|
files: string[];
|
|
30
22
|
}
|
|
31
23
|
|
|
24
|
+
/**
|
|
25
|
+
* Lets a boot that changed nothing about its fonts skip re-subsetting them. `files` is stored relative
|
|
26
|
+
* to the artifact root so the `build` and `start` roots keep independent, relocatable caches.
|
|
27
|
+
*/
|
|
28
|
+
interface FontOptimizerCache {
|
|
29
|
+
version: number;
|
|
30
|
+
key: string;
|
|
31
|
+
css: string;
|
|
32
|
+
files: string[];
|
|
33
|
+
}
|
|
34
|
+
|
|
32
35
|
export type FontOptimizerCommand = "build" | "start";
|
|
33
36
|
|
|
34
37
|
export class FontOptimizer {
|
|
@@ -40,6 +43,7 @@ export class FontOptimizer {
|
|
|
40
43
|
#woff2Ready: Promise<void> | null = null;
|
|
41
44
|
|
|
42
45
|
static #ksX1001Text: string | null = null;
|
|
46
|
+
static readonly #cacheVersion = 1;
|
|
43
47
|
|
|
44
48
|
constructor(app: App, command: FontOptimizerCommand = "start") {
|
|
45
49
|
this.#app = app;
|
|
@@ -49,13 +53,83 @@ export class FontOptimizer {
|
|
|
49
53
|
|
|
50
54
|
async optimize(): Promise<OptimizeAppFontsResult> {
|
|
51
55
|
const fonts = await this.discoverFonts();
|
|
56
|
+
const cacheKey = await this.#buildCacheKey(fonts);
|
|
57
|
+
const cached = cacheKey ? await this.#readCache(cacheKey) : null;
|
|
58
|
+
if (cached) {
|
|
59
|
+
this.#app.verbose(`[font] reused ${cached.files.length} cached file(s); skipped subsetting`);
|
|
60
|
+
return { css: cached.css, fonts, files: cached.files };
|
|
61
|
+
}
|
|
52
62
|
for (const font of fonts) {
|
|
53
63
|
if (!this.#isFontOptimizationEnabled(font)) continue;
|
|
54
64
|
await this.#optimizeFont(font);
|
|
55
65
|
}
|
|
56
66
|
const fontUtilityCss = this.#buildFontUtilityRules(fonts);
|
|
57
67
|
if (fontUtilityCss) this.#cssParts.push(fontUtilityCss);
|
|
58
|
-
|
|
68
|
+
const result = { css: this.#cssParts.join("\n"), fonts, files: this.#files };
|
|
69
|
+
if (cacheKey) await this.#writeCache(cacheKey, result);
|
|
70
|
+
return result;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
get #cachePath() {
|
|
74
|
+
return path.join(this.#artifactRoot, "fontCache.json");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Null means "do not cache this run": a source we cannot stat is a source whose staleness we cannot
|
|
79
|
+
* detect, and the uncached path is also the one that warns about it.
|
|
80
|
+
*/
|
|
81
|
+
async #buildCacheKey(fonts: ReactFont[]): Promise<string | null> {
|
|
82
|
+
const sources: unknown[] = [];
|
|
83
|
+
for (const font of fonts) {
|
|
84
|
+
if (!this.#isFontOptimizationEnabled(font)) continue;
|
|
85
|
+
for (const face of this.#getFontFaces(font)) {
|
|
86
|
+
const sourcePath = await this.#resolveFontSourcePath(face.src);
|
|
87
|
+
const stamp = sourcePath ? await this.#fileStamp(sourcePath) : null;
|
|
88
|
+
if (!stamp) return null;
|
|
89
|
+
sources.push({ optimizedSrc: face.optimizedSrc, ...stamp });
|
|
90
|
+
}
|
|
91
|
+
for (const filePath of font.subsetFiles ?? []) {
|
|
92
|
+
const abs = path.isAbsolute(filePath) ? filePath : path.join(this.#app.cwdPath, filePath);
|
|
93
|
+
const stamp = await this.#fileStamp(abs);
|
|
94
|
+
if (!stamp) return null;
|
|
95
|
+
sources.push({ subsetFile: filePath, ...stamp });
|
|
96
|
+
}
|
|
97
|
+
// `auto` derives the subset from app source text, which no font config hash can capture.
|
|
98
|
+
if (this.#getFontSubsets(font).includes("auto"))
|
|
99
|
+
sources.push({ autoSubsetText: this.#hashFontConfig(await this.#collectAutoSubsetText()) });
|
|
100
|
+
}
|
|
101
|
+
return this.#hashFontConfig({ version: FontOptimizer.#cacheVersion, fonts, sources });
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async #fileStamp(filePath: string): Promise<{ mtimeMs: number; size: number } | null> {
|
|
105
|
+
try {
|
|
106
|
+
const stats = await stat(filePath);
|
|
107
|
+
return { mtimeMs: Math.round(stats.mtimeMs), size: stats.size };
|
|
108
|
+
} catch {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async #readCache(key: string): Promise<{ css: string; files: string[] } | null> {
|
|
114
|
+
const cache = (await Bun.file(this.#cachePath)
|
|
115
|
+
.json()
|
|
116
|
+
.catch(() => null)) as FontOptimizerCache | null;
|
|
117
|
+
if (cache?.version !== FontOptimizer.#cacheVersion || cache.key !== key) return null;
|
|
118
|
+
const files = cache.files.map((relativePath) => path.join(this.#artifactRoot, relativePath));
|
|
119
|
+
for (const filePath of files) {
|
|
120
|
+
if (!(await Bun.file(filePath).exists())) return null;
|
|
121
|
+
}
|
|
122
|
+
return { css: cache.css, files };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async #writeCache(key: string, result: OptimizeAppFontsResult): Promise<void> {
|
|
126
|
+
const cache: FontOptimizerCache = {
|
|
127
|
+
version: FontOptimizer.#cacheVersion,
|
|
128
|
+
key,
|
|
129
|
+
css: result.css,
|
|
130
|
+
files: result.files.map((filePath) => path.relative(this.#artifactRoot, filePath)),
|
|
131
|
+
};
|
|
132
|
+
await Bun.write(this.#cachePath, JSON.stringify(cache));
|
|
59
133
|
}
|
|
60
134
|
|
|
61
135
|
async discoverFonts(): Promise<ReactFont[]> {
|
|
@@ -66,7 +140,11 @@ export class FontOptimizer {
|
|
|
66
140
|
const filePath = path.resolve(this.#app.cwdPath, "page", key);
|
|
67
141
|
const file = Bun.file(filePath);
|
|
68
142
|
if (!(await file.exists())) return;
|
|
69
|
-
|
|
143
|
+
const source = await file.text();
|
|
144
|
+
// A declaration named `fonts` cannot exist in text that never mentions it, and parsing the
|
|
145
|
+
// route files that never declare one is what a cached optimize() otherwise spends its time on.
|
|
146
|
+
if (!source.includes("fonts")) return;
|
|
147
|
+
fonts.push(...this.#extractFontsExport(source, filePath));
|
|
70
148
|
}),
|
|
71
149
|
);
|
|
72
150
|
return this.#dedupeFonts(fonts);
|
|
@@ -85,10 +163,7 @@ export class FontOptimizer {
|
|
|
85
163
|
await mkdir(path.dirname(outputPath), { recursive: true });
|
|
86
164
|
|
|
87
165
|
const sourceBuffer = Buffer.from(await Bun.file(sourcePath).arrayBuffer());
|
|
88
|
-
const outputBuffer =
|
|
89
|
-
font.subset === false
|
|
90
|
-
? await this.#convertToWoff2(sourceBuffer, sourcePath)
|
|
91
|
-
: await subsetFont(sourceBuffer, await this.#getSubsetText(font), { targetFormat: "woff2" });
|
|
166
|
+
const outputBuffer = await this.#buildFontBuffer(font, sourceBuffer, sourcePath);
|
|
92
167
|
await Bun.write(outputPath, outputBuffer);
|
|
93
168
|
this.#files.push(outputPath);
|
|
94
169
|
|
|
@@ -255,14 +330,23 @@ export class FontOptimizer {
|
|
|
255
330
|
return null;
|
|
256
331
|
}
|
|
257
332
|
|
|
333
|
+
/** `subset-font`, `fonteditor-core` and `fontaine` are imported here rather than at module scope so a
|
|
334
|
+
* cache hit — the common case once the cache exists — loads none of them. */
|
|
335
|
+
async #buildFontBuffer(font: ReactFont, sourceBuffer: Buffer, sourcePath: string) {
|
|
336
|
+
if (font.subset === false) return this.#convertToWoff2(sourceBuffer, sourcePath);
|
|
337
|
+
const { default: subsetFont } = await import("subset-font");
|
|
338
|
+
return subsetFont(sourceBuffer, await this.#getSubsetText(font), { targetFormat: "woff2" });
|
|
339
|
+
}
|
|
340
|
+
|
|
258
341
|
async #convertToWoff2(buffer: Buffer, sourcePath: string) {
|
|
342
|
+
const { createFont } = await import("fonteditor-core");
|
|
259
343
|
await this.#initWoff2();
|
|
260
344
|
const font = createFont(buffer, { type: this.#getFontType(sourcePath, buffer) });
|
|
261
345
|
return font.write({ type: "woff2", toBuffer: true });
|
|
262
346
|
}
|
|
263
347
|
|
|
264
348
|
async #initWoff2() {
|
|
265
|
-
this.#woff2Ready ??= woff2.init().then(() => undefined);
|
|
349
|
+
this.#woff2Ready ??= import("fonteditor-core").then(({ woff2 }) => woff2.init()).then(() => undefined);
|
|
266
350
|
return this.#woff2Ready;
|
|
267
351
|
}
|
|
268
352
|
|
|
@@ -363,6 +447,7 @@ export class FontOptimizer {
|
|
|
363
447
|
|
|
364
448
|
async #buildFontaineFallbackCss(font: ReactFont, face: ReactFontFace, outputPath: string) {
|
|
365
449
|
if (font.adjustFontFallback === false) return "";
|
|
450
|
+
const { generateFontFace, getMetricsForFamily, readMetrics, resolveCategoryFallbacks } = await import("fontaine");
|
|
366
451
|
const metrics = await readMetrics(outputPath).catch(() => null);
|
|
367
452
|
if (!metrics) return "";
|
|
368
453
|
const fallbacks = resolveCategoryFallbacks({
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { chmod, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import type { Logger } from "akanjs/common";
|
|
6
|
+
import { type ChangeBatch, HmrWatcher } from "./hmrWatcher";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* These run against the real Bun watcher rather than a fake, because the behaviour under test only
|
|
10
|
+
* exists there: Bun 1.3.14's recursive `fs.watch` reports about one path per ~200ms coalescing window and
|
|
11
|
+
* discards the rest (`local/optimize-resource/06-watcher-dropped-event.md`). A fake that delivered every
|
|
12
|
+
* event would pass no matter what the watcher did with them.
|
|
13
|
+
*/
|
|
14
|
+
const STREAM_WARMUP_MS = 600;
|
|
15
|
+
const SETTLE_MS = 1_500;
|
|
16
|
+
const TEST_TIMEOUT_MS = 15_000;
|
|
17
|
+
|
|
18
|
+
const started: HmrWatcher[] = [];
|
|
19
|
+
const roots: string[] = [];
|
|
20
|
+
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
21
|
+
/** Every level `HmrWatcher` can reach, so adding a log line cannot fail a test for the wrong reason. */
|
|
22
|
+
const silentLogger = {
|
|
23
|
+
trace: () => undefined,
|
|
24
|
+
verbose: () => undefined,
|
|
25
|
+
debug: () => undefined,
|
|
26
|
+
log: () => undefined,
|
|
27
|
+
info: () => undefined,
|
|
28
|
+
warn: () => undefined,
|
|
29
|
+
error: () => undefined,
|
|
30
|
+
} as unknown as Logger;
|
|
31
|
+
|
|
32
|
+
const makeRoot = async () => {
|
|
33
|
+
const root = await mkdtemp(path.join(os.tmpdir(), "akan-hmr-watcher-"));
|
|
34
|
+
roots.push(root);
|
|
35
|
+
return root;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const seed = async (root: string, rel: string, content = "export const x = 1;\n") => {
|
|
39
|
+
const abs = path.join(root, rel);
|
|
40
|
+
await mkdir(path.dirname(abs), { recursive: true });
|
|
41
|
+
await writeFile(abs, content);
|
|
42
|
+
return abs;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/** Start watching and let the FSEvents stream come up; events raised before it does are not delivered. */
|
|
46
|
+
const watch = async (root: string) => {
|
|
47
|
+
const batches: ChangeBatch[] = [];
|
|
48
|
+
const watcher = new HmrWatcher({
|
|
49
|
+
roots: [root],
|
|
50
|
+
logger: silentLogger,
|
|
51
|
+
onBatch: (batch) => void batches.push(batch),
|
|
52
|
+
});
|
|
53
|
+
started.push(watcher);
|
|
54
|
+
await watcher.start();
|
|
55
|
+
await sleep(STREAM_WARMUP_MS);
|
|
56
|
+
return { watcher, batches, seen: () => new Set(batches.flatMap((batch) => batch.files)) };
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
afterEach(async () => {
|
|
60
|
+
for (const watcher of started.splice(0)) watcher.stop();
|
|
61
|
+
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe("HmrWatcher", () => {
|
|
65
|
+
test(
|
|
66
|
+
"reports every file of a save-all",
|
|
67
|
+
async () => {
|
|
68
|
+
const root = await makeRoot();
|
|
69
|
+
const files = await Promise.all([0, 1, 2, 3, 4].map((i) => seed(root, `lib/File${i}.ts`)));
|
|
70
|
+
const { batches, seen, watcher } = await watch(root);
|
|
71
|
+
|
|
72
|
+
// No gaps between writes, so they share one coalescing window and Bun names at most one of them.
|
|
73
|
+
for (const [i, abs] of files.entries()) await writeFile(abs, `export const x = ${i}00;\n`);
|
|
74
|
+
await sleep(SETTLE_MS);
|
|
75
|
+
|
|
76
|
+
expect(batches.length).toBeGreaterThan(0);
|
|
77
|
+
for (const abs of files) expect([...seen()]).toContain(abs);
|
|
78
|
+
// Not asserted as non-zero: the point is that the outcome above holds whether or not Bun drops
|
|
79
|
+
// events, so this suite keeps passing if the upstream defect is ever fixed. The counter is the
|
|
80
|
+
// signal for when the compensation can be removed.
|
|
81
|
+
expect(watcher.unreportedChanges).toBeGreaterThanOrEqual(0);
|
|
82
|
+
},
|
|
83
|
+
TEST_TIMEOUT_MS,
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
test(
|
|
87
|
+
"reports a save that lands in the same window as a build's artifact burst",
|
|
88
|
+
async () => {
|
|
89
|
+
const root = await makeRoot();
|
|
90
|
+
const source = await seed(root, "lib/a.ts");
|
|
91
|
+
const { seen, batches } = await watch(root);
|
|
92
|
+
|
|
93
|
+
// What every build ends with: a burst under `.akan/`, which the classifier ignores. Before this
|
|
94
|
+
// was handled, the burst was the one path Bun reported and the save right after it was invisible.
|
|
95
|
+
const artifactDir = path.join(root, ".akan", "artifact", "server");
|
|
96
|
+
await mkdir(artifactDir, { recursive: true });
|
|
97
|
+
for (let i = 0; i < 60; i++) await writeFile(path.join(artifactDir, `chunk-${i}.js`), "x".repeat(8192));
|
|
98
|
+
await writeFile(path.join(artifactDir, "pages.js"), "y".repeat(4 * 1024 * 1024));
|
|
99
|
+
await writeFile(source, "export const x = 999;\n");
|
|
100
|
+
await sleep(SETTLE_MS);
|
|
101
|
+
|
|
102
|
+
expect([...seen()]).toContain(source);
|
|
103
|
+
// Exactly one batch for one save. Bun does deliver a real event for some paths the scan has already
|
|
104
|
+
// emitted, and counting both produced a second generation and a second build for no change.
|
|
105
|
+
expect(batches.filter((batch) => batch.files.includes(source))).toHaveLength(1);
|
|
106
|
+
},
|
|
107
|
+
TEST_TIMEOUT_MS,
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
test(
|
|
111
|
+
"raises no batch for build output alone",
|
|
112
|
+
async () => {
|
|
113
|
+
const root = await makeRoot();
|
|
114
|
+
await seed(root, "lib/a.ts");
|
|
115
|
+
const { batches } = await watch(root);
|
|
116
|
+
|
|
117
|
+
const artifactDir = path.join(root, ".akan", "artifact");
|
|
118
|
+
await mkdir(artifactDir, { recursive: true });
|
|
119
|
+
for (let i = 0; i < 20; i++) await writeFile(path.join(artifactDir, `chunk-${i}.js`), "x".repeat(4096));
|
|
120
|
+
await sleep(SETTLE_MS);
|
|
121
|
+
|
|
122
|
+
// The verification scan is triggered by ignored paths, so it must not invent work from them either.
|
|
123
|
+
expect(batches).toEqual([]);
|
|
124
|
+
},
|
|
125
|
+
TEST_TIMEOUT_MS,
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
test(
|
|
129
|
+
"classifies a stylesheet edit as css and a config edit as config",
|
|
130
|
+
async () => {
|
|
131
|
+
const root = await makeRoot();
|
|
132
|
+
const style = await seed(root, "ui/app.css", ".a{color:red}\n");
|
|
133
|
+
const config = await seed(root, "akan.config.ts", "export default {};\n");
|
|
134
|
+
const { batches } = await watch(root);
|
|
135
|
+
|
|
136
|
+
await writeFile(style, ".a{color:blue}\n");
|
|
137
|
+
await sleep(400);
|
|
138
|
+
await writeFile(config, "export default { basePaths: [] };\n");
|
|
139
|
+
await sleep(SETTLE_MS);
|
|
140
|
+
|
|
141
|
+
const kinds = new Set(batches.flatMap((batch) => [...batch.kinds]));
|
|
142
|
+
expect(kinds.has("css")).toBe(true);
|
|
143
|
+
expect(kinds.has("config")).toBe(true);
|
|
144
|
+
},
|
|
145
|
+
TEST_TIMEOUT_MS,
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
test(
|
|
149
|
+
"stops reporting after stop()",
|
|
150
|
+
async () => {
|
|
151
|
+
const root = await makeRoot();
|
|
152
|
+
const source = await seed(root, "lib/a.ts");
|
|
153
|
+
const { watcher, batches } = await watch(root);
|
|
154
|
+
|
|
155
|
+
watcher.stop();
|
|
156
|
+
await writeFile(source, "export const x = 5;\n");
|
|
157
|
+
await sleep(SETTLE_MS);
|
|
158
|
+
|
|
159
|
+
expect(batches).toEqual([]);
|
|
160
|
+
},
|
|
161
|
+
TEST_TIMEOUT_MS,
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
test.skipIf(process.getuid?.() === 0)(
|
|
165
|
+
"warns at startup when part of the tree cannot be read",
|
|
166
|
+
async () => {
|
|
167
|
+
const root = await makeRoot();
|
|
168
|
+
const hidden = await seed(root, "locked/b.ts");
|
|
169
|
+
await chmod(path.dirname(hidden), 0o000);
|
|
170
|
+
const warnings: string[] = [];
|
|
171
|
+
|
|
172
|
+
const watcher = new HmrWatcher({
|
|
173
|
+
roots: [root],
|
|
174
|
+
logger: { ...silentLogger, warn: (msg: string) => void warnings.push(msg) } as unknown as Logger,
|
|
175
|
+
onBatch: () => undefined,
|
|
176
|
+
});
|
|
177
|
+
started.push(watcher);
|
|
178
|
+
await watcher.start();
|
|
179
|
+
// Restored before asserting, not after: a failed assertion would otherwise leave a directory `rm`
|
|
180
|
+
// cannot traverse, and the leak would fail the *next* test instead of this one.
|
|
181
|
+
await chmod(path.dirname(hidden), 0o755);
|
|
182
|
+
|
|
183
|
+
// At startup rather than at the first save, because an unreadable root means edits under it never
|
|
184
|
+
// rebuild — waiting for a save to reveal that means waiting for the save that silently does nothing.
|
|
185
|
+
expect(warnings).toHaveLength(1);
|
|
186
|
+
expect(warnings[0]).toContain("will not rebuild");
|
|
187
|
+
expect(warnings[0]).toContain("EACCES");
|
|
188
|
+
},
|
|
189
|
+
TEST_TIMEOUT_MS,
|
|
190
|
+
);
|
|
191
|
+
});
|