diff --git a/CHANGELOG.md b/CHANGELOG.md index b595f8b9..a98296d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,13 @@ All notable changes to evjs are documented here. Releases follow [Semantic Versi ## [Unreleased] +### 🐛 Bug Fixes + +- **Client development HTML updates** — Regenerate Utoopack development HTML and + manifests when client rebuilds change the entry assets, so adding a component + dependency no longer leaves refreshes loading an older chunk. Preserve the + last published HTML while build stats are incomplete. + --- ## [0.2.17] — 2026-10-19 diff --git a/packages/bundler-utoopack/src/adapter/client-stats-watcher.ts b/packages/bundler-utoopack/src/adapter/client-stats-watcher.ts new file mode 100644 index 00000000..edc1cf06 --- /dev/null +++ b/packages/bundler-utoopack/src/adapter/client-stats-watcher.ts @@ -0,0 +1,85 @@ +import fs from "node:fs"; +import path from "node:path"; +import { watch } from "chokidar"; + +export async function readClientStatsVersion( + statsPath: string, +): Promise { + try { + const stats = await fs.promises.stat(statsPath, { bigint: true }); + return `${stats.dev}:${stats.ino}:${stats.size}:${stats.mtimeNs}`; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; + throw error; + } +} + +export interface ClientStatsWatcher { + advance(version: string | undefined): void; + close(): Promise; +} + +/** Watch the directory so atomic replacement does not detach the listener. */ +export function watchClientStats(options: { + statsPath: string; + initialVersion: string | undefined; + onChange(): Promise; + onError(error: unknown): void; +}): ClientStatsWatcher { + const statsPath = path.resolve(options.statsPath); + let version = options.initialVersion; + let closed = false; + let scheduled = false; + let queue = Promise.resolve(); + let closePromise: Promise | undefined; + const watcher = watch(path.dirname(statsPath), { + atomic: true, + awaitWriteFinish: { stabilityThreshold: 50, pollInterval: 10 }, + depth: 0, + disableGlobbing: true, + ignoreInitial: true, + }); + + function schedule(): void { + if (closed || scheduled) return; + scheduled = true; + queue = queue.then(async () => { + scheduled = false; + if (closed) return; + try { + const nextVersion = await readClientStatsVersion(statsPath); + if (!nextVersion || nextVersion === version || closed) return; + // Only a successful publication advances the baseline. Incomplete + // stats can then recover on the next real write without losing it. + await options.onChange(); + } catch (error) { + if (!closed) options.onError(error); + } + }); + } + + const onFileChange = (file: string) => { + if (path.resolve(file) === statsPath) schedule(); + }; + watcher.on("add", onFileChange); + watcher.on("change", onFileChange); + watcher.on("error", (error) => { + if (!closed) options.onError(error); + }); + // Catch writes between the initial publication and watcher attachment. + watcher.on("ready", schedule); + + return { + advance(nextVersion) { + version = nextVersion; + }, + close() { + closePromise ??= (async () => { + closed = true; + await watcher.close(); + await queue; + })(); + return closePromise; + }, + }; +} diff --git a/packages/bundler-utoopack/src/adapter/index.ts b/packages/bundler-utoopack/src/adapter/index.ts index fedfa487..ac0ef4a7 100644 --- a/packages/bundler-utoopack/src/adapter/index.ts +++ b/packages/bundler-utoopack/src/adapter/index.ts @@ -21,7 +21,15 @@ import type { BuildPlan, BuildPlanUpdate } from "@evjs/ev/_internal/manifest"; import type { ResolvedConfig } from "@evjs/ev/config"; import { getLogger } from "@logtape/logtape"; import type { ConfigComplete } from "@utoo/pack"; -import { UtoopackManifestGenerator } from "../manifest-generator.js"; +import { + IncompleteClientStatsError, + UtoopackManifestGenerator, +} from "../manifest-generator.js"; +import { + type ClientStatsWatcher, + readClientStatsVersion, + watchClientStats, +} from "./client-stats-watcher.js"; import { getOutputPaths } from "./output-paths.js"; const logger = getLogger(["evjs", "bundler-utoopack"]); @@ -49,15 +57,17 @@ async function generateDevArtifacts( options?: { isRebuild?: boolean }, ) => void | Promise, options: { isRebuild?: boolean } = {}, + facts?: BundlerBuildFacts, ): Promise { const outputPaths = getOutputPaths(cwd, config.output, plan.distDir); const clientStatsPath = path.join(outputPaths.clientDir, "stats.json"); if (!fs.existsSync(clientStatsPath)) return false; logger.info`Generating development manifest and HTML...`; - const generator = new UtoopackManifestGenerator(cwd, plan); - const facts = await generator.collectBuildFacts(); - await onBuildFacts(facts, options); + const buildFacts = + facts ?? + (await new UtoopackManifestGenerator(cwd, plan).collectBuildFacts()); + await onBuildFacts(buildFacts, options); return true; } @@ -122,59 +132,59 @@ export const utoopackAdapter: BundlerAdapter = { }, }); - await generateDevArtifacts(config, cwd, plan, callbacks.onBuildFacts, { - isRebuild: false, - }); - await callbacks.onDevServerReady?.({ origin: devServerOrigin }); - if (!hasRuntimeServerEntry(plan)) { - return new UtoopackDevController({ - config, - cwd, - onBuildFacts: callbacks.onBuildFacts, - }); - } - - const outDir = getOutputPaths(cwd, config.output, plan.distDir).serverDir; - - if (!fs.existsSync(outDir)) { - fs.mkdirSync(outDir, { recursive: true }); - } - - let ready = false; - const checkReady = async (filename?: string) => { - if (ready) return; - const hasBundle = filename - ? filename === "stats.json" || filename.endsWith(".js") - : (await fs.promises.readdir(outDir).catch(() => [])).some( - (f) => f === "stats.json" || f.endsWith(".js"), - ); - - if (hasBundle) { - ready = true; - try { - await callbacks.onServerBundleReady(); - serverReadyWatcher?.close(); - } catch (err) { - logger.error`Server bundle ready callback failed: ${err}`; - ready = false; - } - } - }; - - serverReadyWatcher = fs.watch(outDir, (_eventType, filename) => { - if (filename) void checkReady(filename); - }); - - // Initial check in case it was written before the watcher attached - await checkReady(); - return new UtoopackDevController({ + const controller = new UtoopackDevController({ config, cwd, + plan, onBuildFacts: callbacks.onBuildFacts, closeWatcher() { serverReadyWatcher?.close(); }, }); + + try { + await controller.initialize(); + await callbacks.onDevServerReady?.({ origin: devServerOrigin }); + if (!hasRuntimeServerEntry(plan)) return controller; + + const outDir = getOutputPaths(cwd, config.output, plan.distDir).serverDir; + + if (!fs.existsSync(outDir)) { + fs.mkdirSync(outDir, { recursive: true }); + } + + let ready = false; + const checkReady = async (filename?: string) => { + if (ready) return; + const hasBundle = filename + ? filename === "stats.json" || filename.endsWith(".js") + : (await fs.promises.readdir(outDir).catch(() => [])).some( + (f) => f === "stats.json" || f.endsWith(".js"), + ); + + if (hasBundle) { + ready = true; + try { + await callbacks.onServerBundleReady(); + serverReadyWatcher?.close(); + } catch (err) { + logger.error`Server bundle ready callback failed: ${err}`; + ready = false; + } + } + }; + + serverReadyWatcher = fs.watch(outDir, (_eventType, filename) => { + if (filename) void checkReady(filename); + }); + + // Initial check in case it was written before the watcher attached + await checkReady(); + return controller; + } catch (error) { + await controller.close(); + throw error; + } }, }; @@ -196,40 +206,128 @@ function formatDevServerOrigin( } class UtoopackDevController implements BundlerDevController { + private plan: BuildPlan; + private clientStatsWatcher: ClientStatsWatcher | undefined; + private publishedClientStatsVersion: string | undefined; + private hasPublishedArtifacts = false; + private publicationQueue: Promise = Promise.resolve(); + private closed = false; + private closePromise: Promise | undefined; + constructor( private options: { config: ResolvedConfig; cwd: string; + plan: BuildPlan; onBuildFacts: BundlerDevContext["callbacks"]["onBuildFacts"]; closeWatcher?: () => void; }, - ) {} + ) { + this.plan = options.plan; + } - close(): void { - this.options.closeWatcher?.(); + private get clientStatsPath(): string { + return path.join( + getOutputPaths( + this.options.cwd, + this.options.config.output, + this.plan.distDir, + ).clientDir, + "stats.json", + ); } - async updatePlan(update: BuildPlanUpdate): Promise { - if (isEmptyPlanUpdate(update)) return; + async initialize(): Promise { + await this.enqueue(() => this.publishArtifacts(this.plan, false)); + if (this.closed) return; + this.clientStatsWatcher = watchClientStats({ + statsPath: this.clientStatsPath, + initialVersion: this.publishedClientStatsVersion, + onChange: () => + this.enqueue(async () => { + const version = await readClientStatsVersion(this.clientStatsPath); + if (!version || version === this.publishedClientStatsVersion) return; + await this.publishArtifacts(this.plan, this.hasPublishedArtifacts); + }), + onError(error) { + logger.warn`Failed to publish Utoopack client rebuild: ${error}`; + }, + }); + } - if (!isHtmlOnlyUpdate(update)) { - throw new Error( - `[evjs] Utoopack dev cannot apply framework plan changes without restarting ev dev (${formatUnsupportedPlanUpdate(update)}). HTML-only framework plan updates are supported; entry additions, removals, server changes, and route metadata changes still require a lower-layer Utoopack update API.`, - ); + close(): Promise { + this.closePromise ??= (async () => { + this.closed = true; + this.options.closeWatcher?.(); + await this.clientStatsWatcher?.close(); + await this.publicationQueue; + })(); + return this.closePromise; + } + + private enqueue(work: () => Promise): Promise { + const result = this.publicationQueue.then(work); + this.publicationQueue = result.then( + () => undefined, + () => undefined, + ); + return result; + } + + private async publishArtifacts( + plan: BuildPlan, + isRebuild: boolean, + ): Promise { + if (this.closed) return false; + const versionBefore = await readClientStatsVersion(this.clientStatsPath); + if (!versionBefore) return false; + let facts: BundlerBuildFacts; + try { + facts = await new UtoopackManifestGenerator( + this.options.cwd, + plan, + ).collectBuildFacts({ strictClientStats: true }); + } catch (error) { + if (error instanceof IncompleteClientStatsError) return false; + throw error; } + const versionAfter = await readClientStatsVersion(this.clientStatsPath); + if (this.closed || versionBefore !== versionAfter) return false; const emitted = await generateDevArtifacts( this.options.config, this.options.cwd, - update.next, + plan, this.options.onBuildFacts, - { isRebuild: true }, + { isRebuild }, + facts, ); - if (!emitted) { + if (emitted) { + this.hasPublishedArtifacts = true; + this.publishedClientStatsVersion = versionAfter; + this.clientStatsWatcher?.advance(versionAfter); + } + return emitted; + } + + async updatePlan(update: BuildPlanUpdate): Promise { + if (isEmptyPlanUpdate(update)) return; + + if (!isHtmlOnlyUpdate(update)) { throw new Error( - "[evjs] Utoopack dev cannot regenerate framework artifacts before client build stats are available.", + `[evjs] Utoopack dev cannot apply framework plan changes without restarting ev dev (${formatUnsupportedPlanUpdate(update)}). HTML-only framework plan updates are supported; entry additions, removals, server changes, and route metadata changes still require a lower-layer Utoopack update API.`, ); } + + await this.enqueue(async () => { + const emitted = await this.publishArtifacts(update.next, true); + if (!emitted) { + throw new Error( + "[evjs] Utoopack dev cannot regenerate framework artifacts before client build stats are available.", + ); + } + this.plan = update.next; + }); } } diff --git a/packages/bundler-utoopack/src/manifest-generator.ts b/packages/bundler-utoopack/src/manifest-generator.ts index c4fa186c..8f6ff828 100644 --- a/packages/bundler-utoopack/src/manifest-generator.ts +++ b/packages/bundler-utoopack/src/manifest-generator.ts @@ -16,6 +16,8 @@ const logger = getLogger(["evjs", "bundler-utoopack", "manifest"]); const EMPTY_ASSETS: AssetGroup = { js: [], css: [] }; +export class IncompleteClientStatsError extends Error {} + interface UtoopackStatsModule { name?: string; id?: string | number; @@ -146,9 +148,14 @@ export class UtoopackManifestGenerator { this.plan = plan; } - async loadClientStats() { + async loadClientStats(strict = false) { const statsPath = path.join(this.outputPaths.clientDir, "stats.json"); if (!fs.existsSync(statsPath)) { + if (strict) { + throw new IncompleteClientStatsError( + "Client stats.json is not available yet.", + ); + } this.clientEntryAssets = {}; this.firstClientEntryAssets = EMPTY_ASSETS; return; @@ -158,9 +165,35 @@ export class UtoopackManifestGenerator { const statsStr = await fs.promises.readFile(statsPath, "utf-8"); const stats = JSON.parse(statsStr); const { byName, first } = readEntrypointAssets(stats); + if ( + strict && + this.plan.entries + .filter((entry) => entry.environment === "client") + .some((entry) => { + const assets = byName[entry.name]; + return !assets || assets.js.length + assets.css.length === 0; + }) + ) { + throw new IncompleteClientStatsError( + "Client stats.json is missing entry assets.", + ); + } this.clientEntryAssets = byName; this.firstClientEntryAssets = first; } catch (err) { + if (strict) { + if ( + err instanceof SyntaxError || + err instanceof TypeError || + (err as NodeJS.ErrnoException).code === "ENOENT" + ) { + throw new IncompleteClientStatsError( + "Client stats.json is not readable yet.", + { cause: err }, + ); + } + throw err; + } logger.warn`Failed to parse client stats.json: ${err}`; this.clientEntryAssets = {}; this.firstClientEntryAssets = EMPTY_ASSETS; @@ -212,8 +245,10 @@ export class UtoopackManifestGenerator { } } - async collectBuildFacts(): Promise { - await this.loadClientStats(); + async collectBuildFacts( + options: { strictClientStats?: boolean } = {}, + ): Promise { + await this.loadClientStats(options.strictClientStats); await this.loadServerStats(); return { diff --git a/packages/bundler-utoopack/tests/adapter.test.ts b/packages/bundler-utoopack/tests/adapter.test.ts index eb2edf72..403540f8 100644 --- a/packages/bundler-utoopack/tests/adapter.test.ts +++ b/packages/bundler-utoopack/tests/adapter.test.ts @@ -28,24 +28,33 @@ import { createClientRuntime } from "../../ev/src/_internal/build/framework-runt import { utoopackAdapter } from "../src/adapter/index.js"; const utoopackMock = vi.hoisted(() => ({ + initialClientAssets: undefined as string[] | undefined, + initialClientStats: undefined as string | undefined, requireUtoopack: vi.fn(() => ({ serve: vi.fn(async ({ config }, _projectPath, _rootPath, serverOptions) => { const fs = await import("node:fs"); const path = await import("node:path"); const clientOutDir = config.output.path; + const clientAssets = utoopackMock.initialClientAssets ?? [ + "main.js", + "main.css", + ]; await fs.promises.mkdir(clientOutDir, { recursive: true }); - await fs.promises.writeFile(path.join(clientOutDir, "main.js"), ""); - await fs.promises.writeFile(path.join(clientOutDir, "main.css"), ""); + for (const asset of clientAssets) { + await fs.promises.writeFile(path.join(clientOutDir, asset), ""); + } await fs.promises.writeFile( path.join(clientOutDir, "stats.json"), - JSON.stringify({ - entrypoints: { - main: { - assets: [{ name: "main.js" }, { name: "main.css" }], - }, - }, - }), + utoopackMock.initialClientStats ?? + JSON.stringify({ + entrypoints: Object.fromEntries( + config.entry.map((entry: { name: string }) => [ + entry.name, + { assets: clientAssets.map((name) => ({ name })) }, + ]), + ), + }), ); if (config.server) { @@ -110,6 +119,8 @@ async function makeProject() { } afterEach(async () => { + utoopackMock.initialClientAssets = undefined; + utoopackMock.initialClientStats = undefined; await Promise.all( tempDirs.splice(0).map((dir) => fs.promises.rm(dir, { @@ -120,6 +131,26 @@ afterEach(async () => { ); }); +async function writeClientStats( + cwd: string, + assets: string[], + clientDir = "dist/client", + entryName = "main", +) { + const outputDir = path.join(cwd, clientDir); + for (const asset of assets) { + await fs.promises.writeFile(path.join(outputDir, asset), ""); + } + await fs.promises.writeFile( + path.join(outputDir, "stats.json"), + JSON.stringify({ + entrypoints: { + [entryName]: { assets: assets.map((name) => ({ name })) }, + }, + }), + ); +} + function createFrameworkCallbacks(options: { config: ResolvedConfig; cwd: string; @@ -276,6 +307,225 @@ async function expectRejectedMessage(action: () => void | Promise) { } describe("utoopackAdapter dev", () => { + it.each([ + ["truncated JSON", '{"entrypoints":'], + ["empty entrypoints", JSON.stringify({ entrypoints: {} })], + ])("waits for complete initial client stats after %s", async (_name, stats) => { + const cwd = await makeProject(); + utoopackMock.initialClientStats = stats; + const config = resolveConfig({ html: "./index.html" }); + const buildContext = await createBuildContext(config, cwd); + const onBuildOutput = vi.fn(); + const rebuildFlags: boolean[] = []; + const framework = createFrameworkCallbacks({ + config, + cwd, + ...buildContext, + onBuildOutput, + }); + const controller = await utoopackAdapter.dev({ + config, + cwd, + ...buildContext, + callbacks: { + ...framework, + async onBuildFacts(facts, options) { + rebuildFlags.push(options?.isRebuild ?? false); + await framework.onBuildFacts(facts); + }, + }, + hooks: [], + }); + + try { + const htmlPath = path.join(cwd, "dist/client/index.html"); + expect(fs.existsSync(htmlPath)).toBe(false); + expect(onBuildOutput).not.toHaveBeenCalled(); + + await writeClientStats(cwd, ["root-initial.js", "main.js"]); + await expect + .poll(() => fs.promises.readFile(htmlPath, "utf-8")) + .toContain('src="/root-initial.js"'); + expect(rebuildFlags).toEqual([false]); + + await writeClientStats(cwd, ["root-rebuilt.js", "main.js"]); + await expect + .poll(() => fs.promises.readFile(htmlPath, "utf-8")) + .toContain('src="/root-rebuilt.js"'); + expect(rebuildFlags).toEqual([false, true]); + } finally { + await controller?.close?.(); + } + }); + + it("rejects initialization when publishing initial client artifacts fails", async () => { + const cwd = await makeProject(); + const config = resolveConfig({ html: "./index.html" }); + const buildContext = await createBuildContext(config, cwd); + const onBuildFacts = vi.fn().mockRejectedValue(new Error("publish failed")); + + await expect( + utoopackAdapter.dev({ + config, + cwd, + ...buildContext, + callbacks: { + onBuildFacts, + onServerBundleReady: vi.fn(), + }, + hooks: [], + }), + ).rejects.toThrow("publish failed"); + expect(onBuildFacts).toHaveBeenCalledTimes(1); + }); + + it("republishes HTML and manifests when a client rebuild replaces a chunk", async () => { + const cwd = await makeProject(); + utoopackMock.initialClientAssets = ["root-old.js", "main.js"]; + const config = resolveConfig({ html: "./index.html" }); + const buildContext = await createBuildContext(config, cwd); + const onBuildOutput = vi.fn(); + const onServerBundleReady = vi.fn(); + const rebuildFlags: boolean[] = []; + const framework = createFrameworkCallbacks({ + config, + cwd, + ...buildContext, + onBuildOutput, + onServerBundleReady, + }); + const controller = await utoopackAdapter.dev({ + config, + cwd, + ...buildContext, + callbacks: { + ...framework, + async onBuildFacts(facts, options) { + rebuildFlags.push(options?.isRebuild ?? false); + await framework.onBuildFacts(facts); + }, + }, + hooks: [], + }); + + try { + const htmlPath = path.join(cwd, "dist/client/index.html"); + expect(await fs.promises.readFile(htmlPath, "utf-8")).toContain( + 'src="/root-old.js"', + ); + + await writeClientStats(cwd, ["root-new.js", "main.js", "new.css"]); + await expect + .poll(() => fs.promises.readFile(htmlPath, "utf-8")) + .toContain('src="/root-new.js"'); + + const html = await fs.promises.readFile(htmlPath, "utf-8"); + const manifest = JSON.parse( + await fs.promises.readFile( + path.join(cwd, "dist/client/manifest.json"), + "utf-8", + ), + ); + const deployment = JSON.parse( + await fs.promises.readFile( + path.join(cwd, "dist/build-output.json"), + "utf-8", + ), + ); + expect(html).not.toContain('src="/root-old.js"'); + expect(html).toContain('href="/new.css"'); + expect(manifest.assets.main).toEqual({ + js: ["root-new.js", "main.js"], + css: ["new.css"], + }); + expect(deployment.documents[0].assets).toEqual({ + js: ["root-new.js", "main.js"], + css: ["new.css"], + }); + expect(onBuildOutput).toHaveBeenCalledTimes(2); + expect(rebuildFlags).toEqual([false, true]); + expect(onServerBundleReady).not.toHaveBeenCalled(); + } finally { + await controller?.close?.(); + } + }); + + it("keeps published HTML while client stats are incomplete and recovers", async () => { + const cwd = await makeProject(); + const config = resolveConfig({ html: "./index.html" }); + const buildContext = await createBuildContext(config, cwd); + const onBuildOutput = vi.fn(); + const controller = await utoopackAdapter.dev({ + config, + cwd, + ...buildContext, + callbacks: createFrameworkCallbacks({ + config, + cwd, + ...buildContext, + onBuildOutput, + }), + hooks: [], + }); + + try { + const htmlPath = path.join(cwd, "dist/client/index.html"); + const initialHtml = await fs.promises.readFile(htmlPath, "utf-8"); + for (const incompleteStats of [ + '{"entrypoints":', + JSON.stringify({ entrypoints: {} }), + ]) { + await fs.promises.writeFile( + path.join(cwd, "dist/client/stats.json"), + incompleteStats, + ); + await new Promise((resolve) => setTimeout(resolve, 200)); + expect(await fs.promises.readFile(htmlPath, "utf-8")).toBe(initialHtml); + expect(onBuildOutput).toHaveBeenCalledTimes(1); + } + + await writeClientStats(cwd, ["root-recovered.js", "main.js"]); + await expect + .poll(() => fs.promises.readFile(htmlPath, "utf-8")) + .toContain('src="/root-recovered.js"'); + expect(onBuildOutput).toHaveBeenCalledTimes(2); + } finally { + await controller?.close?.(); + } + }); + + it("stops publishing client rebuilds after the controller closes", async () => { + const cwd = await makeProject(); + const config = resolveConfig({ html: "./index.html" }); + const buildContext = await createBuildContext(config, cwd); + const onBuildOutput = vi.fn(); + const controller = await utoopackAdapter.dev({ + config, + cwd, + ...buildContext, + callbacks: createFrameworkCallbacks({ + config, + cwd, + ...buildContext, + onBuildOutput, + }), + hooks: [], + }); + + try { + const htmlPath = path.join(cwd, "dist/client/index.html"); + const initialHtml = await fs.promises.readFile(htmlPath, "utf-8"); + await controller?.close?.(); + await writeClientStats(cwd, ["root-after-close.js", "main.js"]); + await new Promise((resolve) => setTimeout(resolve, 200)); + + expect(await fs.promises.readFile(htmlPath, "utf-8")).toBe(initialHtml); + expect(onBuildOutput).toHaveBeenCalledTimes(1); + } finally { + await controller?.close?.(); + } + }); + it("emits flat CSR manifest and index.html in flat output mode", async () => { const cwd = await makeProject(); const config = resolveConfig({ @@ -479,6 +729,22 @@ describe("utoopackAdapter dev", () => { fileName: "home.html", }); expect(onBuildOutput).toHaveBeenCalledTimes(2); + + await writeClientStats( + cwd, + ["root-next-plan.js", "main.js"], + "dist", + "home", + ); + await expect + .poll(() => + fs.promises.readFile(path.join(cwd, "dist/home.html"), "utf-8"), + ) + .toContain('src="/root-next-plan.js"'); + expect( + await fs.promises.readFile(path.join(cwd, "dist/home.html"), "utf-8"), + ).toContain("next-shell"); + expect(onBuildOutput).toHaveBeenCalledTimes(3); } finally { await controller.close?.(); } @@ -610,7 +876,7 @@ describe("utoopackAdapter dev", () => { }, ]; - await utoopackAdapter.dev({ + const controller = await utoopackAdapter.dev({ config, cwd, ...buildContext, @@ -665,6 +931,7 @@ describe("utoopackAdapter dev", () => { expect(html).toContain('data-evjs-id="default"'); expect(html).toContain(''); expect(onServerBundleReady).not.toHaveBeenCalled(); + await controller?.close?.(); }); }); diff --git a/packages/bundler-utoopack/tests/client-stats-watcher.test.ts b/packages/bundler-utoopack/tests/client-stats-watcher.test.ts new file mode 100644 index 00000000..dce4228c --- /dev/null +++ b/packages/bundler-utoopack/tests/client-stats-watcher.test.ts @@ -0,0 +1,198 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { setTimeout as delay } from "node:timers/promises"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + type ClientStatsWatcher, + readClientStatsVersion, + watchClientStats, +} from "../src/adapter/client-stats-watcher.js"; + +const directories: string[] = []; +const watchers: ClientStatsWatcher[] = []; +const releasePending: Array<() => void> = []; + +function deferred() { + let resolve!: () => void; + const promise = new Promise((done) => { + resolve = done; + }); + releasePending.push(resolve); + return { promise, resolve }; +} + +function statsFor(asset: string): string { + return JSON.stringify({ + entrypoints: { main: { assets: [`${asset}.js`] } }, + }); +} + +async function createStats(asset = "initial") { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), "evjs-stats-")); + directories.push(directory); + const statsPath = path.join(directory, "stats.json"); + await fs.writeFile(statsPath, statsFor(asset)); + return statsPath; +} + +function observe(options: Parameters[0]) { + const watcher = watchClientStats(options); + watchers.push(watcher); + return watcher; +} + +async function waitForPublication(assertion: () => void) { + await vi.waitFor(assertion, { timeout: 5_000, interval: 10 }); +} + +afterEach(async () => { + for (const release of releasePending.splice(0)) release(); + await Promise.all(watchers.splice(0).map((watcher) => watcher.close())); + await Promise.all( + directories + .splice(0) + .map((directory) => fs.rm(directory, { recursive: true, force: true })), + ); +}); + +describe("client stats watcher", () => { + it("publishes a change made before the watcher attaches", async () => { + const statsPath = await createStats(); + const initialVersion = await readClientStatsVersion(statsPath); + await fs.writeFile(statsPath, statsFor("changed-before-attachment")); + const published: string[] = []; + const onError = vi.fn(); + const watcher = observe({ + statsPath, + initialVersion, + async onChange() { + const contents = await fs.readFile(statsPath, "utf8"); + watcher.advance(await readClientStatsVersion(statsPath)); + published.push(contents); + }, + onError, + }); + + await waitForPublication(() => { + expect(published).toEqual([statsFor("changed-before-attachment")]); + }); + expect(onError).not.toHaveBeenCalled(); + }); + + it("keeps observing stats after an atomic file replacement", async () => { + const statsPath = await createStats(); + const published: string[] = []; + const onError = vi.fn(); + const watcher = observe({ + statsPath, + initialVersion: undefined, + async onChange() { + const contents = await fs.readFile(statsPath, "utf8"); + watcher.advance(await readClientStatsVersion(statsPath)); + published.push(contents); + }, + onError, + }); + await waitForPublication(() => { + expect(published).toEqual([statsFor("initial")]); + }); + + const replacement = path.join(path.dirname(statsPath), "stats.next"); + await fs.writeFile(replacement, statsFor("atomic-replacement")); + await fs.rename(replacement, statsPath); + await waitForPublication(() => { + expect(published).toEqual([ + statsFor("initial"), + statsFor("atomic-replacement"), + ]); + }); + + await fs.writeFile(statsPath, statsFor("written-after-replacement")); + await waitForPublication(() => { + expect(published).toEqual([ + statsFor("initial"), + statsFor("atomic-replacement"), + statsFor("written-after-replacement"), + ]); + }); + expect(onError).not.toHaveBeenCalled(); + }); + + it("preserves a newer acknowledged version while publication is pending", async () => { + const statsPath = await createStats(); + const firstPublication = deferred(); + const releasePublication = deferred(); + const published: string[] = []; + const onError = vi.fn(); + const watcher = observe({ + statsPath, + initialVersion: undefined, + async onChange() { + published.push(await fs.readFile(statsPath, "utf8")); + if (published.length === 1) { + firstPublication.resolve(); + await releasePublication.promise; + } else { + watcher.advance(await readClientStatsVersion(statsPath)); + } + }, + onError, + }); + await firstPublication.promise; + + await fs.writeFile(statsPath, statsFor("already-published-newer-version")); + watcher.advance(await readClientStatsVersion(statsPath)); + // Allow the real filesystem event to arrive while the first publication + // remains blocked, then let the queued observation inspect its baseline. + await delay(200); + releasePublication.resolve(); + await delay(200); + expect(published).toEqual([statsFor("initial")]); + + await fs.writeFile(statsPath, statsFor("next-unpublished-version")); + await waitForPublication(() => { + expect(published).toEqual([ + statsFor("initial"), + statsFor("next-unpublished-version"), + ]); + }); + expect(onError).not.toHaveBeenCalled(); + }); + + it("closes idempotently after an in-flight publication finishes", async () => { + const statsPath = await createStats(); + const publicationStarted = deferred(); + const releasePublication = deferred(); + const onChange = vi.fn(async () => { + publicationStarted.resolve(); + await releasePublication.promise; + }); + const onError = vi.fn(); + const watcher = observe({ + statsPath, + initialVersion: undefined, + onChange, + onError, + }); + await publicationStarted.promise; + + const closing = watcher.close(); + expect(watcher.close()).toBe(closing); + let closed = false; + void closing.then(() => { + closed = true; + }); + await fs.writeFile(statsPath, statsFor("changed-during-close")); + expect(closed).toBe(false); + + releasePublication.resolve(); + await closing; + expect(closed).toBe(true); + expect(watcher.close()).toBe(closing); + await fs.writeFile(statsPath, statsFor("changed-after-close")); + await delay(100); + expect(onChange).toHaveBeenCalledTimes(1); + expect(onError).not.toHaveBeenCalled(); + }); +});