From 75a1c23d4065230839359d038ca5d70c751da4bb Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Sun, 1 Nov 2026 09:27:53 +0000 Subject: [PATCH 1/4] perf(dev): reuse the vfile cache in hyperbook dev; cache runtimes in the GitHub Pages workflow examples hyperbook dev now enables HYPERBOOK_CACHE by default, so a restarted dev server reuses the persisted directory listings instead of re-walking the book. The GitHub Pages workflow examples (en/de) gain the actions/cache step for the element runtime cache, matching the Caching page. --- .changeset/dev-cache-ghpages.md | 5 +++++ packages/hyperbook/dev.ts | 9 +++++++++ website/de/book/hosting/caching.md | 5 ++++- website/de/book/hosting/ghpages.md | 10 ++++++++++ website/en/book/hosting/caching.md | 3 +++ website/en/book/hosting/ghpages.md | 10 ++++++++++ 6 files changed, 41 insertions(+), 1 deletion(-) create mode 100644 .changeset/dev-cache-ghpages.md diff --git a/.changeset/dev-cache-ghpages.md b/.changeset/dev-cache-ghpages.md new file mode 100644 index 000000000..8b9d9abe9 --- /dev/null +++ b/.changeset/dev-cache-ghpages.md @@ -0,0 +1,5 @@ +--- +"hyperbook": patch +--- + +`hyperbook dev` now enables the on-disk vfile cache (`HYPERBOOK_CACHE`) by default, so a restarted dev server reuses the persisted directory listings (`vdirectory.*.json`, `vfiles.json`) instead of re-walking and re-reading the whole book on the initial build. The files are deleted on every change and ignored by the watcher, so they never trigger a rebuild. The GitHub Pages workflow examples in the documentation now also cache the downloaded element runtimes between runs with `actions/cache` and `HYPERBOOK_ASSET_CACHE`, matching the guidance on the Caching page. diff --git a/packages/hyperbook/dev.ts b/packages/hyperbook/dev.ts index d0ea6b971..199725821 100644 --- a/packages/hyperbook/dev.ts +++ b/packages/hyperbook/dev.ts @@ -136,6 +136,15 @@ export async function runDev({ port: number; offline?: boolean; }): Promise { + // Persist the vfile directory caches on disk (vdirectory.*.json, vfiles.json) + // so a restarted dev server does not re-walk and re-read the whole book. + // vfile.clean removes them on every change, and the watcher below ignores + // dot-prefixed paths, so the cache files never trigger a rebuild. An + // explicit HYPERBOOK_CACHE in the environment wins. + if (!process.env.HYPERBOOK_CACHE) { + process.env.HYPERBOOK_CACHE = "1"; + } + // Check if the port is available const portAvailable = await isPortAvailable(port); diff --git a/website/de/book/hosting/caching.md b/website/de/book/hosting/caching.md index 9645f07b5..e997d0b9d 100644 --- a/website/de/book/hosting/caching.md +++ b/website/de/book/hosting/caching.md @@ -7,7 +7,10 @@ lang: de Der Build-Cache von Hyperbook speichert heruntergeladene Laufzeitumgebungen auf dem Rechner der CLI. HTTP-Caches speichern Dateien, die Leser von deinem Host -oder einem CDN abrufen. +oder einem CDN abrufen. Der Entwicklungsserver legt außerdem seine Dateilisten +(`vdirectory.*.json`, `vfiles.json`) im Projektverzeichnis ab, damit ein Neustart +dort weitermacht, wo die letzte Sitzung aufgehört hat; beim Ändern einer Datei +löscht er sie wieder. ## Downloads zwischen Builds aufbewahren diff --git a/website/de/book/hosting/ghpages.md b/website/de/book/hosting/ghpages.md index 0ee5a73c5..14c4d30c2 100644 --- a/website/de/book/hosting/ghpages.md +++ b/website/de/book/hosting/ghpages.md @@ -32,7 +32,17 @@ jobs: steps: - uses: actions/checkout@v2 + - name: Hyperbook-Laufzeitumgebungen zwischenspeichern + uses: actions/cache@v4 + with: + path: .cache/hyperbook-assets + key: ${{ runner.os }}-hyperbook-assets-${{ hashFiles('package-lock.json', 'pnpm-lock.yaml', 'hyperbook.json') }}-${{ github.sha }} + restore-keys: | + ${{ runner.os }}-hyperbook-assets- + - name: Build + env: + HYPERBOOK_ASSET_CACHE: .cache/hyperbook-assets run: | npx hyperbook build diff --git a/website/en/book/hosting/caching.md b/website/en/book/hosting/caching.md index 75dc019d7..e42740bc2 100644 --- a/website/en/book/hosting/caching.md +++ b/website/en/book/hosting/caching.md @@ -6,6 +6,9 @@ name: Caching Hyperbook's build cache stores downloaded runtimes on the machine running the CLI. HTTP caches store the files readers request from your host or a CDN. +The development server also keeps its file listings (`vdirectory.*.json`, +`vfiles.json`) in the project directory, so a restart picks up where the last +session left off; it deletes them whenever a file changes. ## Cache runtime downloads between builds diff --git a/website/en/book/hosting/ghpages.md b/website/en/book/hosting/ghpages.md index e6f9d536c..42ce457ae 100644 --- a/website/en/book/hosting/ghpages.md +++ b/website/en/book/hosting/ghpages.md @@ -30,7 +30,17 @@ jobs: steps: - uses: actions/checkout@v2 + - name: Cache Hyperbook runtimes + uses: actions/cache@v4 + with: + path: .cache/hyperbook-assets + key: ${{ runner.os }}-hyperbook-assets-${{ hashFiles('package-lock.json', 'pnpm-lock.yaml', 'hyperbook.json') }}-${{ github.sha }} + restore-keys: | + ${{ runner.os }}-hyperbook-assets- + - name: Build + env: + HYPERBOOK_ASSET_CACHE: .cache/hyperbook-assets run: | npx hyperbook build From 127c4665ef5d72460f7878d1204b3c45a3413094 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 1 Nov 2026 13:00:40 +0000 Subject: [PATCH 2/4] fix(dev): reuse fresh persisted vfile listings on the initial dev build --- .changeset/dev-cache-ghpages.md | 3 ++- packages/fs/src/vfile.ts | 40 +++++++++++++++++++++++++++++++ packages/fs/tests/vfile.test.ts | 22 +++++++++++++++++ packages/hyperbook/build.ts | 17 +++++++++++-- packages/hyperbook/incremental.ts | 5 ++-- 5 files changed, 82 insertions(+), 5 deletions(-) diff --git a/.changeset/dev-cache-ghpages.md b/.changeset/dev-cache-ghpages.md index 8b9d9abe9..d809fb36d 100644 --- a/.changeset/dev-cache-ghpages.md +++ b/.changeset/dev-cache-ghpages.md @@ -1,5 +1,6 @@ --- "hyperbook": patch +"@hyperbook/fs": patch --- -`hyperbook dev` now enables the on-disk vfile cache (`HYPERBOOK_CACHE`) by default, so a restarted dev server reuses the persisted directory listings (`vdirectory.*.json`, `vfiles.json`) instead of re-walking and re-reading the whole book on the initial build. The files are deleted on every change and ignored by the watcher, so they never trigger a rebuild. The GitHub Pages workflow examples in the documentation now also cache the downloaded element runtimes between runs with `actions/cache` and `HYPERBOOK_ASSET_CACHE`, matching the guidance on the Caching page. +`hyperbook dev` now enables the on-disk vfile cache (`HYPERBOOK_CACHE`) by default, so a restarted dev server reuses the persisted directory listings (`vdirectory.*.json`, `vfiles.json`) instead of re-walking and re-reading the whole book on the initial build, as long as nothing in the book changed since they were written. The files are deleted on every change and ignored by the watcher, so they never trigger a rebuild. The GitHub Pages workflow examples in the documentation now also cache the downloaded element runtimes between runs with `actions/cache` and `HYPERBOOK_ASSET_CACHE`, matching the guidance on the Caching page. diff --git a/packages/fs/src/vfile.ts b/packages/fs/src/vfile.ts index ff1bd9834..d7693c6f3 100644 --- a/packages/fs/src/vfile.ts +++ b/packages/fs/src/vfile.ts @@ -837,6 +837,46 @@ export const clean = async (root: string): Promise => { }); }; +/** + * Whether the persisted listings under `root` can still be trusted: they must + * exist and nothing in the book may have been modified after the oldest of + * them was written. Only stats files and directories (a directory's mtime + * moves when entries are added, removed or renamed), so it is far cheaper than + * rebuilding the listings. Catches edits made while no watcher was running. + */ +export const isCacheFresh = async (root: string): Promise => { + const cacheFiles = [ + path.join(root, "vfiles.json"), + ...folders.map((folder) => path.join(root, `vdirectory.${folder}.json`)), + ].filter((file) => fsD.existsSync(file)); + if (!cacheFiles.includes(path.join(root, "vfiles.json"))) return false; + + let cachedAt = Infinity; + for (const file of cacheFiles) { + cachedAt = Math.min(cachedAt, (await fs.stat(file)).mtimeMs); + } + + const isNewer = async (target: string): Promise => { + let stat; + try { + stat = await fs.stat(target); + } catch { + return false; + } + if (stat.mtimeMs >= cachedAt) return true; + if (!stat.isDirectory()) return false; + for (const entry of await fs.readdir(target)) { + if (await isNewer(path.join(target, entry))) return true; + } + return false; + }; + + for (const entry of [...folders, "hyperbook.json", "hyperbook.yml"]) { + if (await isNewer(path.join(root, entry))) return false; + } + return true; +}; + export const extractLines = ( content: string, lines?: string, diff --git a/packages/fs/tests/vfile.test.ts b/packages/fs/tests/vfile.test.ts index 1dfa61d44..c3d69c2ed 100644 --- a/packages/fs/tests/vfile.test.ts +++ b/packages/fs/tests/vfile.test.ts @@ -225,3 +225,25 @@ public class SpriteGetCurrentCostumeIndex { expect(result).toEqual(extracted); }); }); + +describe("isCacheFresh", () => { + it("detects edits made after the cache was written", async () => { + const fsp = await import("fs/promises"); + const os = await import("os"); + const root = await fsp.mkdtemp(path.join(os.tmpdir(), "hb-cache-")); + await fsp.mkdir(path.join(root, "book")); + await fsp.writeFile(path.join(root, "book", "a.md"), "# a"); + const past = new Date(Date.now() - 10000); + await fsp.utimes(path.join(root, "book", "a.md"), past, past); + await fsp.utimes(path.join(root, "book"), past, past); + expect(await vfile.isCacheFresh(root)).toBe(false); + + await fsp.writeFile(path.join(root, "vfiles.json"), "[]"); + expect(await vfile.isCacheFresh(root)).toBe(true); + + const future = new Date(Date.now() + 10000); + await fsp.utimes(path.join(root, "book", "a.md"), future, future); + expect(await vfile.isCacheFresh(root)).toBe(false); + await fsp.rm(root, { recursive: true }); + }); +}); diff --git a/packages/hyperbook/build.ts b/packages/hyperbook/build.ts index 0fe80697c..fa907dd49 100644 --- a/packages/hyperbook/build.ts +++ b/packages/hyperbook/build.ts @@ -504,6 +504,7 @@ export async function runBuildProject( filter?: string, onPage?: PageResultSink, assets = new AssetManager(), + reuseCache = false, ): Promise { const name = hyperproject.getName(project); if (project.type === "book") { @@ -517,6 +518,7 @@ export async function runBuildProject( filter, onPage, assets, + reuseCache, ); } else { console.log(`${chalk.cyan(`[${name}]`)} Building Library.`); @@ -525,7 +527,15 @@ export async function runBuildProject( } await rimraf(path.join(out, ".hyperbook", "out")); for (const p of project.projects) { - await runBuildProject(p, rootProject, out, filter, onPage, assets); + await runBuildProject( + p, + rootProject, + out, + filter, + onPage, + assets, + reuseCache, + ); } } } @@ -737,6 +747,7 @@ async function runBuild( filter?: string, onPage?: PageResultSink, assets = new AssetManager(), + reuseCache = false, ): Promise { console.log(`${chalk.blue(`[${prefix}]`)} Reading hyperbook.json.`); const hyperbookJson = await hyperbook.getJson(root); @@ -755,7 +766,9 @@ async function runBuild( hyperbookJson.basePath = basePath; - vfile.clean(root); + if (!(reuseCache && (await vfile.isCacheFresh(root)))) { + vfile.clean(root); + } let link: Link | undefined = undefined; if (rootProject.type === "library") { diff --git a/packages/hyperbook/incremental.ts b/packages/hyperbook/incremental.ts index 928f1a8c1..e3122794d 100644 --- a/packages/hyperbook/incremental.ts +++ b/packages/hyperbook/incremental.ts @@ -128,7 +128,7 @@ export class IncrementalBuilder { async initialize(): Promise { console.log(`${chalk.yellow("[Incremental]")} Full initial build...`); - await this.fullRebuild(); + await this.fullRebuild(!!process.env.HYPERBOOK_CACHE); // Populate caches after full build await this.refreshCaches(); @@ -142,7 +142,7 @@ export class IncrementalBuilder { * Full rebuild that also repopulates the dependency graph and the cached * search documents, so incremental updates afterwards start from the truth. */ - private async fullRebuild(): Promise { + private async fullRebuild(reuseCache = false): Promise { this.dependents.clear(); this.searchDocuments.clear(); this.archiveIncludes.clear(); @@ -158,6 +158,7 @@ export class IncrementalBuilder { this.recordPageResult(href, result); }, this.assets, + reuseCache, ); } From 68b1e4824e92be9191e51b812fbc1f1da9c2ea6b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 1 Nov 2026 13:02:38 +0000 Subject: [PATCH 3/4] fix(dev): keep an explicitly empty HYPERBOOK_CACHE as an opt-out Co-authored-by: mikebarkmin <2592379+mikebarkmin@users.noreply.github.com> --- packages/hyperbook/dev.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/hyperbook/dev.ts b/packages/hyperbook/dev.ts index 199725821..796545c11 100644 --- a/packages/hyperbook/dev.ts +++ b/packages/hyperbook/dev.ts @@ -140,8 +140,8 @@ export async function runDev({ // so a restarted dev server does not re-walk and re-read the whole book. // vfile.clean removes them on every change, and the watcher below ignores // dot-prefixed paths, so the cache files never trigger a rebuild. An - // explicit HYPERBOOK_CACHE in the environment wins. - if (!process.env.HYPERBOOK_CACHE) { + // explicit HYPERBOOK_CACHE in the environment wins, and an empty value opts out. + if (process.env.HYPERBOOK_CACHE === undefined) { process.env.HYPERBOOK_CACHE = "1"; } From 9d5b955ca390fed82385f32dd28a957b8fb32126 Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Sun, 1 Nov 2026 15:36:39 +0000 Subject: [PATCH 4/4] docs(changeset): mention isCacheFresh and the empty-value opt-out --- .changeset/dev-cache-ghpages.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/dev-cache-ghpages.md b/.changeset/dev-cache-ghpages.md index d809fb36d..c07633f30 100644 --- a/.changeset/dev-cache-ghpages.md +++ b/.changeset/dev-cache-ghpages.md @@ -3,4 +3,4 @@ "@hyperbook/fs": patch --- -`hyperbook dev` now enables the on-disk vfile cache (`HYPERBOOK_CACHE`) by default, so a restarted dev server reuses the persisted directory listings (`vdirectory.*.json`, `vfiles.json`) instead of re-walking and re-reading the whole book on the initial build, as long as nothing in the book changed since they were written. The files are deleted on every change and ignored by the watcher, so they never trigger a rebuild. The GitHub Pages workflow examples in the documentation now also cache the downloaded element runtimes between runs with `actions/cache` and `HYPERBOOK_ASSET_CACHE`, matching the guidance on the Caching page. +`hyperbook dev` now enables the on-disk vfile cache (`HYPERBOOK_CACHE`) by default, so a restarted dev server reuses the persisted directory listings (`vdirectory.*.json`, `vfiles.json`) instead of re-walking and re-reading the whole book on the initial build. The new `vfile.isCacheFresh` check rejects persisted listings when anything in the book or `hyperbook.json` changed while the server was not running; `vfile.clean` still deletes them on every change during a session, and the watcher ignores them, so the cache files never trigger a rebuild. Setting `HYPERBOOK_CACHE` to an empty value opts out. The GitHub Pages workflow examples in the documentation now also cache the downloaded element runtimes between runs with `actions/cache` and `HYPERBOOK_ASSET_CACHE`, matching the guidance on the Caching page.