From e0480e2fd06ed5ca228390c9caaebde6a3c21f49 Mon Sep 17 00:00:00 2001 From: zoomdong <1344492820@qq.com> Date: Mon, 17 Aug 2026 16:24:21 +0800 Subject: [PATCH 1/6] fix(utoopack): regenerate dev artifacts after client rebuilds --- .../bundler-utoopack/src/adapter/index.ts | 144 ++++++++++++------ .../src/adapter/server-stats-monitor.ts | 84 ---------- .../src/adapter/stats-watcher.ts | 121 +++++++++++++++ .../bundler-utoopack/tests/adapter.test.ts | 65 +++++++- .../tests/server-stats-monitor.test.ts | 54 ------- .../tests/stats-watcher.test.ts | 124 +++++++++++++++ 6 files changed, 404 insertions(+), 188 deletions(-) delete mode 100644 packages/bundler-utoopack/src/adapter/server-stats-monitor.ts create mode 100644 packages/bundler-utoopack/src/adapter/stats-watcher.ts delete mode 100644 packages/bundler-utoopack/tests/server-stats-monitor.test.ts create mode 100644 packages/bundler-utoopack/tests/stats-watcher.test.ts diff --git a/packages/bundler-utoopack/src/adapter/index.ts b/packages/bundler-utoopack/src/adapter/index.ts index ce1c2c5d..20c4551c 100644 --- a/packages/bundler-utoopack/src/adapter/index.ts +++ b/packages/bundler-utoopack/src/adapter/index.ts @@ -40,10 +40,10 @@ import { import { assertSafeUtoopackCleanOutput } from "./output-paths.js"; import { runUtoopackBuild } from "./runtime.js"; import { - readServerStatsVersion, - startUtoopackServerStatsMonitor, - type UtoopackServerStatsMonitor, -} from "./server-stats-monitor.js"; + readStatsVersion, + startUtoopackStatsWatcher, + type UtoopackStatsWatcher, +} from "./stats-watcher.js"; const logger = getLogger(["evjs", "bundler-utoopack"]); const require = createRequire(import.meta.url); @@ -53,6 +53,15 @@ const { version: utoopackVersion } = require("@utoo/pack/package.json") as { type UtoopackRuntime = Pick; const DEV_STATS_POLL_INTERVAL_MS = 25; +interface UtoopackDevBuildSnapshot { + facts: BundlerBuildFacts; + statsVersion: string | undefined; +} + +interface ProcessedUtoopackDevBuild extends UtoopackDevBuildSnapshot { + disposition: BundlerBuildFactsDisposition; +} + async function cleanServerOutput( cwd: string, outputPaths: ResolvedBuildOutputPaths, @@ -85,18 +94,14 @@ async function waitForReadableDevStats( cwd: string, plan: BuildPlan, signal: AbortSignal, -): Promise { +): Promise { const outputPaths = resolveBuildOutputPaths(cwd, plan); - const requiredStats = [ - ...(plan.entries.some((entry) => entry.environment === "client") - ? [path.join(outputPaths.clientDir, "stats.json")] - : []), - ...(hasServerEntries(plan) - ? [path.join(outputPaths.serverDir, "stats.json")] - : []), - ]; + const requiredStats = resolveRequiredDevStatsPaths(outputPaths, plan); if (requiredStats.length === 0) { - return new UtoopackManifestGenerator(cwd, plan).collectBuildFacts(); + return { + facts: await new UtoopackManifestGenerator(cwd, plan).collectBuildFacts(), + statsVersion: undefined, + }; } while (true) { @@ -105,7 +110,15 @@ async function waitForReadableDevStats( "[evjs] Utoopack development session closed while waiting for build stats.", ); try { - return await new UtoopackManifestGenerator(cwd, plan).collectBuildFacts(); + const versionBefore = await readStatsVersion(requiredStats); + const facts = await new UtoopackManifestGenerator( + cwd, + plan, + ).collectBuildFacts(); + const versionAfter = await readStatsVersion(requiredStats); + if (versionBefore && versionBefore === versionAfter) { + return { facts, statsVersion: versionAfter }; + } } catch {} await waitForPollingDelay( signal, @@ -114,6 +127,20 @@ async function waitForReadableDevStats( } } +function resolveRequiredDevStatsPaths( + outputPaths: ResolvedBuildOutputPaths, + plan: BuildPlan, +): string[] { + return [ + ...(plan.entries.some((entry) => entry.environment === "client") + ? [path.join(outputPaths.clientDir, "stats.json")] + : []), + ...(hasServerEntries(plan) + ? [path.join(outputPaths.serverDir, "stats.json")] + : []), + ]; +} + function throwIfPollingAborted(signal: AbortSignal, message: string): void { if (signal.aborted) throw new Error(message); } @@ -293,26 +320,23 @@ async function startUtoopackDev( logger.warn`Reserved client port ${config.dev.port} became unavailable during startup; Utoopack is listening on ${ready.port}.${fallbackStatus}`; } - const serverStatsPath = path.join(outputPaths.serverDir, "stats.json"); - // Establish the monitor baseline before reading facts. If stats changes - // while facts are being collected, the monitor may conservatively emit a - // duplicate cycle, but it cannot mistake an unseen newer version for the - // version represented by the initial callback. - const initialServerStatsVersion = hasServerEntries(plan) - ? await readServerStatsVersion(serverStatsPath) - : undefined; - if (hasServerEntries(plan)) { - const monitor = startUtoopackServerStatsMonitor({ - statsPath: serverStatsPath, - initialVersion: initialServerStatsVersion, + const statsPaths = resolveRequiredDevStatsPaths(outputPaths, plan); + // Establish the watcher baseline before reading facts. The controller + // advances it to the exact stable stats version committed by each callback, + // while the watcher revision guard preserves any newer observation. + const initialStatsVersion = await readStatsVersion(statsPaths); + if (statsPaths.length > 0) { + const watcher = startUtoopackStatsWatcher({ + statsPaths, + initialVersion: initialStatsVersion, async onChange(version) { - return controller.processServerStatsChange(version); + return controller.processStatsChange(version); }, onError(error) { - logger.error`Failed to process Utoopack server rebuild: ${error}`; + logger.error`Failed to process Utoopack rebuild: ${error}`; }, }); - controller.attachServerStatsMonitor(monitor); + controller.attachStatsWatcher(watcher); } controller.startInitialFacts(); worker.throwIfFailed(); @@ -361,9 +385,10 @@ function formatDevServerOrigin( class UtoopackDevController implements BundlerDevController { origin = ""; readonly done: Promise; - private serverStatsMonitor: UtoopackServerStatsMonitor | undefined; + private statsWatcher: UtoopackStatsWatcher | undefined; private devWorkQueue: Promise = Promise.resolve(); private hasEmittedDevArtifacts = false; + private publishedStatsVersion: string | undefined; private closed = false; private closePromise: Promise | undefined; private readonly closingController = new AbortController(); @@ -389,20 +414,20 @@ class UtoopackDevController implements BundlerDevController { this.origin = origin; } - attachServerStatsMonitor(monitor: UtoopackServerStatsMonitor): void { - if (this.serverStatsMonitor) { + attachStatsWatcher(watcher: UtoopackStatsWatcher): void { + if (this.statsWatcher) { throw new Error( - "[evjs] Utoopack server stats monitor was attached more than once.", + "[evjs] Utoopack stats watcher was attached more than once.", ); } - this.serverStatsMonitor = monitor; + this.statsWatcher = watcher; } startInitialFacts(): void { void this.enqueueDevWork(async () => { - const disposition = await this.processBuildFacts(false); - if (disposition === "published") { - this.hasEmittedDevArtifacts = true; + const result = await this.processBuildFacts(false); + if (result.disposition === "published") { + this.statsWatcher?.advance(result.statsVersion); } }).catch((error) => { if (!this.closed) { @@ -417,7 +442,7 @@ class UtoopackDevController implements BundlerDevController { this.closingController.abort(); const errors: unknown[] = []; try { - await this.serverStatsMonitor?.close(); + await this.statsWatcher?.close(); } catch (error) { errors.push(error); } @@ -444,15 +469,23 @@ class UtoopackDevController implements BundlerDevController { return this.closePromise; } - processServerStatsChange(_version: string): Promise { + processStatsChange(_version: string): Promise { if (this.closed) return Promise.resolve(true); return this.enqueueDevWork(async () => { - const disposition = await this.processBuildFacts(true); - return disposition !== "discarded"; + const currentVersion = await this.readCurrentStatsVersion(); + if (currentVersion && currentVersion === this.publishedStatsVersion) { + this.statsWatcher?.advance(currentVersion); + return true; + } + const result = await this.processBuildFacts(true); + if (result.disposition === "published") { + this.statsWatcher?.advance(result.statsVersion); + } + return result.disposition !== "discarded"; }); } - waitForReadableStats(plan: BuildPlan): Promise { + waitForReadableStats(plan: BuildPlan): Promise { return Promise.race([ waitForReadableDevStats( this.options.cwd, @@ -464,26 +497,39 @@ class UtoopackDevController implements BundlerDevController { ]); } + private readCurrentStatsVersion(): Promise { + const outputPaths = resolveBuildOutputPaths( + this.options.cwd, + this.options.plan, + ); + return readStatsVersion( + resolveRequiredDevStatsPaths(outputPaths, this.options.plan), + ); + } + private async processBuildFacts( isRebuild: boolean, - ): Promise { - if (this.closed) return "discarded"; - const facts = await this.waitForReadableStats(this.options.plan); - if (this.closed) return "discarded"; + ): Promise { + if (this.closed) { + return { disposition: "discarded", facts: {}, statsVersion: undefined }; + } + const snapshot = await this.waitForReadableStats(this.options.plan); + if (this.closed) return { disposition: "discarded", ...snapshot }; const disposition = await generateDevArtifacts( this.options.cwd, this.options.plan, this.options.onBuildFacts, { isRebuild: isRebuild || this.hasEmittedDevArtifacts }, - facts, + snapshot.facts, ); if (disposition === "published") { this.hasEmittedDevArtifacts = true; + this.publishedStatsVersion = snapshot.statsVersion; if (hasRuntimeServerEntry(this.options.plan) && !this.closed) { await this.options.onServerBundleReady(); } } - return disposition; + return { disposition, ...snapshot }; } private enqueueDevWork(work: () => Promise): Promise { diff --git a/packages/bundler-utoopack/src/adapter/server-stats-monitor.ts b/packages/bundler-utoopack/src/adapter/server-stats-monitor.ts deleted file mode 100644 index 584eea59..00000000 --- a/packages/bundler-utoopack/src/adapter/server-stats-monitor.ts +++ /dev/null @@ -1,84 +0,0 @@ -import fs from "node:fs"; - -export interface UtoopackServerStatsMonitor { - /** Advance the authoritative baseline after an out-of-band publication. */ - advance(version: string | undefined): void; - close(): Promise; -} - -interface StartUtoopackServerStatsMonitorOptions { - statsPath: string; - initialVersion: string | undefined; - /** Return true only when the observed version was consumed or discarded. */ - onChange(version: string): Promise; - onError(error: unknown): void; -} - -const POLL_INTERVAL_MS = 100; - -export async function readServerStatsVersion( - 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 | undefined)?.code === "ENOENT") { - return undefined; - } - throw error; - } -} - -/** - * Track completed Utoopack server rebuilds. - * - * `@utoo/pack` invokes `onReady` for the initial build and server startup, not - * later completed server rebuilds. Poll the authoritative server `stats.json` - * so evjs can relink build facts and refresh its server runtime. - */ -export function startUtoopackServerStatsMonitor( - options: StartUtoopackServerStatsMonitorOptions, -): UtoopackServerStatsMonitor { - let currentVersion = options.initialVersion; - let baselineRevision = 0; - let closed = false; - let queue = Promise.resolve(); - const poll = () => { - queue = queue.then(processChange, processChange); - }; - const interval = setInterval(poll, POLL_INTERVAL_MS); - interval.unref(); - - async function processChange(): Promise { - if (closed) return; - try { - const nextVersion = await readServerStatsVersion(options.statsPath); - if (!nextVersion || nextVersion === currentVersion) return; - const observedRevision = baselineRevision; - const consumed = await options.onChange(nextVersion); - if (consumed && observedRevision === baselineRevision) { - currentVersion = nextVersion; - } - } catch (error) { - if (!closed) options.onError(error); - } - } - - // Close the attach/read race after the initial facts were consumed. Polling - // also avoids fs.watch descriptor exhaustion and atomic-replacement gaps. - poll(); - - return { - advance(version) { - baselineRevision += 1; - currentVersion = version; - }, - async close() { - if (closed) return; - closed = true; - clearInterval(interval); - await queue; - }, - }; -} diff --git a/packages/bundler-utoopack/src/adapter/stats-watcher.ts b/packages/bundler-utoopack/src/adapter/stats-watcher.ts new file mode 100644 index 00000000..300a461e --- /dev/null +++ b/packages/bundler-utoopack/src/adapter/stats-watcher.ts @@ -0,0 +1,121 @@ +import fs from "node:fs"; +import path from "node:path"; +import { watch } from "chokidar"; + +export interface UtoopackStatsWatcher { + /** Advance the authoritative baseline after an out-of-band publication. */ + advance(version: string | undefined): void; + close(): Promise; +} + +interface StartUtoopackStatsWatcherOptions { + statsPaths: readonly string[]; + initialVersion: string | undefined; + /** Return true only when the observed version was consumed or discarded. */ + onChange(version: string): Promise; + onError(error: unknown): void; +} + +async function readStatsFileVersion( + 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 | undefined)?.code === "ENOENT") { + return undefined; + } + throw error; + } +} + +export async function readStatsVersion( + statsPaths: readonly string[], +): Promise { + const versions = await Promise.all(statsPaths.map(readStatsFileVersion)); + if (versions.every((version) => version === undefined)) return undefined; + return versions + .map((version, index) => `${index}:${version ?? "missing"}`) + .join("|"); +} + +/** + * Track completed Utoopack client and server rebuilds. + * + * `@utoo/pack` invokes `onReady` for the initial build and server startup, not + * later completed rebuilds. Watch the authoritative `stats.json` files and + * wait for atomic writes to settle before evjs relinks build facts. A ready + * scan closes the watcher-attachment race without a perpetual polling task. + */ +export function startUtoopackStatsWatcher( + options: StartUtoopackStatsWatcherOptions, +): UtoopackStatsWatcher { + let currentVersion = options.initialVersion; + let baselineRevision = 0; + let closed = false; + let scheduled = false; + let queue = Promise.resolve(); + const statsPaths = new Set( + options.statsPaths.map((value) => path.resolve(value)), + ); + const statsDirectories = [ + ...new Set([...statsPaths].map((value) => path.dirname(value))), + ]; + const watcher = watch(statsDirectories, { + atomic: true, + awaitWriteFinish: { + stabilityThreshold: 50, + pollInterval: 10, + }, + depth: 0, + disableGlobbing: true, + ignoreInitial: true, + }); + + function scheduleStatsChange(changedPath: string): void { + if (statsPaths.has(path.resolve(changedPath))) scheduleChange(); + } + + function scheduleChange(): void { + if (closed || scheduled) return; + scheduled = true; + queue = queue.then(processChange, processChange); + } + + async function processChange(): Promise { + scheduled = false; + if (closed) return; + try { + const nextVersion = await readStatsVersion(options.statsPaths); + if (!nextVersion || nextVersion === currentVersion) return; + const observedRevision = baselineRevision; + const consumed = await options.onChange(nextVersion); + if (consumed && observedRevision === baselineRevision) { + currentVersion = nextVersion; + } + } catch (error) { + if (!closed) options.onError(error); + } + } + + watcher.on("add", scheduleStatsChange); + watcher.on("change", scheduleStatsChange); + watcher.on("error", (error) => { + if (!closed) options.onError(error); + }); + watcher.on("ready", scheduleChange); + + return { + advance(version) { + baselineRevision += 1; + currentVersion = version; + }, + async close() { + if (closed) return; + closed = true; + await watcher.close(); + await queue; + }, + }; +} diff --git a/packages/bundler-utoopack/tests/adapter.test.ts b/packages/bundler-utoopack/tests/adapter.test.ts index 2a9df631..e2e33b3d 100644 --- a/packages/bundler-utoopack/tests/adapter.test.ts +++ b/packages/bundler-utoopack/tests/adapter.test.ts @@ -527,6 +527,7 @@ describe("utoopackAdapter dev", () => { () => onBuildOutput.mock.calls.length > 0, "Utoopack did not publish its initial build facts", ); + await new Promise((resolve) => setTimeout(resolve, 150)); expect(onBuildOutput).toHaveBeenCalledTimes(1); } finally { await controller.close(); @@ -568,6 +569,7 @@ describe("utoopackAdapter dev", () => { () => rebuildFlags.length > 0, "Utoopack did not recover after stats became readable", ); + await new Promise((resolve) => setTimeout(resolve, 150)); expect(rebuildFlags).toEqual([false]); expect(utoopackMock.workerClose).not.toHaveBeenCalled(); } finally { @@ -575,6 +577,67 @@ describe("utoopackAdapter dev", () => { } }); + it("regenerates development artifacts after client stats change", async () => { + const cwd = await makeProject(); + const config = await resolveProjectConfig(cwd, { + routing: { mode: "spa" }, + }); + const buildContext = await createBuildContext(config, cwd); + const outputs: BuildOutput[] = []; + const rebuildFlags: boolean[] = []; + const callbacks = createFrameworkCallbacks({ + config, + cwd, + ...buildContext, + onBuildOutput(output) { + outputs.push(output); + }, + }); + const controller = await utoopackAdapter.dev({ + config, + cwd, + signal: new AbortController().signal, + plan: buildContext.plan, + callbacks: { + ...callbacks, + async onBuildFacts(facts, options) { + rebuildFlags.push(options.isRebuild); + return callbacks.onBuildFacts(facts, options); + }, + }, + hooks: [], + }); + + try { + await waitForCondition( + () => outputs.length === 1, + "Utoopack did not publish initial artifacts", + ); + const clientDir = path.join(cwd, "dist/client"); + await fs.promises.writeFile(path.join(clientDir, "main-v2.js"), ""); + await fs.promises.writeFile( + path.join(clientDir, "stats.json"), + JSON.stringify({ + entrypoints: { + main: { assets: [{ name: "main-v2.js" }] }, + }, + }), + "utf-8", + ); + + await waitForCondition( + () => outputs.length === 2, + "Utoopack did not publish rebuilt client facts", + ); + expect(rebuildFlags).toEqual([false, true]); + await expect( + fs.promises.readFile(path.join(clientDir, "index.html"), "utf-8"), + ).resolves.toContain('src="/main-v2.js"'); + } finally { + await controller.close(); + } + }); + it("keeps the last published artifacts while replacement stats are unreadable", async () => { const cwd = await makeProject(); const config = await resolveProjectConfig(cwd, { @@ -607,7 +670,7 @@ describe("utoopackAdapter dev", () => { const initialHtml = await fs.promises.readFile(htmlPath, "utf-8"); const statsPath = path.join(cwd, "dist/client/stats.json"); await fs.promises.writeFile(statsPath, "null", "utf-8"); - const pending = controller.processServerStatsChange("malformed"); + const pending = controller.processStatsChange("malformed"); await new Promise((resolve) => setTimeout(resolve, 75)); expect(outputs).toHaveLength(1); diff --git a/packages/bundler-utoopack/tests/server-stats-monitor.test.ts b/packages/bundler-utoopack/tests/server-stats-monitor.test.ts deleted file mode 100644 index 089e9356..00000000 --- a/packages/bundler-utoopack/tests/server-stats-monitor.test.ts +++ /dev/null @@ -1,54 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { describe, expect, it, vi } from "vitest"; -import { - readServerStatsVersion, - startUtoopackServerStatsMonitor, -} from "../src/adapter/server-stats-monitor.js"; - -describe("Utoopack server stats monitor", () => { - it("does not let a deferred older observation replace an advanced baseline", async () => { - const cwd = await fs.promises.mkdtemp( - path.join(os.tmpdir(), "evjs-utoo-stats-monitor-"), - ); - const statsPath = path.join(cwd, "stats.json"); - await fs.promises.writeFile(statsPath, '{"version":1}', "utf-8"); - - let markObserved!: () => void; - const observed = new Promise((resolve) => { - markObserved = resolve; - }); - let releaseObservation!: () => void; - const observationGate = new Promise((resolve) => { - releaseObservation = resolve; - }); - const onChange = vi.fn(async () => { - markObserved(); - await observationGate; - return true; - }); - const monitor = startUtoopackServerStatsMonitor({ - statsPath, - initialVersion: undefined, - onChange, - onError(error) { - throw error; - }, - }); - - try { - await observed; - await fs.promises.writeFile(statsPath, '{"version":2}', "utf-8"); - monitor.advance(await readServerStatsVersion(statsPath)); - releaseObservation(); - - await new Promise((resolve) => setTimeout(resolve, 250)); - expect(onChange).toHaveBeenCalledTimes(1); - } finally { - releaseObservation(); - await monitor.close(); - await fs.promises.rm(cwd, { recursive: true, force: true }); - } - }); -}); diff --git a/packages/bundler-utoopack/tests/stats-watcher.test.ts b/packages/bundler-utoopack/tests/stats-watcher.test.ts new file mode 100644 index 00000000..c48a5cff --- /dev/null +++ b/packages/bundler-utoopack/tests/stats-watcher.test.ts @@ -0,0 +1,124 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it, vi } from "vitest"; +import { + readStatsVersion, + startUtoopackStatsWatcher, +} from "../src/adapter/stats-watcher.js"; + +describe("Utoopack stats watcher", () => { + it("does not let a deferred older observation replace an advanced baseline", async () => { + const cwd = await fs.promises.mkdtemp( + path.join(os.tmpdir(), "evjs-utoo-stats-watcher-"), + ); + const statsPath = path.join(cwd, "stats.json"); + await fs.promises.writeFile(statsPath, '{"version":1}', "utf-8"); + + let markObserved!: () => void; + const observed = new Promise((resolve) => { + markObserved = resolve; + }); + let releaseObservation!: () => void; + const observationGate = new Promise((resolve) => { + releaseObservation = resolve; + }); + const onChange = vi.fn(async () => { + markObserved(); + await observationGate; + return true; + }); + const watcher = startUtoopackStatsWatcher({ + statsPaths: [statsPath], + initialVersion: undefined, + onChange, + onError(error) { + throw error; + }, + }); + + try { + await observed; + await fs.promises.writeFile(statsPath, '{"version":2}', "utf-8"); + watcher.advance(await readStatsVersion([statsPath])); + releaseObservation(); + + await new Promise((resolve) => setTimeout(resolve, 250)); + expect(onChange).toHaveBeenCalledTimes(1); + } finally { + releaseObservation(); + await watcher.close(); + await fs.promises.rm(cwd, { recursive: true, force: true }); + } + }); + + it("coalesces client and server stats changed before the ready scan", async () => { + const cwd = await fs.promises.mkdtemp( + path.join(os.tmpdir(), "evjs-utoo-stats-watcher-"), + ); + const clientStatsPath = path.join(cwd, "client-stats.json"); + const serverStatsPath = path.join(cwd, "server-stats.json"); + const statsPaths = [clientStatsPath, serverStatsPath]; + await Promise.all([ + fs.promises.writeFile(clientStatsPath, '{"version":1}', "utf-8"), + fs.promises.writeFile(serverStatsPath, '{"version":1}', "utf-8"), + ]); + const initialVersion = await readStatsVersion(statsPaths); + await Promise.all([ + fs.promises.writeFile(clientStatsPath, '{"version":22}', "utf-8"), + fs.promises.writeFile(serverStatsPath, '{"version":22}', "utf-8"), + ]); + const onChange = vi.fn(async () => true); + const watcher = startUtoopackStatsWatcher({ + statsPaths, + initialVersion, + onChange, + onError(error) { + throw error; + }, + }); + + try { + const deadline = Date.now() + 1_000; + while (onChange.mock.calls.length === 0 && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 20)); + } + await new Promise((resolve) => setTimeout(resolve, 150)); + + expect(onChange).toHaveBeenCalledTimes(1); + } finally { + await watcher.close(); + await fs.promises.rm(cwd, { recursive: true, force: true }); + } + }); + + it("observes a stats file created after the watcher starts", async () => { + const cwd = await fs.promises.mkdtemp( + path.join(os.tmpdir(), "evjs-utoo-stats-watcher-"), + ); + const statsPath = path.join(cwd, "stats.json"); + const onChange = vi.fn(async () => true); + const watcher = startUtoopackStatsWatcher({ + statsPaths: [statsPath], + initialVersion: undefined, + onChange, + onError(error) { + throw error; + }, + }); + + try { + await new Promise((resolve) => setTimeout(resolve, 50)); + await fs.promises.writeFile(statsPath, '{"version":1}', "utf-8"); + const deadline = Date.now() + 1_000; + while (onChange.mock.calls.length === 0 && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 20)); + } + + expect(onChange).toHaveBeenCalledTimes(1); + } finally { + await watcher.close(); + await fs.promises.rm(cwd, { recursive: true, force: true }); + } + }); +}); From 8c4219ddc94baa60842a5cdc4340e25c9a5669e4 Mon Sep 17 00:00:00 2001 From: zoomdong <1344492820@qq.com> Date: Fri, 30 Oct 2026 15:05:18 +0800 Subject: [PATCH 2/6] refactor(utoopack): simplify client rebuild publication --- .../bundler-utoopack/src/adapter/adapter.ts | 65 +- .../src/adapter/development/stats-watcher.ts | 124 +--- .../src/adapter/output/manifest-generator.ts | 43 +- .../bundler-utoopack/tests/adapter.test.ts | 594 ++++-------------- .../tests/stats-watcher.test.ts | 294 +-------- 5 files changed, 227 insertions(+), 893 deletions(-) diff --git a/packages/bundler-utoopack/src/adapter/adapter.ts b/packages/bundler-utoopack/src/adapter/adapter.ts index 67177c16..97a0fdce 100644 --- a/packages/bundler-utoopack/src/adapter/adapter.ts +++ b/packages/bundler-utoopack/src/adapter/adapter.ts @@ -38,9 +38,8 @@ import { type UtoopackDevWorkerHandle, } from "./development/dev-worker-client.js"; import { - readStatsVersion, - startUtoopackStatsWatcher, - type UtoopackStatsWatcher, + readClientStatsVersion, + watchClientStats, } from "./development/stats-watcher.js"; import { runUtoopackBuild } from "./execution/utoopack.js"; import { UtoopackManifestGenerator } from "./output/manifest-generator.js"; @@ -112,10 +111,10 @@ interface DevBuildSnapshot { clientStatsVersion: string | undefined; } -function getClientStatsPaths(cwd: string, plan: BuildPlan): string[] { +function getClientStatsPath(cwd: string, plan: BuildPlan): string | undefined { return plan.entries.some((entry) => entry.environment === "client") - ? [path.join(resolveBuildOutputPaths(cwd, plan).clientDir, "stats.json")] - : []; + ? path.join(resolveBuildOutputPaths(cwd, plan).clientDir, "stats.json") + : undefined; } async function collectDevBuildSnapshot( @@ -123,13 +122,11 @@ async function collectDevBuildSnapshot( plan: BuildPlan, clientOnly = false, ): Promise { - const statsPaths = getClientStatsPaths(cwd, plan); - const clientStatsVersion = await readStatsVersion(statsPaths); + const statsPath = getClientStatsPath(cwd, plan); + const clientStatsVersion = await readClientStatsVersion(statsPath); const generator = new UtoopackManifestGenerator(cwd, plan); - const facts = await (clientOnly - ? generator.collectClientBuildFacts() - : generator.collectBuildFacts()); - if (clientStatsVersion !== (await readStatsVersion(statsPaths))) { + const facts = await generator.collectBuildFacts({ clientOnly }); + if (clientStatsVersion !== (await readClientStatsVersion(statsPath))) { throw new Error("[evjs] Utoopack client stats changed while being read."); } return { facts, clientStatsVersion }; @@ -421,7 +418,7 @@ class UtoopackDevController implements BundlerDevController { private hasEmittedDevArtifacts = false; private acceptedFacts: BundlerBuildFacts | undefined; private lastPublishedClientVersion: string | undefined; - private clientStatsWatcher: UtoopackStatsWatcher | undefined; + private clientStatsWatcher: ReturnType | undefined; private closed = false; private closePromise: Promise | undefined; private readonly closingController = new AbortController(); @@ -481,16 +478,20 @@ class UtoopackDevController implements BundlerDevController { } startClientStatsWatcher(): void { - const statsPaths = getClientStatsPaths(this.options.cwd, this.options.plan); - if (this.closed || statsPaths.length === 0) return; - this.clientStatsWatcher = startUtoopackStatsWatcher({ - statsPaths, - initialVersion: this.lastPublishedClientVersion, - onChange: () => this.processClientBuild(), - onError(error) { + const statsPath = getClientStatsPath(this.options.cwd, this.options.plan); + if (this.closed || !statsPath) return; + const onError = (error: unknown) => { + if (!this.closed) { logger.error`Failed to process Utoopack client rebuild: ${error}`; + } + }; + this.clientStatsWatcher = watchClientStats( + statsPath, + () => { + if (!this.closed) void this.processClientBuild().catch(onError); }, - }); + onError, + ); } async close(): Promise { @@ -532,20 +533,15 @@ class UtoopackDevController implements BundlerDevController { }); } - private processClientBuild(): Promise { + private processClientBuild(): Promise { return this.enqueueDevWork(async () => { - if (this.closed) return false; + if (this.closed) return; // The native server endpoint owns the first publication and API ACK. - if (hasServerEntries(this.options.plan) && !this.acceptedFacts) - return false; - const version = await readStatsVersion( - getClientStatsPaths(this.options.cwd, this.options.plan), + if (hasServerEntries(this.options.plan) && !this.acceptedFacts) return; + const version = await readClientStatsVersion( + getClientStatsPath(this.options.cwd, this.options.plan), ); - if (!version) return false; - if (version === this.lastPublishedClientVersion) { - this.clientStatsWatcher?.advance(version); - return true; - } + if (!version || version === this.lastPublishedClientVersion) return; let snapshot: DevBuildSnapshot; try { snapshot = await collectDevBuildSnapshot( @@ -555,7 +551,7 @@ class UtoopackDevController implements BundlerDevController { ); } catch { // A partial/invalid client write must not block a server endpoint ACK. - return false; + return; } if (this.acceptedFacts) { snapshot.facts = { @@ -569,7 +565,7 @@ class UtoopackDevController implements BundlerDevController { }, }; } - return (await this.publishBuildFacts(snapshot, false)) === "published"; + await this.publishBuildFacts(snapshot, false); }); } @@ -616,7 +612,6 @@ class UtoopackDevController implements BundlerDevController { } this.acceptedFacts = snapshot.facts; this.lastPublishedClientVersion = snapshot.clientStatsVersion; - this.clientStatsWatcher?.advance(snapshot.clientStatsVersion); } return disposition; } diff --git a/packages/bundler-utoopack/src/adapter/development/stats-watcher.ts b/packages/bundler-utoopack/src/adapter/development/stats-watcher.ts index ad966e21..85ac3fca 100644 --- a/packages/bundler-utoopack/src/adapter/development/stats-watcher.ts +++ b/packages/bundler-utoopack/src/adapter/development/stats-watcher.ts @@ -2,123 +2,39 @@ import fs from "node:fs"; import path from "node:path"; import { watch } from "chokidar"; -export interface UtoopackStatsWatcher { - /** Advance the authoritative baseline after an out-of-band publication. */ - advance(version: string | undefined): void; - close(): Promise; -} - -interface StartUtoopackStatsWatcherOptions { - statsPaths: readonly string[]; - initialVersion: string | undefined; - /** Return true only when the observed version was consumed or discarded. */ - onChange(version: string): Promise; - onError(error: unknown): void; -} - -async function readStatsFileVersion( - statsPath: string, +export async function readClientStatsVersion( + statsPath: string | undefined, ): Promise { + if (!statsPath) return undefined; 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 | undefined)?.code === "ENOENT") { - return undefined; - } + if ((error as NodeJS.ErrnoException)?.code === "ENOENT") return undefined; throw error; } } -export async function readStatsVersion( - statsPaths: readonly string[], -): Promise { - const versions = await Promise.all(statsPaths.map(readStatsFileVersion)); - if (versions.every((version) => version === undefined)) return undefined; - return versions - .map((version, index) => `${index}:${version ?? "missing"}`) - .join("|"); -} - -/** - * Observe settled stats writes without owning the native build lifecycle. - * - * `@utoo/pack` invokes `onReady` for the initial build and server startup, not - * later completed rebuilds. Watch the authoritative `stats.json` files and - * wait for atomic writes to settle before evjs relinks build facts. A ready - * scan closes the watcher-attachment race without a perpetual polling task. - */ -export function startUtoopackStatsWatcher( - options: StartUtoopackStatsWatcherOptions, -): UtoopackStatsWatcher { - let currentVersion = options.initialVersion; - let baselineRevision = 0; - let closed = false; - let scheduled = false; - let queue = Promise.resolve(); - let closePromise: Promise | undefined; - const statsPaths = new Set( - options.statsPaths.map((value) => path.resolve(value)), - ); - const statsDirectories = [ - ...new Set([...statsPaths].map((value) => path.dirname(value))), - ]; - const watcher = watch(statsDirectories, { +/** Notify the host queue when settled client stats change, including startup. */ +export function watchClientStats( + statsPath: string, + onChange: () => void, + onError: (error: unknown) => void, +) { + const resolvedPath = path.resolve(statsPath); + const watcher = watch(path.dirname(resolvedPath), { atomic: true, - awaitWriteFinish: { - stabilityThreshold: 50, - pollInterval: 10, - }, + awaitWriteFinish: { stabilityThreshold: 50, pollInterval: 10 }, depth: 0, disableGlobbing: true, ignoreInitial: true, }); - - function scheduleStatsChange(changedPath: string): void { - if (statsPaths.has(path.resolve(changedPath))) scheduleChange(); - } - - function scheduleChange(): void { - if (closed || scheduled) return; - scheduled = true; - queue = queue.then(processChange, processChange); - } - - async function processChange(): Promise { - scheduled = false; - if (closed) return; - try { - const observedRevision = baselineRevision; - const nextVersion = await readStatsVersion(options.statsPaths); - if (closed || !nextVersion || nextVersion === currentVersion) return; - const consumed = await options.onChange(nextVersion); - if (consumed && observedRevision === baselineRevision) { - currentVersion = nextVersion; - } - } catch (error) { - if (!closed) options.onError(error); - } - } - - watcher.on("add", scheduleStatsChange); - watcher.on("change", scheduleStatsChange); - watcher.on("error", (error) => { - if (!closed) options.onError(error); - }); - watcher.on("ready", scheduleChange); - - return { - advance(version) { - baselineRevision += 1; - currentVersion = version; - }, - close() { - closePromise ??= (async () => { - closed = true; - await watcher.close(); - await queue; - })(); - return closePromise; - }, + const onFile = (file: string) => { + if (path.resolve(file) === resolvedPath) onChange(); }; + watcher.on("add", onFile); + watcher.on("change", onFile); + watcher.on("ready", onChange); + watcher.on("error", onError); + return watcher; } diff --git a/packages/bundler-utoopack/src/adapter/output/manifest-generator.ts b/packages/bundler-utoopack/src/adapter/output/manifest-generator.ts index 28adf376..91aa835b 100644 --- a/packages/bundler-utoopack/src/adapter/output/manifest-generator.ts +++ b/packages/bundler-utoopack/src/adapter/output/manifest-generator.ts @@ -173,18 +173,26 @@ export class UtoopackManifestGenerator { this.serverEmittedFiles = readEmittedFiles(stats); } - /** Collect client output without reading a server batch awaiting host ACK. */ - async collectClientBuildFacts(): Promise { + async collectBuildFacts({ + clientOnly = false, + } = {}): Promise { await this.loadClientStats(); - const facts: BundlerBuildFacts = { + if (!clientOnly) await this.loadServerStats(); + const emittedFiles = { ...(this.clientEmittedFiles - ? { - emittedFiles: { - client: includeAdapterStatsFile(this.clientEmittedFiles), - }, - } + ? { client: includeAdapterStatsFile(this.clientEmittedFiles) } : {}), + ...(!clientOnly && this.serverEmittedFiles + ? { server: includeAdapterStatsFile(this.serverEmittedFiles) } + : {}), + }; + + const facts: BundlerBuildFacts = { + ...(Object.keys(emittedFiles).length > 0 ? { emittedFiles } : {}), clientEntryAssets: cloneEntryAssets(this.clientEntryAssets), + ...(!clientOnly + ? { serverEntryAssets: cloneEntryAssets(this.serverEntryAssets) } + : {}), }; assertBundlerEmittedFiles(facts.emittedFiles); assertBuildOutputLinkInputClientAssets({ @@ -194,25 +202,6 @@ export class UtoopackManifestGenerator { return facts; } - async collectBuildFacts(): Promise { - const clientFacts = await this.collectClientBuildFacts(); - await this.loadServerStats(); - const facts: BundlerBuildFacts = { - ...clientFacts, - ...(this.serverEmittedFiles - ? { - emittedFiles: { - ...clientFacts.emittedFiles, - server: includeAdapterStatsFile(this.serverEmittedFiles), - }, - } - : {}), - serverEntryAssets: cloneEntryAssets(this.serverEntryAssets), - }; - assertBundlerEmittedFiles(facts.emittedFiles); - return facts; - } - async build(): Promise { return this.collectBuildFacts(); } diff --git a/packages/bundler-utoopack/tests/adapter.test.ts b/packages/bundler-utoopack/tests/adapter.test.ts index e8b090a9..cf652aef 100644 --- a/packages/bundler-utoopack/tests/adapter.test.ts +++ b/packages/bundler-utoopack/tests/adapter.test.ts @@ -250,19 +250,18 @@ async function waitForCondition( } function clientStatsFor(assets: string[], includeInventory = true): string { + const values = assets.map((name) => ({ name })); return JSON.stringify({ - ...(includeInventory ? { assets: assets.map((name) => ({ name })) } : {}), - entrypoints: { - main: { assets: assets.map((name) => ({ name })) }, - }, + ...(includeInventory ? { assets: values } : {}), + entrypoints: { main: { assets: values } }, }); } async function writeClientStats( cwd: string, assets: string[], - includeInventory = true, -): Promise { + inventory = true, +) { const clientDir = path.join(cwd, "dist/client"); await fs.promises.mkdir(clientDir, { recursive: true }); await Promise.all( @@ -272,16 +271,71 @@ async function writeClientStats( ); await fs.promises.writeFile( path.join(clientDir, "stats.json"), - clientStatsFor(assets, includeInventory), + clientStatsFor(assets, inventory), ); } -function deferred() { - let resolve!: () => void; - const promise = new Promise((next) => { - resolve = next; +async function startDevFixture( + server = false, + beforePublish?: (isRebuild: boolean) => Promise, +) { + const cwd = await makeProject(); + const initialAssets = ["root-old.js", "main.js"]; + utoopackMock.clientStats = clientStatsFor(initialAssets); + await writeClientStats(cwd, initialAssets); + const config = await resolveProjectConfig(cwd, { routing: { mode: "spa" } }); + const context = await createBuildContext(config, cwd); + if (server) { + const page = context.graph.pages.index; + if (!page) throw new Error("Expected index Page."); + page.render = "ssr"; + context.plan = createBuildPlan(config, context.graph, { + mode: "development", + }); + } + const outputs: BuildOutput[] = []; + const facts: BundlerBuildFacts[] = []; + const rebuildFlags: boolean[] = []; + const onServerBundleReady = vi.fn(async () => {}); + const callbacks = createFrameworkCallbacks({ + config, + cwd, + ...context, + onBuildOutput: (output) => { + outputs.push(output); + }, + onServerBundleReady, }); - return { promise, resolve }; + const controller = await utoopackAdapter.dev({ + config, + cwd, + plan: context.plan, + signal: new AbortController().signal, + hooks: [], + callbacks: { + ...callbacks, + async onBuildFacts(snapshot, options) { + facts.push(snapshot); + rebuildFlags.push(options.isRebuild); + await beforePublish?.(options.isRebuild); + return callbacks.onBuildFacts(snapshot, options); + }, + }, + }); + return { + cwd, + controller, + outputs, + facts, + rebuildFlags, + onServerBundleReady, + html: () => + fs.promises.readFile(path.join(cwd, "dist/client/index.html"), "utf-8"), + serverBuild: () => + utoopackMock.startUtoopackDevWorker.mock.calls + .at(-1)?.[0] + .onServerBuild?.(), + }; } async function makeProject(pageId = "index") { @@ -545,465 +599,106 @@ describe("utoopackAdapter browser log forwarding config", () => { }); describe("utoopackAdapter dev", () => { - it("automatically republishes HTML and metadata when a client chunk changes", async () => { - const cwd = await makeProject(); - const initialAssets = ["root-old.js", "main.js"]; - utoopackMock.clientStats = clientStatsFor(initialAssets); - await writeClientStats(cwd, initialAssets); - const config = await resolveProjectConfig(cwd, { - routing: { mode: "spa" }, - }); - const buildContext = await createBuildContext(config, cwd); - const outputs: BuildOutput[] = []; - const rebuildFlags: boolean[] = []; - const onServerBundleReady = vi.fn(async () => {}); - const callbacks = createFrameworkCallbacks({ - config, - cwd, - ...buildContext, - onBuildOutput(output) { - outputs.push(output); - }, - onServerBundleReady, - }); - const controller = await utoopackAdapter.dev({ - config, - cwd, - signal: new AbortController().signal, - plan: buildContext.plan, - callbacks: { - ...callbacks, - async onBuildFacts(facts, options) { - rebuildFlags.push(options.isRebuild); - return callbacks.onBuildFacts(facts, options); - }, - }, - hooks: [], - }); - + it("automatically republishes HTML and metadata and recovers from incomplete client stats", async () => { + const app = await startDevFixture(); try { - await waitForCondition( - () => outputs.length === 1, - "Utoopack did not publish initial client artifacts", - ); - 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"); + await expect.poll(() => app.outputs.length).toBe(1); + const initialHtml = await app.html(); + expect(initialHtml).toContain('src="/root-old.js"'); + for (const stats of ['{"entrypoints":', '{"entrypoints":{}}']) { + await fs.promises.writeFile( + path.join(app.cwd, "dist/client/stats.json"), + stats, + ); + await new Promise((resolve) => setTimeout(resolve, 150)); + expect(await app.html()).toBe(initialHtml); + expect(app.outputs).toHaveLength(1); + } + await writeClientStats(app.cwd, ["root-new.js", "main.js", "new.css"]); + await expect.poll(app.html).toContain('src="/root-new.js"'); + expect(await app.html()).not.toContain('src="/root-old.js"'); const metadata = JSON.parse( await fs.promises.readFile( - path.join(cwd, "dist/deployment-metadata.json"), + path.join(app.cwd, "dist/deployment-metadata.json"), "utf-8", ), ); - expect(html).not.toContain('src="/root-old.js"'); - expect(html).toContain('href="/new.css"'); expect(metadata.documents[0].assets).toEqual({ js: ["root-new.js", "main.js"], css: ["new.css"], }); - expect(outputs).toHaveLength(2); - expect(rebuildFlags).toEqual([false, true]); - expect(onServerBundleReady).not.toHaveBeenCalled(); + expect(app.rebuildFlags).toEqual([false, true]); + expect(app.onServerBundleReady).not.toHaveBeenCalled(); } finally { - await controller.close(); + await app.controller.close(); } + await writeClientStats(app.cwd, ["root-after-close.js", "main.js"]); + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(app.outputs).toHaveLength(2); + expect(await app.html()).toContain('src="/root-new.js"'); }); - it("keeps the last HTML through incomplete client rebuild stats and recovers automatically", async () => { - const cwd = await makeProject(); - const config = await resolveProjectConfig(cwd, { - routing: { mode: "spa" }, - }); - const buildContext = await createBuildContext(config, cwd); - const onBuildOutput = vi.fn(); - const controller = await utoopackAdapter.dev({ - config, - cwd, - signal: new AbortController().signal, - plan: buildContext.plan, - callbacks: createFrameworkCallbacks({ - config, - cwd, - ...buildContext, - onBuildOutput, - }), - hooks: [], - }); - - try { - await waitForCondition( - () => onBuildOutput.mock.calls.length === 1, - "Utoopack did not publish initial client artifacts", - ); - 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, 150)); - 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("publishes a client rebuild without restarting an existing API runtime", async () => { - const cwd = await makeProject(); - const config = await resolveProjectConfig(cwd, { - routing: { mode: "spa" }, - }); - const buildContext = await createServerBuildContext(config, cwd); - const onBuildOutput = vi.fn(); - const onServerBundleReady = vi.fn(async () => {}); - const controller = await utoopackAdapter.dev({ - config, - cwd, - signal: new AbortController().signal, - plan: buildContext.plan, - callbacks: createFrameworkCallbacks({ - config, - cwd, - ...buildContext, - onBuildOutput, - onServerBundleReady, - }), - hooks: [], - }); - - try { - await waitForCondition( - () => - onBuildOutput.mock.calls.length === 1 && - onServerBundleReady.mock.calls.length === 1, - "Utoopack did not publish the initial server batch", - ); - await writeClientStats(cwd, ["root-client-only.js", "main.js"]); - await expect - .poll(() => - fs.promises.readFile( - path.join(cwd, "dist/client/index.html"), - "utf-8", - ), - ) - .toContain('src="/root-client-only.js"'); - expect(onBuildOutput).toHaveBeenCalledTimes(2); - expect(onServerBundleReady).toHaveBeenCalledTimes(1); - } finally { - await controller.close(); - } - }); - - it("keeps accepted server facts when client stats change ahead of server publication", async () => { - const cwd = await makeProject(); - utoopackMock.clientStats = clientStatsFor(["main.js", "main.css"]); - const config = await resolveProjectConfig(cwd, { - routing: { mode: "spa" }, - }); - const buildContext = await createServerBuildContext(config, cwd); - const outputs: BuildOutput[] = []; - const factsSnapshots: BundlerBuildFacts[] = []; - const onServerBundleReady = vi.fn(async () => {}); - const callbacks = createFrameworkCallbacks({ - config, - cwd, - ...buildContext, - onBuildOutput(output) { - outputs.push(output); - }, - onServerBundleReady, - }); - const controller = await utoopackAdapter.dev({ - config, - cwd, - signal: new AbortController().signal, - plan: buildContext.plan, - callbacks: { - ...callbacks, - async onBuildFacts(facts, options) { - factsSnapshots.push(facts); - return callbacks.onBuildFacts(facts, options); - }, - }, - hooks: [], - }); - + it("gates initial server publication and reuses accepted server facts for client rebuilds", async () => { + utoopackMock.deferInitialServerBuild = true; + const app = await startDevFixture(true); try { - await waitForCondition( - () => - outputs.length === 1 && onServerBundleReady.mock.calls.length === 1, - "Utoopack did not publish the initial server batch", - ); - const initialOutput = outputs[0]; - if (!initialOutput) throw new Error("Expected initial build output."); - const acceptedServer = structuredClone(initialOutput.server); - expect(factsSnapshots[0]?.emittedFiles?.client).toContain("main.js"); - const pendingFiles = [ - "server-pending.js", - "page-server-index-pending.js", - ]; - await Promise.all( - pendingFiles.map((file) => - fs.promises.writeFile(path.join(cwd, "dist/server", file), ""), - ), - ); + await writeClientStats(app.cwd, ["root-before-server.js", "main.js"]); + await new Promise((resolve) => setTimeout(resolve, 150)); + expect(app.outputs).toHaveLength(0); + expect(app.onServerBundleReady).not.toHaveBeenCalled(); + await app.serverBuild(); + expect(app.outputs).toHaveLength(1); + expect(app.rebuildFlags).toEqual([false]); + const acceptedServer = structuredClone(app.outputs[0]?.server); + expect(app.facts[0]?.emittedFiles?.client).toContain("main.js"); const pendingStats = JSON.stringify({ - assets: pendingFiles.map((name) => ({ name })), entrypoints: { - server: { assets: [{ name: "server-pending.js" }] }, - "page-server-index": { - assets: [{ name: "page-server-index-pending.js" }], - }, + server: { assets: ["server-pending.js"] }, + "page-server-index": { assets: ["page-pending.js"] }, }, }); - for (const [index, serverStats] of ["null", pendingStats].entries()) { await fs.promises.writeFile( - path.join(cwd, "dist/server/stats.json"), + path.join(app.cwd, "dist/server/stats.json"), serverStats, ); - const root = `root-before-server-${index}.js`; - // Entrypoint-only stats must not inherit the previous client inventory. - await writeClientStats(cwd, [root, "main.js"], index === 0); - await expect - .poll(() => - fs.promises.readFile( - path.join(cwd, "dist/client/index.html"), - "utf-8", - ), - ) - .toContain(`src="/${root}"`); - expect(outputs).toHaveLength(index + 2); - expect(outputs.at(-1)?.server).toEqual(acceptedServer); - expect(onServerBundleReady).toHaveBeenCalledTimes(1); - if (index === 1) { - expect(factsSnapshots.at(-1)?.emittedFiles?.client).toBeUndefined(); - } + const root = `root-client-${index}.js`; + await writeClientStats(app.cwd, [root, "main.js"], index === 0); + await expect.poll(app.html).toContain(`src="/${root}"`); + expect(app.outputs.at(-1)?.server).toEqual(acceptedServer); + expect(app.onServerBundleReady).toHaveBeenCalledTimes(1); } + expect(app.outputs).toHaveLength(3); + expect(app.facts.at(-1)?.emittedFiles?.client).toBeUndefined(); } finally { - await controller.close(); + await app.controller.close(); } }); - it("waits for the first server batch before publishing client artifacts", async () => { - utoopackMock.deferInitialServerBuild = true; - const cwd = await makeProject(); - const config = await resolveProjectConfig(cwd, { - routing: { mode: "spa" }, + it("serializes automatic client publication with a concurrent server batch", async () => { + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; }); - const buildContext = await createServerBuildContext(config, cwd); - const onBuildOutput = vi.fn(); - const onServerBundleReady = vi.fn(async () => {}); - const rebuildFlags: boolean[] = []; - const callbacks = createFrameworkCallbacks({ - config, - cwd, - ...buildContext, - onBuildOutput, - onServerBundleReady, - }); - const controller = await utoopackAdapter.dev({ - config, - cwd, - signal: new AbortController().signal, - plan: buildContext.plan, - callbacks: { - ...callbacks, - async onBuildFacts(facts, options) { - rebuildFlags.push(options.isRebuild); - return callbacks.onBuildFacts(facts, options); - }, - }, - hooks: [], - }); - - try { - await writeClientStats(cwd, ["root-before-server.js", "main.js"]); - await new Promise((resolve) => setTimeout(resolve, 150)); - expect(onBuildOutput).not.toHaveBeenCalled(); - expect(onServerBundleReady).not.toHaveBeenCalled(); - expect(fs.existsSync(path.join(cwd, "dist/client/index.html"))).toBe( - false, - ); - - const onServerBuild = - utoopackMock.startUtoopackDevWorker.mock.calls.at(-1)?.[0] - .onServerBuild; - if (!onServerBuild) throw new Error("Expected a server build callback."); - await onServerBuild(); - expect(onBuildOutput).toHaveBeenCalledTimes(1); - expect(onServerBundleReady).toHaveBeenCalledTimes(1); - expect(rebuildFlags).toEqual([false]); - await expect( - fs.promises.readFile(path.join(cwd, "dist/client/index.html"), "utf-8"), - ).resolves.toContain('src="/root-before-server.js"'); - } finally { - await controller.close(); - } - }); - - it("serializes client publication with a concurrent server batch", async () => { - const cwd = await makeProject(); - const config = await resolveProjectConfig(cwd, { - routing: { mode: "spa" }, - }); - const buildContext = await createServerBuildContext(config, cwd); - const onBuildOutput = vi.fn(); - const onServerBundleReady = vi.fn(async () => {}); - const callbacks = createFrameworkCallbacks({ - config, - cwd, - ...buildContext, - onBuildOutput, - onServerBundleReady, - }); - const publicationGate = deferred(); - let publicationCount = 0; - let activePublications = 0; - let maximumPublications = 0; - const controller = await utoopackAdapter.dev({ - config, - cwd, - signal: new AbortController().signal, - plan: buildContext.plan, - callbacks: { - ...callbacks, - async onBuildFacts(facts, options) { - publicationCount += 1; - activePublications += 1; - maximumPublications = Math.max( - maximumPublications, - activePublications, - ); - try { - if (publicationCount === 2) await publicationGate.promise; - return await callbacks.onBuildFacts(facts, options); - } finally { - activePublications -= 1; - } - }, - }, - hooks: [], + const app = await startDevFixture(true, async (isRebuild) => { + if (isRebuild) await gate; }); - try { - await waitForCondition( - () => - onBuildOutput.mock.calls.length === 1 && - onServerBundleReady.mock.calls.length === 1, - "Utoopack did not publish the initial server batch", - ); - await writeClientStats(cwd, ["root-queued.js", "main.js"]); - await waitForCondition( - () => publicationCount === 2, - "Utoopack did not begin the client publication", - ); - const onServerBuild = - utoopackMock.startUtoopackDevWorker.mock.calls.at(-1)?.[0] - .onServerBuild; - if (!onServerBuild) throw new Error("Expected a server build callback."); - const serverPublication = onServerBuild(); - await new Promise((resolve) => setTimeout(resolve, 100)); - expect(publicationCount).toBe(2); - expect(maximumPublications).toBe(1); - - publicationGate.resolve(); + await expect.poll(() => app.outputs.length).toBe(1); + await writeClientStats(app.cwd, ["root-queued.js", "main.js"]); + await expect.poll(() => app.facts.length).toBe(2); + const serverPublication = app.serverBuild(); + await new Promise((resolve) => setTimeout(resolve, 75)); + expect(app.facts).toHaveLength(2); + expect(app.outputs).toHaveLength(1); + release(); await serverPublication; - expect(publicationCount).toBe(3); - expect(maximumPublications).toBe(1); - expect(onBuildOutput).toHaveBeenCalledTimes(3); - expect(onServerBundleReady).toHaveBeenCalledTimes(2); + expect(app.rebuildFlags).toEqual([false, true, true]); + expect(app.outputs).toHaveLength(3); + expect(app.onServerBundleReady).toHaveBeenCalledTimes(2); } finally { - publicationGate.resolve(); - await controller.close(); - } - }); - - it("drains active client publication and ignores changes after closing", async () => { - const cwd = await makeProject(); - const config = await resolveProjectConfig(cwd, { - routing: { mode: "spa" }, - }); - const buildContext = await createBuildContext(config, cwd); - const onBuildOutput = vi.fn(); - const callbacks = createFrameworkCallbacks({ - config, - cwd, - ...buildContext, - onBuildOutput, - }); - const publicationGate = deferred(); - let clientPublicationStarted = false; - const controller = await utoopackAdapter.dev({ - config, - cwd, - signal: new AbortController().signal, - plan: buildContext.plan, - callbacks: { - ...callbacks, - async onBuildFacts(facts, options) { - if (options.isRebuild) { - clientPublicationStarted = true; - await publicationGate.promise; - } - return callbacks.onBuildFacts(facts, options); - }, - }, - hooks: [], - }); - - try { - await waitForCondition( - () => onBuildOutput.mock.calls.length === 1, - "Utoopack did not publish initial client artifacts", - ); - await writeClientStats(cwd, ["root-before-close.js", "main.js"]); - await waitForCondition( - () => clientPublicationStarted, - "Utoopack did not begin the client publication", - ); - let closed = false; - const closing = controller.close().then(() => { - closed = true; - }); - await new Promise((resolve) => setTimeout(resolve, 100)); - expect(closed).toBe(false); - - publicationGate.resolve(); - await closing; - expect(onBuildOutput).toHaveBeenCalledTimes(2); - const htmlPath = path.join(cwd, "dist/client/index.html"); - const htmlBeforeChange = await fs.promises.readFile(htmlPath, "utf-8"); - await writeClientStats(cwd, ["root-after-close.js", "main.js"]); - await new Promise((resolve) => setTimeout(resolve, 150)); - expect(await fs.promises.readFile(htmlPath, "utf-8")).toBe( - htmlBeforeChange, - ); - expect(onBuildOutput).toHaveBeenCalledTimes(2); - expect(utoopackMock.workerClose).toHaveBeenCalledTimes(1); - } finally { - publicationGate.resolve(); - await controller.close(); + release(); + await app.controller.close(); } }); @@ -1114,12 +809,8 @@ describe("utoopackAdapter dev", () => { } }); - it.each([ - "null", - '{"entrypoints":', - JSON.stringify({ entrypoints: {} }), - ])("recovers when initial stats are incomplete: %s", async (initialStats) => { - utoopackMock.initialClientStats = initialStats; + it("recovers when initial stats are malformed", async () => { + utoopackMock.initialClientStats = "null"; utoopackMock.clientStatsDelayMs = 100; const cwd = await makeProject(); const config = await resolveProjectConfig(cwd, { @@ -1425,18 +1116,3 @@ async function createBuildContext( }), }; } - -async function createServerBuildContext( - config: ResolvedConfig, - cwd: string, -) { - const initial = await createBuildContext(config, cwd); - const graph = structuredClone(initial.graph); - const page = graph.pages.index; - if (!page) throw new Error("Expected index Page."); - page.render = "ssr"; - return { - graph, - plan: createBuildPlan(config, graph, { mode: "development" }), - }; -} diff --git a/packages/bundler-utoopack/tests/stats-watcher.test.ts b/packages/bundler-utoopack/tests/stats-watcher.test.ts index a62b6656..e7181ac2 100644 --- a/packages/bundler-utoopack/tests/stats-watcher.test.ts +++ b/packages/bundler-utoopack/tests/stats-watcher.test.ts @@ -1,272 +1,30 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { describe, expect, it, vi } from "vitest"; -import { - readStatsVersion, - startUtoopackStatsWatcher, -} from "../src/adapter/development/stats-watcher.js"; - -async function deferNextStatsRead(statsPath: string) { - const stats = await fs.promises.stat(statsPath, { bigint: true }); - let markStarted!: () => void; - const started = new Promise((resolve) => { - markStarted = resolve; - }); - let release!: () => void; - const gate = new Promise((resolve) => { - release = resolve; - }); - const spy = vi.spyOn(fs.promises, "stat").mockImplementationOnce(async () => { - markStarted(); - await gate; - return stats; - }); - return { started, release, restore: () => spy.mockRestore() }; -} - -describe("Utoopack stats watcher", () => { - it("preserves an advanced baseline while an older stats read is pending", async () => { - const cwd = await fs.promises.mkdtemp( - path.join(os.tmpdir(), "evjs-utoo-stats-watcher-"), - ); - const statsPath = path.join(cwd, "stats.json"); - await fs.promises.writeFile(statsPath, '{"version":1}', "utf-8"); - const olderVersion = await readStatsVersion([statsPath]); - const pendingRead = await deferNextStatsRead(statsPath); - const onChange = vi.fn(async () => true); - const onError = vi.fn(); - const watcher = startUtoopackStatsWatcher({ - statsPaths: [statsPath], - initialVersion: undefined, - onChange, - onError, - }); - - try { - await pendingRead.started; - await fs.promises.writeFile(statsPath, '{"version":22}', "utf-8"); - watcher.advance(await readStatsVersion([statsPath])); - pendingRead.release(); - await vi.waitFor(() => expect(onChange).toHaveBeenCalled()); - await new Promise((resolve) => setTimeout(resolve, 200)); - expect(onChange).toHaveBeenCalledExactlyOnceWith(olderVersion); - - await fs.promises.writeFile(statsPath, '{"version":333}', "utf-8"); - await vi.waitFor(() => expect(onChange).toHaveBeenCalledTimes(2)); - expect(onError).not.toHaveBeenCalled(); - } finally { - pendingRead.release(); - await watcher.close(); - pendingRead.restore(); - await fs.promises.rm(cwd, { recursive: true, force: true }); - } - }); - - it("does not begin publication when closing interrupts a stats read", async () => { - const cwd = await fs.promises.mkdtemp( - path.join(os.tmpdir(), "evjs-utoo-stats-watcher-"), - ); - const statsPath = path.join(cwd, "stats.json"); - await fs.promises.writeFile(statsPath, '{"version":1}', "utf-8"); - const pendingRead = await deferNextStatsRead(statsPath); - const onChange = vi.fn(async () => true); - const onError = vi.fn(); - const watcher = startUtoopackStatsWatcher({ - statsPaths: [statsPath], - initialVersion: undefined, - onChange, - onError, - }); - - try { - await pendingRead.started; - const closing = watcher.close(); - pendingRead.release(); - await closing; - expect(onChange).not.toHaveBeenCalled(); - expect(onError).not.toHaveBeenCalled(); - } finally { - pendingRead.release(); - await watcher.close(); - pendingRead.restore(); - await fs.promises.rm(cwd, { recursive: true, force: true }); - } - }); - - it("makes every close wait for an in-flight publication", async () => { - const cwd = await fs.promises.mkdtemp( - path.join(os.tmpdir(), "evjs-utoo-stats-watcher-"), - ); - const statsPath = path.join(cwd, "stats.json"); - await fs.promises.writeFile(statsPath, '{"version":1}', "utf-8"); - - let markObserved!: () => void; - const observed = new Promise((resolve) => { - markObserved = resolve; - }); - let releasePublication!: () => void; - const publicationGate = new Promise((resolve) => { - releasePublication = resolve; - }); - const onChange = vi.fn(async () => { - markObserved(); - await publicationGate; - return true; - }); - const onError = vi.fn(); - const watcher = startUtoopackStatsWatcher({ - statsPaths: [statsPath], - initialVersion: undefined, - onChange, - onError, - }); - - try { - await observed; - const closing = watcher.close(); - const repeatedClose = watcher.close(); - let firstClosed = false; - let secondClosed = false; - void closing.then(() => { - firstClosed = true; - }); - void repeatedClose.then(() => { - secondClosed = true; - }); - - await fs.promises.writeFile(statsPath, '{"version":22}', "utf-8"); - expect(firstClosed).toBe(false); - expect(secondClosed).toBe(false); - expect(repeatedClose).toBe(closing); - - releasePublication(); - await Promise.all([closing, repeatedClose]); - expect(firstClosed).toBe(true); - expect(secondClosed).toBe(true); - expect(watcher.close()).toBe(closing); - await fs.promises.writeFile(statsPath, '{"version":333}', "utf-8"); - await new Promise((resolve) => setTimeout(resolve, 100)); - expect(onChange).toHaveBeenCalledTimes(1); - expect(onError).not.toHaveBeenCalled(); - } finally { - releasePublication(); - await watcher.close(); - await fs.promises.rm(cwd, { recursive: true, force: true }); - } - }); - - it("does not let a deferred older observation replace an advanced baseline", async () => { - const cwd = await fs.promises.mkdtemp( - path.join(os.tmpdir(), "evjs-utoo-stats-watcher-"), - ); - const statsPath = path.join(cwd, "stats.json"); - await fs.promises.writeFile(statsPath, '{"version":1}', "utf-8"); - - let markObserved!: () => void; - const observed = new Promise((resolve) => { - markObserved = resolve; - }); - let releaseObservation!: () => void; - const observationGate = new Promise((resolve) => { - releaseObservation = resolve; - }); - const onChange = vi.fn(async () => { - markObserved(); - await observationGate; - return true; - }); - const watcher = startUtoopackStatsWatcher({ - statsPaths: [statsPath], - initialVersion: undefined, - onChange, - onError(error) { - throw error; - }, - }); - - try { - await observed; - await fs.promises.writeFile(statsPath, '{"version":2}', "utf-8"); - watcher.advance(await readStatsVersion([statsPath])); - releaseObservation(); - - await new Promise((resolve) => setTimeout(resolve, 250)); - expect(onChange).toHaveBeenCalledTimes(1); - } finally { - releaseObservation(); - await watcher.close(); - await fs.promises.rm(cwd, { recursive: true, force: true }); - } - }); - - it("coalesces client and server stats changed before the ready scan", async () => { - const cwd = await fs.promises.mkdtemp( - path.join(os.tmpdir(), "evjs-utoo-stats-watcher-"), - ); - const clientStatsPath = path.join(cwd, "client-stats.json"); - const serverStatsPath = path.join(cwd, "server-stats.json"); - const statsPaths = [clientStatsPath, serverStatsPath]; - await Promise.all([ - fs.promises.writeFile(clientStatsPath, '{"version":1}', "utf-8"), - fs.promises.writeFile(serverStatsPath, '{"version":1}', "utf-8"), - ]); - const initialVersion = await readStatsVersion(statsPaths); - await Promise.all([ - fs.promises.writeFile(clientStatsPath, '{"version":22}', "utf-8"), - fs.promises.writeFile(serverStatsPath, '{"version":22}', "utf-8"), - ]); - const onChange = vi.fn(async () => true); - const watcher = startUtoopackStatsWatcher({ - statsPaths, - initialVersion, - onChange, - onError(error) { - throw error; - }, - }); - - try { - const deadline = Date.now() + 1_000; - while (onChange.mock.calls.length === 0 && Date.now() < deadline) { - await new Promise((resolve) => setTimeout(resolve, 20)); - } - await new Promise((resolve) => setTimeout(resolve, 150)); - - expect(onChange).toHaveBeenCalledTimes(1); - } finally { - await watcher.close(); - await fs.promises.rm(cwd, { recursive: true, force: true }); - } - }); - - it("observes a stats file created after the watcher starts", async () => { - const cwd = await fs.promises.mkdtemp( - path.join(os.tmpdir(), "evjs-utoo-stats-watcher-"), - ); - const statsPath = path.join(cwd, "stats.json"); - const onChange = vi.fn(async () => true); - const watcher = startUtoopackStatsWatcher({ - statsPaths: [statsPath], - initialVersion: undefined, - onChange, - onError(error) { - throw error; - }, - }); - - try { - await new Promise((resolve) => setTimeout(resolve, 50)); - await fs.promises.writeFile(statsPath, '{"version":1}', "utf-8"); - const deadline = Date.now() + 1_000; - while (onChange.mock.calls.length === 0 && Date.now() < deadline) { - await new Promise((resolve) => setTimeout(resolve, 20)); - } - - expect(onChange).toHaveBeenCalledTimes(1); - } finally { - await watcher.close(); - await fs.promises.rm(cwd, { recursive: true, force: true }); - } - }); +import { expect, it, vi } from "vitest"; +import { watchClientStats } from "../src/adapter/development/stats-watcher.js"; + +it("scans stats on ready, filters unrelated files, and stops on close", async () => { + const cwd = await fs.promises.mkdtemp(path.join(os.tmpdir(), "evjs-stats-")); + const statsPath = path.join(cwd, "stats.json"); + await fs.promises.writeFile(statsPath, "{}"); + const onChange = vi.fn(); + const onError = vi.fn(); + const watcher = watchClientStats(statsPath, onChange, onError); + try { + await vi.waitFor(() => expect(onChange).toHaveBeenCalledTimes(1)); + await fs.promises.writeFile(path.join(cwd, "other.json"), "{}"); + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(onChange).toHaveBeenCalledTimes(1); + await fs.promises.writeFile(statsPath, '{"updated":true}'); + await vi.waitFor(() => expect(onChange).toHaveBeenCalledTimes(2)); + await watcher.close(); + await fs.promises.writeFile(statsPath, "{}"); + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(onChange).toHaveBeenCalledTimes(2); + expect(onError).not.toHaveBeenCalled(); + } finally { + await watcher.close(); + await fs.promises.rm(cwd, { recursive: true, force: true }); + } }); From aad5f26adeda5be073d165f21173088d7382acdc Mon Sep 17 00:00:00 2001 From: zoomdong <1344492820@qq.com> Date: Fri, 30 Oct 2026 15:40:45 +0800 Subject: [PATCH 3/6] fix(utoopack): publish client artifacts from build callbacks --- package-lock.json | 1 - packages/bundler-utoopack/package.json | 1 - .../bundler-utoopack/src/adapter/adapter.ts | 255 ++++------------- .../adapter/development/dev-worker-client.ts | 24 +- .../development/dev-worker-protocol.ts | 19 +- .../src/adapter/development/dev-worker.ts | 107 ++++--- .../src/adapter/development/stats-watcher.ts | 40 --- .../src/adapter/execution/utoopack.ts | 9 +- .../bundler-utoopack/src/adapter/index.ts | 2 +- .../src/adapter/output/manifest-generator.ts | 29 +- .../bundler-utoopack/tests/adapter.test.ts | 264 +++++------------- .../tests/dev-worker-client.test.ts | 33 ++- .../tests/dev-worker-native-session.test.ts | 2 + .../tests/fixtures/dev-worker-client.mjs | 30 +- .../dev-worker-native-owner-harness.mjs | 41 ++- .../tests/stats-watcher.test.ts | 30 -- 16 files changed, 356 insertions(+), 531 deletions(-) delete mode 100644 packages/bundler-utoopack/src/adapter/development/stats-watcher.ts delete mode 100644 packages/bundler-utoopack/tests/stats-watcher.test.ts diff --git a/package-lock.json b/package-lock.json index cc048a3f..3c156950 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24872,7 +24872,6 @@ "@evjs/shared": "*", "@logtape/logtape": "^2.0.4", "@utoo/pack": "^1.5.28", - "chokidar": "^3.6.0", "fast-glob": "^3.3.3", "less": "4.1.3", "less-loader": "12.3.2" diff --git a/packages/bundler-utoopack/package.json b/packages/bundler-utoopack/package.json index e5e333ba..568c1276 100644 --- a/packages/bundler-utoopack/package.json +++ b/packages/bundler-utoopack/package.json @@ -36,7 +36,6 @@ "@evjs/shared": "*", "@logtape/logtape": "^2.0.4", "@utoo/pack": "^1.5.28", - "chokidar": "^3.6.0", "fast-glob": "^3.3.3", "less": "4.1.3", "less-loader": "12.3.2" diff --git a/packages/bundler-utoopack/src/adapter/adapter.ts b/packages/bundler-utoopack/src/adapter/adapter.ts index 97a0fdce..b70a2ac6 100644 --- a/packages/bundler-utoopack/src/adapter/adapter.ts +++ b/packages/bundler-utoopack/src/adapter/adapter.ts @@ -9,7 +9,6 @@ import fs from "node:fs"; import { createRequire } from "node:module"; -import path from "node:path"; import type { BundlerAdapter, BundlerBuildContext, @@ -37,10 +36,6 @@ import { startUtoopackDevWorker, type UtoopackDevWorkerHandle, } from "./development/dev-worker-client.js"; -import { - readClientStatsVersion, - watchClientStats, -} from "./development/stats-watcher.js"; import { runUtoopackBuild } from "./execution/utoopack.js"; import { UtoopackManifestGenerator } from "./output/manifest-generator.js"; import { assertSafeUtoopackCleanOutput } from "./output/output-paths.js"; @@ -51,7 +46,6 @@ const { version: utoopackVersion } = require("@utoo/pack/package.json") as { version: string; }; type UtoopackRuntime = Pick; -const DEV_STATS_POLL_INTERVAL_MS = 25; async function createUtoopackSelfSignedCertificate(): Promise< ClientDevMiddlewareTlsCredentials | undefined @@ -81,102 +75,6 @@ async function cleanServerOutput( }); } -async function generateDevArtifacts( - cwd: string, - plan: BuildPlan, - onBuildFacts: ( - facts: BundlerBuildFacts, - options: { isRebuild: boolean }, - ) => Promise, - options: { isRebuild: boolean }, - facts?: BundlerBuildFacts, -): Promise { - logger.info`Generating development manifest and HTML...`; - const buildFacts = - facts ?? - (await new UtoopackManifestGenerator(cwd, plan).collectBuildFacts()); - return onBuildFacts(buildFacts, options); -} - -async function waitForReadableDevStats( - cwd: string, - plan: BuildPlan, - signal: AbortSignal, -): Promise { - return (await waitForReadableDevSnapshot(cwd, plan, signal)).facts; -} - -interface DevBuildSnapshot { - facts: BundlerBuildFacts; - clientStatsVersion: string | undefined; -} - -function getClientStatsPath(cwd: string, plan: BuildPlan): string | undefined { - return plan.entries.some((entry) => entry.environment === "client") - ? path.join(resolveBuildOutputPaths(cwd, plan).clientDir, "stats.json") - : undefined; -} - -async function collectDevBuildSnapshot( - cwd: string, - plan: BuildPlan, - clientOnly = false, -): Promise { - const statsPath = getClientStatsPath(cwd, plan); - const clientStatsVersion = await readClientStatsVersion(statsPath); - const generator = new UtoopackManifestGenerator(cwd, plan); - const facts = await generator.collectBuildFacts({ clientOnly }); - if (clientStatsVersion !== (await readClientStatsVersion(statsPath))) { - throw new Error("[evjs] Utoopack client stats changed while being read."); - } - return { facts, clientStatsVersion }; -} - -async function waitForReadableDevSnapshot( - cwd: string, - plan: BuildPlan, - signal: AbortSignal, -): Promise { - while (true) { - throwIfPollingAborted( - signal, - "[evjs] Utoopack development session closed while waiting for build stats.", - ); - try { - return await collectDevBuildSnapshot(cwd, plan); - } catch {} - await waitForPollingDelay( - signal, - "[evjs] Utoopack development session closed while waiting for build stats.", - ); - } -} - -function throwIfPollingAborted(signal: AbortSignal, message: string): void { - if (signal.aborted) throw new Error(message); -} - -function waitForPollingDelay( - signal: AbortSignal, - abortMessage: string, -): Promise { - throwIfPollingAborted(signal, abortMessage); - return new Promise((resolve, reject) => { - const timer = setTimeout(finish, DEV_STATS_POLL_INTERVAL_MS); - timer.unref(); - const onAbort = () => { - clearTimeout(timer); - signal.removeEventListener("abort", onAbort); - reject(new Error(abortMessage)); - }; - function finish() { - signal.removeEventListener("abort", onAbort); - resolve(); - } - signal.addEventListener("abort", onAbort, { once: true }); - }); -} - function waitForAbort(signal: AbortSignal): Promise { if (signal.aborted) { return Promise.reject( @@ -286,6 +184,9 @@ async function startUtoopackDev( const worker = startUtoopackDevWorker({ cwd, config: utoopackConfig, + async onClientBuild({ isRebuild }) { + await controller.processClientBuild(isRebuild); + }, async onServerBuild() { await controller.processServerBuild(); }, @@ -366,8 +267,6 @@ async function startUtoopackDev( logger.warn`Reserved client port ${internalPort} became unavailable during startup; Utoopack is listening on ${ready.port}.${fallbackStatus}`; } - if (!hasServerEntries(plan)) controller.startInitialFacts(); - controller.startClientStatsWatcher(); worker.throwIfFailed(); return controller; } catch (error) { @@ -417,8 +316,9 @@ class UtoopackDevController implements BundlerDevController { private devWorkQueue: Promise = Promise.resolve(); private hasEmittedDevArtifacts = false; private acceptedFacts: BundlerBuildFacts | undefined; - private lastPublishedClientVersion: string | undefined; - private clientStatsWatcher: ReturnType | undefined; + private clientFacts: BundlerBuildFacts = { clientEntryAssets: {} }; + private readonly clientFactsReady: Promise; + private resolveClientFactsReady!: () => void; private closed = false; private closePromise: Promise | undefined; private readonly closingController = new AbortController(); @@ -433,6 +333,17 @@ class UtoopackDevController implements BundlerDevController { onServerBundleReady: BundlerDevContext["callbacks"]["onServerBundleReady"]; }, ) { + this.clientFactsReady = Promise.race([ + new Promise((resolve) => { + this.resolveClientFactsReady = resolve; + }), + options.worker.failure, + waitForAbort(this.closingController.signal), + ]); + void this.clientFactsReady.catch(() => {}); + if (!options.plan.entries.some((entry) => entry.environment === "client")) { + this.resolveClientFactsReady(); + } const clientMiddlewareFailure = new Promise((_resolve, reject) => { this.rejectClientMiddlewareFailure = reject; }); @@ -464,44 +375,11 @@ class UtoopackDevController implements BundlerDevController { void server.failure.catch(this.rejectClientMiddlewareFailure); } - startInitialFacts(): void { - void this.enqueueDevWork(async () => { - const disposition = await this.processBuildFacts(false); - if (disposition === "published") { - this.hasEmittedDevArtifacts = true; - } - }).catch((error) => { - if (!this.closed) { - logger.error`Failed to process initial Utoopack dev build: ${error}`; - } - }); - } - - startClientStatsWatcher(): void { - const statsPath = getClientStatsPath(this.options.cwd, this.options.plan); - if (this.closed || !statsPath) return; - const onError = (error: unknown) => { - if (!this.closed) { - logger.error`Failed to process Utoopack client rebuild: ${error}`; - } - }; - this.clientStatsWatcher = watchClientStats( - statsPath, - () => { - if (!this.closed) void this.processClientBuild().catch(onError); - }, - onError, - ); - } - async close(): Promise { this.closePromise ??= (async () => { this.closed = true; this.closingController.abort(); const errors: unknown[] = []; - const statsWatcherClosed = this.clientStatsWatcher - ?.close() - .catch((error) => errors.push(error)); try { await this.clientMiddlewareServer?.close(); } catch (error) { @@ -512,7 +390,6 @@ class UtoopackDevController implements BundlerDevController { } catch (error) { errors.push(error); } - await statsWatcherClosed; try { await this.devWorkQueue; } catch (error) { @@ -529,78 +406,58 @@ class UtoopackDevController implements BundlerDevController { processServerBuild(): Promise { if (this.closed) return Promise.resolve(); return this.enqueueDevWork(async () => { - await this.processBuildFacts(this.hasEmittedDevArtifacts); + await this.clientFactsReady; + if (this.closed) return; + const serverFacts = await new UtoopackManifestGenerator( + this.options.cwd, + this.options.plan, + ).collectBuildFacts({ environment: "server" }); + await this.publishBuildFacts(this.combineFacts(serverFacts), true); }); } - private processClientBuild(): Promise { + async processClientBuild(isRebuild: boolean): Promise { + if (this.closed) return; + // Capture only the output owned by this callback, while Utoopack holds its + // write queue. Server callbacks independently own their server snapshot. + this.clientFacts = await new UtoopackManifestGenerator( + this.options.cwd, + this.options.plan, + ).collectBuildFacts({ environment: "client" }); + this.resolveClientFactsReady(); return this.enqueueDevWork(async () => { if (this.closed) return; // The native server endpoint owns the first publication and API ACK. if (hasServerEntries(this.options.plan) && !this.acceptedFacts) return; - const version = await readClientStatsVersion( - getClientStatsPath(this.options.cwd, this.options.plan), + if (!isRebuild && this.acceptedFacts) return; + await this.publishBuildFacts( + this.combineFacts(this.acceptedFacts), + false, ); - if (!version || version === this.lastPublishedClientVersion) return; - let snapshot: DevBuildSnapshot; - try { - snapshot = await collectDevBuildSnapshot( - this.options.cwd, - this.options.plan, - true, - ); - } catch { - // A partial/invalid client write must not block a server endpoint ACK. - return; - } - if (this.acceptedFacts) { - snapshot.facts = { - ...this.acceptedFacts, - ...snapshot.facts, - emittedFiles: { - ...(this.acceptedFacts.emittedFiles?.server - ? { server: this.acceptedFacts.emittedFiles.server } - : {}), - ...snapshot.facts.emittedFiles, - }, - }; - } - await this.publishBuildFacts(snapshot, false); }); } - private waitForReadableSnapshot(plan: BuildPlan): Promise { - return Promise.race([ - waitForReadableDevSnapshot( - this.options.cwd, - plan, - this.closingController.signal, - ), - this.options.worker.failure, - ]); - } - - private async processBuildFacts( - isRebuild: boolean, - ): Promise { - if (this.closed) return "discarded"; - const snapshot = await this.waitForReadableSnapshot(this.options.plan); - return this.publishBuildFacts(snapshot, true, isRebuild); + private combineFacts(serverFacts?: BundlerBuildFacts): BundlerBuildFacts { + return { + ...serverFacts, + ...this.clientFacts, + emittedFiles: { + ...(serverFacts?.emittedFiles?.server + ? { server: serverFacts.emittedFiles.server } + : {}), + ...this.clientFacts.emittedFiles, + }, + }; } private async publishBuildFacts( - snapshot: DevBuildSnapshot, + facts: BundlerBuildFacts, notifyServerReady: boolean, - isRebuild = this.hasEmittedDevArtifacts, ): Promise { if (this.closed) return "discarded"; - const disposition = await generateDevArtifacts( - this.options.cwd, - this.options.plan, - this.options.onBuildFacts, - { isRebuild: isRebuild || this.hasEmittedDevArtifacts }, - snapshot.facts, - ); + const disposition = await this.options.onBuildFacts(facts, { + isRebuild: this.hasEmittedDevArtifacts, + }); if (disposition === "published") { this.hasEmittedDevArtifacts = true; if ( @@ -610,8 +467,7 @@ class UtoopackDevController implements BundlerDevController { ) { await this.options.onServerBundleReady(); } - this.acceptedFacts = snapshot.facts; - this.lastPublishedClientVersion = snapshot.clientStatsVersion; + this.acceptedFacts = facts; } return disposition; } @@ -625,8 +481,3 @@ class UtoopackDevController implements BundlerDevController { return result; } } - -export const __testing = { - startUtoopackDev, - waitForReadableDevStats, -}; diff --git a/packages/bundler-utoopack/src/adapter/development/dev-worker-client.ts b/packages/bundler-utoopack/src/adapter/development/dev-worker-client.ts index 94c8878b..b7b21771 100644 --- a/packages/bundler-utoopack/src/adapter/development/dev-worker-client.ts +++ b/packages/bundler-utoopack/src/adapter/development/dev-worker-client.ts @@ -28,6 +28,7 @@ const DEFAULT_NATIVE_OWNER_WORKER_URL = new URL( interface PreparedWorkerOptions { workerOptions: UtoopackDevWorkerSessionOptions; onServerBuild?: UtoopackDevWorkerOptions["onServerBuild"]; + onClientBuild?: UtoopackDevWorkerOptions["onClientBuild"]; onServerError?: UtoopackDevWorkerOptions["onServerError"]; pathRewriteFunctions: ReadonlyMap< number, @@ -106,7 +107,12 @@ function assertHostDoesNotOwnUtoopackBinding(): void { export function prepareUtoopackDevWorkerOptions( options: UtoopackDevWorkerOptions, ): PreparedWorkerOptions { - const { onServerBuild, onServerError, ...serializableOptions } = options; + const { + onServerBuild, + onClientBuild, + onServerError, + ...serializableOptions + } = options; const pathRewriteFunctions = new Map< number, Extract string> @@ -137,6 +143,7 @@ export function prepareUtoopackDevWorkerOptions( workerOptions: structuredClone(candidate), pathRewriteFunctions, onServerBuild, + onClientBuild, onServerError, }; } catch (error) { @@ -292,11 +299,17 @@ class UtoopackNativeOwner { } return; } - if (message.type === "server-build") { + if (message.type === "server-build" || message.type === "client-build") { void (async () => { let error: string | undefined; try { - if (!session.closing) await session.prepared.onServerBuild?.(); + if (!session.closing) { + if (message.type === "client-build") { + await session.prepared.onClientBuild?.(message.context); + } else { + await session.prepared.onServerBuild?.(); + } + } } catch (cause) { error = normalizeError(cause).message; } @@ -304,7 +317,10 @@ class UtoopackNativeOwner { // leave the native write queue waiting on the host. if (this.activeSession === session && !session.doneSettled) { this.postMessage({ - type: "server-published", + type: + message.type === "client-build" + ? "client-published" + : "server-published", sessionId: session.id, buildId: message.buildId, error, diff --git a/packages/bundler-utoopack/src/adapter/development/dev-worker-protocol.ts b/packages/bundler-utoopack/src/adapter/development/dev-worker-protocol.ts index c43109b1..d6a523d4 100644 --- a/packages/bundler-utoopack/src/adapter/development/dev-worker-protocol.ts +++ b/packages/bundler-utoopack/src/adapter/development/dev-worker-protocol.ts @@ -1,4 +1,5 @@ import type { ConfigComplete, DevServerReadyContext } from "@utoo/pack"; +import type { UtoopackClientBuildContext } from "../execution/utoopack.js"; export const PATH_REWRITE_HEADER_INTS = 3; export const PATH_REWRITE_BUFFER_BYTES = 256 * 1024; @@ -9,6 +10,7 @@ export interface UtoopackDevWorkerOptions { config: ConfigComplete; /** Host publication must finish before the next server write begins. */ onServerBuild?(): Promise; + onClientBuild?(context: UtoopackClientBuildContext): Promise; onServerError?(error: Error): void; spaHistoryFallbackRuleIndex?: number; server: { @@ -20,7 +22,10 @@ export interface UtoopackDevWorkerOptions { } export interface UtoopackDevWorkerSessionOptions - extends Omit { + extends Omit< + UtoopackDevWorkerOptions, + "onServerBuild" | "onClientBuild" | "onServerError" + > { pathRewriteFunctionIndexes: number[]; } @@ -36,6 +41,12 @@ export type UtoopackDevWorkerCommand = sessionId: number; buildId: number; error?: string; + } + | { + type: "client-published"; + sessionId: number; + buildId: number; + error?: string; }; export type UtoopackDevWorkerMessage = @@ -48,6 +59,12 @@ export type UtoopackDevWorkerMessage = } | { type: "closed"; sessionId: number } | { type: "server-build"; sessionId: number; buildId: number } + | { + type: "client-build"; + sessionId: number; + buildId: number; + context: UtoopackClientBuildContext; + } | { type: "server-error"; sessionId: number; message: string } | { type: "session-error"; diff --git a/packages/bundler-utoopack/src/adapter/development/dev-worker.ts b/packages/bundler-utoopack/src/adapter/development/dev-worker.ts index 613038b9..6fd7fc84 100644 --- a/packages/bundler-utoopack/src/adapter/development/dev-worker.ts +++ b/packages/bundler-utoopack/src/adapter/development/dev-worker.ts @@ -1,7 +1,10 @@ import { createRequire } from "node:module"; import { parentPort } from "node:worker_threads"; import type { DevServerReadyContext } from "@utoo/pack"; -import { runUtoopackDevServer } from "../execution/utoopack.js"; +import { + runUtoopackDevServer, + type UtoopackClientBuildContext, +} from "../execution/utoopack.js"; import { PATH_REWRITE_BUFFER_BYTES, PATH_REWRITE_HEADER_INTS, @@ -30,12 +33,9 @@ interface ActiveSession { options: UtoopackDevWorkerSessionOptions; serverProject?: Awaited>; closing: boolean; + clientClosed?: boolean; nextBuildId: number; - publication?: { - id: number; - resolve(): void; - reject(error: Error): void; - }; + publications: Map; } type UtoopackDevRuntime = Pick; @@ -70,22 +70,28 @@ function observeSchedulerFailure( function enqueueCommand(command: UtoopackDevWorkerCommand): void { // Publication acknowledgements must bypass start/close, which can themselves // be waiting for the current publication to finish. - if (command.type === "server-published") { + if ( + command.type === "server-published" || + command.type === "client-published" + ) { const session = activeSession; - if ( - session?.id === command.sessionId && - session.publication?.id === command.buildId - ) { - if (command.error) session.publication.reject(new Error(command.error)); - else session.publication.resolve(); + const publication = + session?.id === command.sessionId + ? session.publications.get(command.buildId) + : undefined; + if (publication) { + if (command.error) publication.reject(new Error(command.error)); + else publication.resolve(); } return; } if (command.type === "close" && activeSession?.id === command.sessionId) { activeSession.closing = true; - activeSession.publication?.reject( - new Error("[evjs] Utoopack Session closed during server publication."), - ); + for (const publication of activeSession.publications.values()) { + publication.reject( + new Error("[evjs] Utoopack Session closed during build publication."), + ); + } } commandQueue = commandQueue .then(() => executeCommand(command)) @@ -93,7 +99,10 @@ function enqueueCommand(command: UtoopackDevWorkerCommand): void { } async function executeCommand( - command: Exclude, + command: Exclude< + UtoopackDevWorkerCommand, + { type: "server-published" | "client-published" } + >, ): Promise { if (ownerExiting) return; if (command.type === "start") { @@ -125,6 +134,7 @@ async function startSession( options, closing: false, nextBuildId: 0, + publications: new Map(), }; activeSession = session; installPathRewriteBridges(sessionId, options); @@ -133,7 +143,7 @@ async function startSession( session.serverProject = await startUtoopackServerProject({ cwd: options.cwd, config: options.config, - onBuild: () => publishServerBuild(session), + onBuild: () => publishBuild(session), onError(error) { if (!session.closing) { postMessage({ @@ -150,18 +160,40 @@ async function startSession( } let readyContext: DevServerReadyContext | undefined; let spaHistoryFallbackUpdated = false; - await runUtoopackDevServer( - runtime, - createUtoopackClientDevConfig(options.config, options.cwd), - options.cwd, - { - ...options.server, - async onReady(context: DevServerReadyContext) { - spaHistoryFallbackUpdated = updateSpaHistoryFallback(options, context); - readyContext = context; + let clientBuildReceived = false; + try { + await runUtoopackDevServer( + runtime, + createUtoopackClientDevConfig(options.config, options.cwd), + options.cwd, + { + ...options.server, + onClientBuild(context) { + clientBuildReceived = true; + return publishBuild(session, context); + }, + async onReady(context: DevServerReadyContext) { + if (context.clientPaths.length && !clientBuildReceived) { + throw new Error( + "[evjs] This @utoo/pack version does not support onClientBuild. Upgrade to a version that provides the client build callback.", + ); + } + spaHistoryFallbackUpdated = updateSpaHistoryFallback( + options, + context, + ); + readyContext = context; + }, }, - }, - ); + ); + } catch (error) { + if (!session.closing) throw error; + // serve() has already awaited its own cleanup after the callback rejected. + // Its SIGTERM handler is gone, so do not run the normal lifecycle twice. + session.clientClosed = true; + session.lifecycle.dispose(); + } + if (session.closing) return; if (!readyContext) { throw new Error( @@ -186,7 +218,7 @@ async function closeSession(sessionId: number): Promise { const errors = await rejections([ session.serverProject?.close(), - session.lifecycle.close(), + session.clientClosed ? undefined : session.lifecycle.close(), ]); if (errors.length) { throw new AggregateError( @@ -198,14 +230,21 @@ async function closeSession(sessionId: number): Promise { postMessage({ type: "closed", sessionId }); } -function publishServerBuild(session: ActiveSession): Promise { +function publishBuild( + session: ActiveSession, + context?: UtoopackClientBuildContext, +): Promise { if (session.closing || activeSession !== session) return Promise.resolve(); const buildId = ++session.nextBuildId; return new Promise((resolve, reject) => { - session.publication = { id: buildId, resolve, reject }; - postMessage({ type: "server-build", sessionId: session.id, buildId }); + session.publications.set(buildId, { resolve, reject }); + postMessage( + context + ? { type: "client-build", sessionId: session.id, buildId, context } + : { type: "server-build", sessionId: session.id, buildId }, + ); }).finally(() => { - if (session.publication?.id === buildId) session.publication = undefined; + session.publications.delete(buildId); }); } diff --git a/packages/bundler-utoopack/src/adapter/development/stats-watcher.ts b/packages/bundler-utoopack/src/adapter/development/stats-watcher.ts deleted file mode 100644 index 85ac3fca..00000000 --- a/packages/bundler-utoopack/src/adapter/development/stats-watcher.ts +++ /dev/null @@ -1,40 +0,0 @@ -import fs from "node:fs"; -import path from "node:path"; -import { watch } from "chokidar"; - -export async function readClientStatsVersion( - statsPath: string | undefined, -): Promise { - if (!statsPath) return undefined; - 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; - } -} - -/** Notify the host queue when settled client stats change, including startup. */ -export function watchClientStats( - statsPath: string, - onChange: () => void, - onError: (error: unknown) => void, -) { - const resolvedPath = path.resolve(statsPath); - const watcher = watch(path.dirname(resolvedPath), { - atomic: true, - awaitWriteFinish: { stabilityThreshold: 50, pollInterval: 10 }, - depth: 0, - disableGlobbing: true, - ignoreInitial: true, - }); - const onFile = (file: string) => { - if (path.resolve(file) === resolvedPath) onChange(); - }; - watcher.on("add", onFile); - watcher.on("change", onFile); - watcher.on("ready", onChange); - watcher.on("error", onError); - return watcher; -} diff --git a/packages/bundler-utoopack/src/adapter/execution/utoopack.ts b/packages/bundler-utoopack/src/adapter/execution/utoopack.ts index f3b51ee4..1bda1857 100644 --- a/packages/bundler-utoopack/src/adapter/execution/utoopack.ts +++ b/packages/bundler-utoopack/src/adapter/execution/utoopack.ts @@ -4,6 +4,11 @@ */ import type { ConfigComplete } from "@utoo/pack"; +export interface UtoopackClientBuildContext { + clientPaths: string[]; + isRebuild: boolean; +} + type UtoopackBuildRuntime = Pick; // This bridge uses the void-returning serve overload; Session teardown is // owned by the native-owner Worker rather than the optional dev server handle. @@ -14,7 +19,9 @@ type UtoopackDevRuntime = { }; type UtoopackServerOptions = NonNullable< Parameters[3] ->; +> & { + onClientBuild?(context: UtoopackClientBuildContext): void | Promise; +}; export function runUtoopackBuild( runtime: UtoopackBuildRuntime, diff --git a/packages/bundler-utoopack/src/adapter/index.ts b/packages/bundler-utoopack/src/adapter/index.ts index 9041866a..2b5b6844 100644 --- a/packages/bundler-utoopack/src/adapter/index.ts +++ b/packages/bundler-utoopack/src/adapter/index.ts @@ -1 +1 @@ -export { __testing, utoopackAdapter } from "./adapter.js"; +export { utoopackAdapter } from "./adapter.js"; diff --git a/packages/bundler-utoopack/src/adapter/output/manifest-generator.ts b/packages/bundler-utoopack/src/adapter/output/manifest-generator.ts index 91aa835b..dc145eb4 100644 --- a/packages/bundler-utoopack/src/adapter/output/manifest-generator.ts +++ b/packages/bundler-utoopack/src/adapter/output/manifest-generator.ts @@ -174,31 +174,38 @@ export class UtoopackManifestGenerator { } async collectBuildFacts({ - clientOnly = false, + environment, + }: { + environment?: "client" | "server"; } = {}): Promise { - await this.loadClientStats(); - if (!clientOnly) await this.loadServerStats(); + if (environment !== "server") await this.loadClientStats(); + if (environment !== "client") await this.loadServerStats(); const emittedFiles = { - ...(this.clientEmittedFiles + ...(environment !== "server" && this.clientEmittedFiles ? { client: includeAdapterStatsFile(this.clientEmittedFiles) } : {}), - ...(!clientOnly && this.serverEmittedFiles + ...(environment !== "client" && this.serverEmittedFiles ? { server: includeAdapterStatsFile(this.serverEmittedFiles) } : {}), }; const facts: BundlerBuildFacts = { ...(Object.keys(emittedFiles).length > 0 ? { emittedFiles } : {}), - clientEntryAssets: cloneEntryAssets(this.clientEntryAssets), - ...(!clientOnly + clientEntryAssets: + environment === "server" + ? {} + : cloneEntryAssets(this.clientEntryAssets), + ...(environment !== "client" ? { serverEntryAssets: cloneEntryAssets(this.serverEntryAssets) } : {}), }; assertBundlerEmittedFiles(facts.emittedFiles); - assertBuildOutputLinkInputClientAssets({ - plan: this.plan, - clientEntryAssets: facts.clientEntryAssets, - }); + if (environment !== "server") { + assertBuildOutputLinkInputClientAssets({ + plan: this.plan, + clientEntryAssets: facts.clientEntryAssets, + }); + } return facts; } diff --git a/packages/bundler-utoopack/tests/adapter.test.ts b/packages/bundler-utoopack/tests/adapter.test.ts index cf652aef..0f582ef5 100644 --- a/packages/bundler-utoopack/tests/adapter.test.ts +++ b/packages/bundler-utoopack/tests/adapter.test.ts @@ -25,28 +25,29 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { withPageRoutingDefaults } from "../../ev/esm/_internal/build/discovery/convention-config.js"; import { createClientRuntime } from "../../ev/src/_internal/build/output/framework-runtime.js"; import { createUtoopackConfig } from "../src/adapter/config/create-config.js"; -import { - utoopackAdapter, - __testing as utoopackAdapterTesting, -} from "../src/adapter/index.js"; +import { utoopackAdapter } from "../src/adapter/index.js"; const utoopackMock = vi.hoisted(() => ({ - clientStatsDelayMs: 0, - initialClientStats: undefined as string | undefined, clientStats: undefined as string | undefined, omitClientStats: false, deferInitialServerBuild: false, + deferInitialClientBuild: false, markUtoopackProcessForBuild: vi.fn(), workerClose: vi.fn(async () => {}), startUtoopackDevWorker: vi.fn( ({ config, server, + onClientBuild, onServerBuild, onServerError, }: { config: ConfigComplete; server: unknown; + onClientBuild?: (context: { + clientPaths: string[]; + isRebuild: boolean; + }) => Promise; onServerBuild?: () => Promise; onServerError?: (error: Error) => void; }) => { @@ -70,6 +71,7 @@ const utoopackMock = vi.hoisted(() => ({ void runtime .serve({ config }, undefined, undefined, { ...(server as object), + onClientBuild, onReady(context: { port: number; hostname: string; @@ -131,21 +133,7 @@ const utoopackMock = vi.hoisted(() => ({ }, }), ); - if (utoopackMock.initialClientStats !== undefined) { - await fs.promises.writeFile( - path.join(clientOutDir, "stats.json"), - utoopackMock.initialClientStats, - ); - } - if (!utoopackMock.omitClientStats) { - if (utoopackMock.clientStatsDelayMs > 0) { - setTimeout(() => { - void writeClientStats().catch(() => {}); - }, utoopackMock.clientStatsDelayMs); - } else { - await writeClientStats(); - } - } + if (!utoopackMock.omitClientStats) await writeClientStats(); if (config.server) { const serverOutDir = config.server.output.path; @@ -173,6 +161,15 @@ const utoopackMock = vi.hoisted(() => ({ }), ); } + if ( + !utoopackMock.omitClientStats && + !utoopackMock.deferInitialClientBuild + ) { + await serverOptions?.onClientBuild?.({ + clientPaths: ["main.js"], + isRebuild: false, + }); + } await serverOptions?.onReady?.({ port: serverOptions.port === 3000 ? 3210 : (serverOptions.port ?? 3210), hostname: "0.0.0.0", @@ -331,6 +328,10 @@ async function startDevFixture( onServerBundleReady, html: () => fs.promises.readFile(path.join(cwd, "dist/client/index.html"), "utf-8"), + clientBuild: (isRebuild = true) => + utoopackMock.startUtoopackDevWorker.mock.calls + .at(-1)?.[0] + .onClientBuild?.({ clientPaths: [], isRebuild }), serverBuild: () => utoopackMock.startUtoopackDevWorker.mock.calls .at(-1)?.[0] @@ -373,11 +374,10 @@ async function resolveProjectConfig( } afterEach(async () => { - utoopackMock.clientStatsDelayMs = 0; - utoopackMock.initialClientStats = undefined; utoopackMock.clientStats = undefined; utoopackMock.omitClientStats = false; utoopackMock.deferInitialServerBuild = false; + utoopackMock.deferInitialClientBuild = false; utoopackMock.workerClose.mockClear(); utoopackMock.startUtoopackDevWorker.mockClear(); utoopackMock.markUtoopackProcessForBuild.mockClear(); @@ -599,6 +599,47 @@ describe("utoopackAdapter browser log forwarding config", () => { }); describe("utoopackAdapter dev", () => { + it("unblocks an early server build with one initial client publication", async () => { + utoopackMock.deferInitialClientBuild = true; + const app = await startDevFixture(true); + try { + expect(app.outputs).toHaveLength(0); + await app.clientBuild(false); + expect(app.outputs).toHaveLength(1); + expect(app.rebuildFlags).toEqual([false]); + expect(app.onServerBundleReady).toHaveBeenCalledOnce(); + // Server rebuilds reuse the captured client output, even while a new + // client write has not yet reached its completion callback. + await fs.promises.writeFile( + path.join(app.cwd, "dist/client/stats.json"), + "null", + ); + await app.serverBuild(); + expect(app.outputs).toHaveLength(2); + expect(await app.html()).toContain('src="/root-old.js"'); + } finally { + await app.controller.close(); + } + }); + + it("propagates a failed client publication and recovers on the next callback", async () => { + let fail = true; + const app = await startDevFixture(false, async (isRebuild) => { + if (isRebuild && fail) throw new Error("publication failed"); + }); + try { + await writeClientStats(app.cwd, ["root-new.js", "main.js"]); + await expect(app.clientBuild()).rejects.toThrow("publication failed"); + expect(await app.html()).toContain('src="/root-old.js"'); + fail = false; + await app.clientBuild(); + expect(await app.html()).toContain('src="/root-new.js"'); + expect(app.outputs).toHaveLength(2); + } finally { + await app.controller.close(); + } + }); + it("automatically republishes HTML and metadata and recovers from incomplete client stats", async () => { const app = await startDevFixture(); try { @@ -610,11 +651,12 @@ describe("utoopackAdapter dev", () => { path.join(app.cwd, "dist/client/stats.json"), stats, ); - await new Promise((resolve) => setTimeout(resolve, 150)); + await expect(app.clientBuild()).rejects.toThrow(); expect(await app.html()).toBe(initialHtml); expect(app.outputs).toHaveLength(1); } await writeClientStats(app.cwd, ["root-new.js", "main.js", "new.css"]); + await app.clientBuild(); await expect.poll(app.html).toContain('src="/root-new.js"'); expect(await app.html()).not.toContain('src="/root-old.js"'); const metadata = JSON.parse( @@ -633,7 +675,7 @@ describe("utoopackAdapter dev", () => { await app.controller.close(); } await writeClientStats(app.cwd, ["root-after-close.js", "main.js"]); - await new Promise((resolve) => setTimeout(resolve, 100)); + await app.clientBuild(); expect(app.outputs).toHaveLength(2); expect(await app.html()).toContain('src="/root-new.js"'); }); @@ -643,7 +685,7 @@ describe("utoopackAdapter dev", () => { const app = await startDevFixture(true); try { await writeClientStats(app.cwd, ["root-before-server.js", "main.js"]); - await new Promise((resolve) => setTimeout(resolve, 150)); + await app.clientBuild(); expect(app.outputs).toHaveLength(0); expect(app.onServerBundleReady).not.toHaveBeenCalled(); await app.serverBuild(); @@ -664,6 +706,7 @@ describe("utoopackAdapter dev", () => { ); const root = `root-client-${index}.js`; await writeClientStats(app.cwd, [root, "main.js"], index === 0); + await app.clientBuild(); await expect.poll(app.html).toContain(`src="/${root}"`); expect(app.outputs.at(-1)?.server).toEqual(acceptedServer); expect(app.onServerBundleReady).toHaveBeenCalledTimes(1); @@ -686,13 +729,14 @@ describe("utoopackAdapter dev", () => { try { await expect.poll(() => app.outputs.length).toBe(1); await writeClientStats(app.cwd, ["root-queued.js", "main.js"]); + const clientPublication = app.clientBuild(); await expect.poll(() => app.facts.length).toBe(2); const serverPublication = app.serverBuild(); await new Promise((resolve) => setTimeout(resolve, 75)); expect(app.facts).toHaveLength(2); expect(app.outputs).toHaveLength(1); release(); - await serverPublication; + await Promise.all([clientPublication, serverPublication]); expect(app.rebuildFlags).toEqual([false, true, true]); expect(app.outputs).toHaveLength(3); expect(app.onServerBundleReady).toHaveBeenCalledTimes(2); @@ -702,63 +746,6 @@ describe("utoopackAdapter dev", () => { } }); - it("waits for readable stats until the session is aborted", async () => { - const cwd = await makeProject(); - const config = await resolveProjectConfig(cwd, { - routing: { mode: "spa" }, - }); - const buildContext = await createBuildContext(config, cwd); - const abortController = new AbortController(); - const pending = utoopackAdapterTesting.waitForReadableDevStats( - cwd, - buildContext.plan, - abortController.signal, - ); - setTimeout(() => abortController.abort(), 50); - - await expect(pending).rejects.toThrow( - "closed while waiting for build stats", - ); - }); - - it("returns a listening controller before initial facts are published", async () => { - utoopackMock.clientStatsDelayMs = 100; - const cwd = await makeProject(); - const config = await resolveProjectConfig(cwd, { - routing: { mode: "spa" }, - }); - const buildContext = await createBuildContext(config, cwd); - const onBuildOutput = vi.fn(); - const controller = await utoopackAdapter.dev({ - config, - cwd, - signal: new AbortController().signal, - plan: buildContext.plan, - callbacks: createFrameworkCallbacks({ - config, - cwd, - ...buildContext, - onBuildOutput, - }), - hooks: [], - }); - - try { - expect(controller.origin).toBe("http://localhost:3210"); - expect(utoopackMock.startUtoopackDevWorker).toHaveBeenCalledWith( - expect.objectContaining({ cwd }), - ); - expect(onBuildOutput).not.toHaveBeenCalled(); - await waitForCondition( - () => onBuildOutput.mock.calls.length > 0, - "Utoopack did not publish its initial build facts", - ); - expect(onBuildOutput).toHaveBeenCalledTimes(1); - } finally { - await controller.close(); - } - }); - it("runs client middleware through the shared public server", async () => { const port = await reserveClientDevMiddlewareUpstreamPort(); const cwd = await makeProject(); @@ -809,109 +796,7 @@ describe("utoopackAdapter dev", () => { } }); - it("recovers when initial stats are malformed", async () => { - utoopackMock.initialClientStats = "null"; - utoopackMock.clientStatsDelayMs = 100; - const cwd = await makeProject(); - const config = await resolveProjectConfig(cwd, { - routing: { mode: "spa" }, - }); - const buildContext = await createBuildContext(config, cwd); - const rebuildFlags: boolean[] = []; - const callbacks = createFrameworkCallbacks({ - config, - cwd, - ...buildContext, - }); - const controller = await utoopackAdapter.dev({ - config, - cwd, - signal: new AbortController().signal, - plan: buildContext.plan, - callbacks: { - ...callbacks, - async onBuildFacts(facts, options) { - rebuildFlags.push(options.isRebuild); - return callbacks.onBuildFacts(facts, options); - }, - }, - hooks: [], - }); - - try { - expect(rebuildFlags).toEqual([]); - await waitForCondition( - () => rebuildFlags.length > 0, - "Utoopack did not recover after stats became readable", - ); - expect(rebuildFlags).toEqual([false]); - expect(utoopackMock.workerClose).not.toHaveBeenCalled(); - } finally { - await controller.close(); - } - }); - - it("keeps the last published artifacts while replacement stats are unreadable", async () => { - const cwd = await makeProject(); - const config = await resolveProjectConfig(cwd, { - routing: { mode: "spa" }, - }); - const buildContext = await createBuildContext(config, cwd); - const outputs: BuildOutput[] = []; - const controller = await utoopackAdapterTesting.startUtoopackDev({ - config, - cwd, - signal: new AbortController().signal, - plan: buildContext.plan, - callbacks: createFrameworkCallbacks({ - config, - cwd, - ...buildContext, - onBuildOutput(output) { - outputs.push(output); - }, - }), - hooks: [], - }); - - try { - await waitForCondition( - () => outputs.length === 1, - "Utoopack did not publish initial artifacts", - ); - const htmlPath = path.join(cwd, "dist/client/index.html"); - const initialHtml = await fs.promises.readFile(htmlPath, "utf-8"); - const statsPath = path.join(cwd, "dist/client/stats.json"); - await fs.promises.writeFile(statsPath, "null", "utf-8"); - const pending = controller.processServerBuild(); - await new Promise((resolve) => setTimeout(resolve, 75)); - - expect(outputs).toHaveLength(1); - await expect(fs.promises.readFile(htmlPath, "utf-8")).resolves.toBe( - initialHtml, - ); - - await fs.promises.writeFile(path.join(cwd, "dist/client/main-v2.js"), ""); - await fs.promises.writeFile( - statsPath, - JSON.stringify({ - entrypoints: { - main: { assets: [{ name: "main-v2.js" }] }, - }, - }), - "utf-8", - ); - await expect(pending).resolves.toBeUndefined(); - expect(outputs).toHaveLength(2); - await expect(fs.promises.readFile(htmlPath, "utf-8")).resolves.toContain( - 'src="/main-v2.js"', - ); - } finally { - await controller.close(); - } - }); - - it("closes promptly while waiting for initial stats", async () => { + it("closes promptly while a server build waits for the initial client callback", async () => { utoopackMock.omitClientStats = true; const cwd = await makeProject(); const config = await resolveProjectConfig(cwd, { @@ -931,6 +816,10 @@ describe("utoopackAdapter dev", () => { hooks: [], }); + const publishing = utoopackMock.startUtoopackDevWorker.mock.calls + .at(-1)?.[0] + .onServerBuild?.(); + const rejected = expect(publishing).rejects.toThrow("aborted"); await expect( Promise.race([ controller.close(), @@ -942,6 +831,7 @@ describe("utoopackAdapter dev", () => { ), ]), ).resolves.toBeUndefined(); + await rejected; expect(utoopackMock.workerClose).toHaveBeenCalledOnce(); }); diff --git a/packages/bundler-utoopack/tests/dev-worker-client.test.ts b/packages/bundler-utoopack/tests/dev-worker-client.test.ts index e2a4fddb..267d6b9b 100644 --- a/packages/bundler-utoopack/tests/dev-worker-client.test.ts +++ b/packages/bundler-utoopack/tests/dev-worker-client.test.ts @@ -51,38 +51,55 @@ function createOptions( } describe("Utoopack dev worker client", () => { - it("acknowledges server builds only after host publication and returns failures", async () => { + it.each([ + "server", + "client", + ] as const)("acknowledges %s builds only after host publication and returns failures", async (target) => { let release!: () => void; const publishing = new Promise((resolve) => { release = resolve; }); - const onServerBuild = vi.fn(async () => { - if (onServerBuild.mock.calls.length === 1) await publishing; + const onBuild = vi.fn(async () => { + if (onBuild.mock.calls.length === 1) await publishing; else throw new Error("publication failed"); }); const onServerError = vi.fn(); const handle = startUtoopackDevWorker( { ...createOptions((requestPath) => requestPath, { - TEST_SERVER_BUILD: "true", + TEST_BUILD_TARGET: target, }), - onServerBuild, + ...(target === "client" + ? { onClientBuild: onBuild } + : { onServerBuild: onBuild }), onServerError, }, fixtureUrl, ); try { await handle.ready; - await vi.waitFor(() => expect(onServerBuild).toHaveBeenCalledOnce()); + await vi.waitFor(() => expect(onBuild).toHaveBeenCalledOnce()); await new Promise((resolve) => setTimeout(resolve, 25)); - expect(onServerBuild).toHaveBeenCalledOnce(); + expect(onBuild).toHaveBeenCalledOnce(); + if (target === "client") { + expect(onBuild).toHaveBeenLastCalledWith({ + clientPaths: ["main-1.js"], + isRebuild: false, + }); + } release(); await vi.waitFor(() => expect(onServerError).toHaveBeenCalledWith( expect.objectContaining({ message: "publication failed" }), ), ); - expect(onServerBuild).toHaveBeenCalledTimes(2); + expect(onBuild).toHaveBeenCalledTimes(2); + if (target === "client") { + expect(onBuild).toHaveBeenLastCalledWith({ + clientPaths: ["main-2.js"], + isRebuild: true, + }); + } } finally { release(); await handle.close(); diff --git a/packages/bundler-utoopack/tests/dev-worker-native-session.test.ts b/packages/bundler-utoopack/tests/dev-worker-native-session.test.ts index 9712a1c5..11bcdbed 100644 --- a/packages/bundler-utoopack/tests/dev-worker-native-session.test.ts +++ b/packages/bundler-utoopack/tests/dev-worker-native-session.test.ts @@ -74,12 +74,14 @@ describe("Utoopack single native-owner Session replacement", () => { buildModeRejected: boolean; serverPublicationClosed: boolean; serverSessionReused: boolean; + clientPublicationClosed: boolean; }; expect(result).toMatchObject({ hostLoadedBinding: false, buildModeRejected: true, serverPublicationClosed: true, serverSessionReused: true, + clientPublicationClosed: true, }); expect(result.firstOwnerThreadId).toBeGreaterThan(0); expect(result.secondOwnerThreadId).toBe(result.firstOwnerThreadId); diff --git a/packages/bundler-utoopack/tests/fixtures/dev-worker-client.mjs b/packages/bundler-utoopack/tests/fixtures/dev-worker-client.mjs index a6f8b63d..dd03f7e3 100644 --- a/packages/bundler-utoopack/tests/fixtures/dev-worker-client.mjs +++ b/packages/bundler-utoopack/tests/fixtures/dev-worker-client.mjs @@ -13,7 +13,8 @@ parentPort.on("message", (message) => { } if (message?.type === "close") closeSession(message.sessionId); if ( - message?.type === "server-published" && + (message?.type === "server-published" || + message?.type === "client-published") && activeSession?.id === message.sessionId ) { if (message.error) { @@ -23,11 +24,7 @@ parentPort.on("message", (message) => { message: message.error, }); } else if (message.buildId === 1) { - parentPort.postMessage({ - type: "server-build", - sessionId: message.sessionId, - buildId: 2, - }); + sendBuild(activeSession, 2); } } }); @@ -78,8 +75,8 @@ function startSession(sessionId, options) { spaHistoryFallbackUpdated: Boolean(fallbackRule), }); - if (options.config.define?.TEST_SERVER_BUILD === "true") { - parentPort.postMessage({ type: "server-build", sessionId, buildId: 1 }); + if (options.config.define?.TEST_BUILD_TARGET) { + sendBuild(activeSession, 1); } if (options.config.define?.TEST_EXIT === "true") { @@ -87,6 +84,23 @@ function startSession(sessionId, options) { } } +function sendBuild(session, buildId) { + const target = session.options.config.define.TEST_BUILD_TARGET; + parentPort.postMessage({ + type: `${target}-build`, + sessionId: session.id, + buildId, + ...(target === "client" + ? { + context: { + clientPaths: [`main-${buildId}.js`], + isRebuild: buildId > 1, + }, + } + : {}), + }); +} + function closeSession(sessionId) { const session = activeSession; if (!session || session.id !== sessionId) { diff --git a/packages/bundler-utoopack/tests/fixtures/dev-worker-native-owner-harness.mjs b/packages/bundler-utoopack/tests/fixtures/dev-worker-native-owner-harness.mjs index 2ebf9e35..12b2c53a 100644 --- a/packages/bundler-utoopack/tests/fixtures/dev-worker-native-owner-harness.mjs +++ b/packages/bundler-utoopack/tests/fixtures/dev-worker-native-owner-harness.mjs @@ -38,6 +38,10 @@ await runSession(serverConfig, server.port); const serverSessionReused = serverOwnerThreadId === firstOwnerThreadId && ownerTesting.getNativeOwnerThreadId() === firstOwnerThreadId; +await runSession(config, server.port, false, true); +const client = await runSession(config, server.port); +const clientSessionReused = + ownerTesting.getNativeOwnerThreadId() === firstOwnerThreadId; let buildModeRejected = false; try { markUtoopackProcessForBuild(); @@ -57,6 +61,7 @@ process.stdout.write( secondPort: second.port, serverPublicationClosed: server.publicationClosed, serverSessionReused, + clientPublicationClosed: clientSessionReused && client.clientPublished, })}\n`, ); @@ -86,11 +91,21 @@ function createConfig(customLoaderPath) { }; } -async function runSession(config, port, blockPublication = false) { +async function runSession( + config, + port, + blockPublication = false, + blockClientPublication = false, +) { let beginPublication; let rejectPublication; let releasePublication; let publicationBlocked = false; + let clientPublished = false; + let beginClientPublication; + const clientPublication = new Promise((resolve) => { + beginClientPublication = resolve; + }); const publication = new Promise((resolve, reject) => { beginPublication = resolve; rejectPublication = reject; @@ -102,6 +117,20 @@ async function runSession(config, port, blockPublication = false) { const handle = startUtoopackDevWorker({ cwd, config, + async onClientBuild({ clientPaths }) { + const stats = await fs.promises.readFile( + path.join(cwd, "dist/client/stats.json"), + "utf8", + ); + if (!clientPaths.length || !stats.includes("entrypoints")) { + throw new Error( + "Client publication ran before its outputs were ready.", + ); + } + clientPublished = true; + beginClientPublication(); + if (blockClientPublication) await publicationGate; + }, async onServerBuild() { try { const stats = JSON.parse( @@ -136,7 +165,15 @@ async function runSession(config, port, blockPublication = false) { }, }); try { + if (blockClientPublication) { + await clientPublication; + await handle.close(); + await handle.done; + return; + } const ready = await handle.ready; + if (!clientPublished) + throw new Error("Missing initial client publication."); const stats = await fs.promises.readFile( path.join(cwd, "dist/client/stats.json"), "utf8", @@ -148,7 +185,7 @@ async function runSession(config, port, blockPublication = false) { await handle.close(); await handle.done; await assertPortAvailable(ready.port); - return { ...ready, publicationClosed: publicationBlocked }; + return { ...ready, publicationClosed: publicationBlocked, clientPublished }; } finally { releasePublication(); await handle.close().catch(() => {}); diff --git a/packages/bundler-utoopack/tests/stats-watcher.test.ts b/packages/bundler-utoopack/tests/stats-watcher.test.ts deleted file mode 100644 index e7181ac2..00000000 --- a/packages/bundler-utoopack/tests/stats-watcher.test.ts +++ /dev/null @@ -1,30 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { expect, it, vi } from "vitest"; -import { watchClientStats } from "../src/adapter/development/stats-watcher.js"; - -it("scans stats on ready, filters unrelated files, and stops on close", async () => { - const cwd = await fs.promises.mkdtemp(path.join(os.tmpdir(), "evjs-stats-")); - const statsPath = path.join(cwd, "stats.json"); - await fs.promises.writeFile(statsPath, "{}"); - const onChange = vi.fn(); - const onError = vi.fn(); - const watcher = watchClientStats(statsPath, onChange, onError); - try { - await vi.waitFor(() => expect(onChange).toHaveBeenCalledTimes(1)); - await fs.promises.writeFile(path.join(cwd, "other.json"), "{}"); - await new Promise((resolve) => setTimeout(resolve, 100)); - expect(onChange).toHaveBeenCalledTimes(1); - await fs.promises.writeFile(statsPath, '{"updated":true}'); - await vi.waitFor(() => expect(onChange).toHaveBeenCalledTimes(2)); - await watcher.close(); - await fs.promises.writeFile(statsPath, "{}"); - await new Promise((resolve) => setTimeout(resolve, 100)); - expect(onChange).toHaveBeenCalledTimes(2); - expect(onError).not.toHaveBeenCalled(); - } finally { - await watcher.close(); - await fs.promises.rm(cwd, { recursive: true, force: true }); - } -}); From 161397c62198549d10a6a090f9cb1838e6ab6b32 Mon Sep 17 00:00:00 2001 From: zoomdong Date: Fri, 30 Oct 2026 23:58:16 +0800 Subject: [PATCH 4/6] fix(utoopack): require client build callback release --- package-lock.json | 92 +++++++++++++------------- packages/bundler-utoopack/package.json | 2 +- 2 files changed, 47 insertions(+), 47 deletions(-) diff --git a/package-lock.json b/package-lock.json index 3c156950..9c0479a8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7299,6 +7299,15 @@ "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==", "license": "Apache-2.0" }, + "node_modules/@swc/helpers": { + "version": "0.5.23", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz", + "integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, "node_modules/@swc/html": { "version": "1.15.30", "resolved": "https://registry.npmjs.org/@swc/html/-/html-1.15.30.tgz", @@ -9152,9 +9161,9 @@ } }, "node_modules/@utoo/pack-darwin-arm64": { - "version": "1.5.28", - "resolved": "https://registry.npmjs.org/@utoo/pack-darwin-arm64/-/pack-darwin-arm64-1.5.28.tgz", - "integrity": "sha512-i6u98SJMHSV4dS+8tGVgkU85IHA6spD6i6nKU+KwEsoJ4jAia6IYZz4yHfUMze13cDrT+7WR5s/wq9EMi9Z6hw==", + "version": "1.5.36", + "resolved": "https://registry.npmjs.org/@utoo/pack-darwin-arm64/-/pack-darwin-arm64-1.5.36.tgz", + "integrity": "sha512-Be3OZ87rYzEFxF7H8WnaFleHQV/sXTtutnUXdV4lHXwlKNKcvikoy5tTqAGEwUvt+lf22YqKf/gYZX6G+/4X4Q==", "cpu": [ "arm64" ], @@ -9168,9 +9177,9 @@ } }, "node_modules/@utoo/pack-darwin-x64": { - "version": "1.5.28", - "resolved": "https://registry.npmjs.org/@utoo/pack-darwin-x64/-/pack-darwin-x64-1.5.28.tgz", - "integrity": "sha512-rgwOHcgS9DPelHpbpXA8+E2GkFkZD9fS8DFbvAWt6mKWQgMzKbETqYmP6C7MMcXlwwgQ+/FTqS/JFUSVHUTDwQ==", + "version": "1.5.36", + "resolved": "https://registry.npmjs.org/@utoo/pack-darwin-x64/-/pack-darwin-x64-1.5.36.tgz", + "integrity": "sha512-WSCVqQajW0wasinAFHQCvXzC3XxtJE6UkJ5dxwQ/clh9OriEZz3yG2Qs1VbnPH/t/p9YCuHMb8MgT3y44zoCyA==", "cpu": [ "x64" ], @@ -9184,9 +9193,9 @@ } }, "node_modules/@utoo/pack-linux-arm64-gnu": { - "version": "1.5.28", - "resolved": "https://registry.npmjs.org/@utoo/pack-linux-arm64-gnu/-/pack-linux-arm64-gnu-1.5.28.tgz", - "integrity": "sha512-yWGovZr0kHT54QLUlYLVYgqzR4e5ne+kj7kLKzVnZYqm2j9SM8mAU4PBqOTZE/Bn3o0i5c3wHMEnuNujnyiXmA==", + "version": "1.5.36", + "resolved": "https://registry.npmjs.org/@utoo/pack-linux-arm64-gnu/-/pack-linux-arm64-gnu-1.5.36.tgz", + "integrity": "sha512-vMrS6zd8hh7L+UnKgbBGclMcYahlzf1bKcSOgZBqLrlnYQ6KLwRNq0X0th6ClGsPMSTwX05MwBINdOYLKDqj5Q==", "cpu": [ "arm64" ], @@ -9200,9 +9209,9 @@ } }, "node_modules/@utoo/pack-linux-arm64-musl": { - "version": "1.5.28", - "resolved": "https://registry.npmjs.org/@utoo/pack-linux-arm64-musl/-/pack-linux-arm64-musl-1.5.28.tgz", - "integrity": "sha512-i5n6FhnF8/OaT/J4BaGFbYM+v/ctggMZ4Hmi8YjfWttc2JAV7c8SVlzSG/majU+kGfmA+HcAnFaDKn7wksLj4A==", + "version": "1.5.36", + "resolved": "https://registry.npmjs.org/@utoo/pack-linux-arm64-musl/-/pack-linux-arm64-musl-1.5.36.tgz", + "integrity": "sha512-s7J1CkmzdWD3D1DfojWTZKG8G79nh0OZxUlEs6bIIk5DoG2OkIa2eQ19HCYi/dXfcZR7yBPIYDT+DuYBmyzgtA==", "cpu": [ "arm64" ], @@ -9216,9 +9225,9 @@ } }, "node_modules/@utoo/pack-linux-x64-gnu": { - "version": "1.5.28", - "resolved": "https://registry.npmjs.org/@utoo/pack-linux-x64-gnu/-/pack-linux-x64-gnu-1.5.28.tgz", - "integrity": "sha512-nN9EIVXNSs9THDtSsrQxx7ZeGkKzzVzyQP20y/sgvKeRA8A/pdbp/E5dKXPtrIwz0BgYPmxMtNAm6ZGufRqsaQ==", + "version": "1.5.36", + "resolved": "https://registry.npmjs.org/@utoo/pack-linux-x64-gnu/-/pack-linux-x64-gnu-1.5.36.tgz", + "integrity": "sha512-LKc2BTDnn/IGqE7tTllfx7QGhLiSr+FMMmnLdM6qa1HBdfgmuE/v8qr6+Y9A1+dcfdkWf3EeLlN/NJykb/k05w==", "cpu": [ "x64" ], @@ -9232,9 +9241,9 @@ } }, "node_modules/@utoo/pack-linux-x64-musl": { - "version": "1.5.28", - "resolved": "https://registry.npmjs.org/@utoo/pack-linux-x64-musl/-/pack-linux-x64-musl-1.5.28.tgz", - "integrity": "sha512-fWuCvkGQItHsBn2M/vlrX3WJITu7eMenntflauQ9rFAg7QBFIhkX007mEAL599xHynTyQOjjx+zcIEcf297hjQ==", + "version": "1.5.36", + "resolved": "https://registry.npmjs.org/@utoo/pack-linux-x64-musl/-/pack-linux-x64-musl-1.5.36.tgz", + "integrity": "sha512-9WntMbc9F1vokvuSfUcAoSlUnJXtYYulieWo1bxqaHjEyJxAwzd26DeRKKRoAV2jufbMPEmiwTMdQfoeaJnjZw==", "cpu": [ "x64" ], @@ -9248,9 +9257,9 @@ } }, "node_modules/@utoo/pack-shared": { - "version": "1.5.28", - "resolved": "https://registry.npmjs.org/@utoo/pack-shared/-/pack-shared-1.5.28.tgz", - "integrity": "sha512-94M2/5Q8l4/VMwf9Wuyjy2phMWu44Sp/Y1L+mCICWYLNnGFI8Aj1XI/JBaodTeldqX0wlb6l2N1kNuCfINZmew==", + "version": "1.5.36", + "resolved": "https://registry.npmjs.org/@utoo/pack-shared/-/pack-shared-1.5.36.tgz", + "integrity": "sha512-7ISpVi9JzTdwlejbx168j0CJ3ybgkObGWF9YZgRuschsM0C9LFR2AhZJQ5JMXkJD3H+Qjac2T/aEocJ0859g4g==", "license": "MIT", "dependencies": { "@babel/code-frame": "7.22.5", @@ -9273,9 +9282,9 @@ } }, "node_modules/@utoo/pack-win32-x64-msvc": { - "version": "1.5.28", - "resolved": "https://registry.npmjs.org/@utoo/pack-win32-x64-msvc/-/pack-win32-x64-msvc-1.5.28.tgz", - "integrity": "sha512-QlaRRASh9kZONRbR1CKYXbHr6ZdVqr381VHvhs9c63h3jstvxgSocPDXS/CO0bSYe2KxHdP5BeU+eLVqQqkTgQ==", + "version": "1.5.36", + "resolved": "https://registry.npmjs.org/@utoo/pack-win32-x64-msvc/-/pack-win32-x64-msvc-1.5.36.tgz", + "integrity": "sha512-z40ExNM1OvC5NR9JBnsIgNEBOBIrTWXcjg2rYE+Gb5kvBungyKmYgGl6TpMIeo8EQdiINgHewhW2VmTj8nJIIQ==", "cpu": [ "x64" ], @@ -24871,7 +24880,7 @@ "@evjs/ev": "*", "@evjs/shared": "*", "@logtape/logtape": "^2.0.4", - "@utoo/pack": "^1.5.28", + "@utoo/pack": "^1.5.36", "fast-glob": "^3.3.3", "less": "4.1.3", "less-loader": "12.3.2" @@ -24893,27 +24902,18 @@ "node": ">=6.9.0" } }, - "packages/bundler-utoopack/node_modules/@swc/helpers": { - "version": "0.5.15", - "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", - "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.8.0" - } - }, "packages/bundler-utoopack/node_modules/@utoo/pack": { - "version": "1.5.28", - "resolved": "https://registry.npmjs.org/@utoo/pack/-/pack-1.5.28.tgz", - "integrity": "sha512-6raBayON3ZSiDiPje/wVawtAv+d5Z1SJgLjnBKV3emZ6uwd8WOPHI07C8OPeoA1x1MTci7ADmB10w1+eQPlQ+w==", + "version": "1.5.36", + "resolved": "https://registry.npmjs.org/@utoo/pack/-/pack-1.5.36.tgz", + "integrity": "sha512-WuN0uqpnp/M5IyAzQFkBbSSCSzddlXdAfAo2UyNK906C0TjBFC5DL9VR9zFuiyMZTV3QI4PAMzjJ3u5bokyshQ==", "license": "MIT", "dependencies": { "@babel/code-frame": "7.22.5", "@hono/node-server": "^1.19.11", "@hono/node-ws": "^1.3.0", "@jridgewell/trace-mapping": "^0.3.31", - "@swc/helpers": "0.5.15", - "@utoo/pack-shared": "1.5.28", + "@swc/helpers": "0.5.23", + "@utoo/pack-shared": "1.5.36", "browserslist": "^4.28.5", "domparser-rs": "^0.0.7", "find-up": "4.1.0", @@ -24928,13 +24928,13 @@ "node": ">= 20" }, "optionalDependencies": { - "@utoo/pack-darwin-arm64": "1.5.28", - "@utoo/pack-darwin-x64": "1.5.28", - "@utoo/pack-linux-arm64-gnu": "1.5.28", - "@utoo/pack-linux-arm64-musl": "1.5.28", - "@utoo/pack-linux-x64-gnu": "1.5.28", - "@utoo/pack-linux-x64-musl": "1.5.28", - "@utoo/pack-win32-x64-msvc": "1.5.28" + "@utoo/pack-darwin-arm64": "1.5.36", + "@utoo/pack-darwin-x64": "1.5.36", + "@utoo/pack-linux-arm64-gnu": "1.5.36", + "@utoo/pack-linux-arm64-musl": "1.5.36", + "@utoo/pack-linux-x64-gnu": "1.5.36", + "@utoo/pack-linux-x64-musl": "1.5.36", + "@utoo/pack-win32-x64-msvc": "1.5.36" }, "peerDependencies": { "less": "^4.0.0", diff --git a/packages/bundler-utoopack/package.json b/packages/bundler-utoopack/package.json index 568c1276..a47cbebb 100644 --- a/packages/bundler-utoopack/package.json +++ b/packages/bundler-utoopack/package.json @@ -35,7 +35,7 @@ "@evjs/ev": "*", "@evjs/shared": "*", "@logtape/logtape": "^2.0.4", - "@utoo/pack": "^1.5.28", + "@utoo/pack": "^1.5.36", "fast-glob": "^3.3.3", "less": "4.1.3", "less-loader": "12.3.2" From 455a75aa527a72605c9540cd44fb463d25256662 Mon Sep 17 00:00:00 2001 From: zoomdong Date: Fri, 30 Oct 2026 23:58:16 +0800 Subject: [PATCH 5/6] fix(utoopack): propagate startup cleanup failures --- .../src/adapter/development/dev-worker.ts | 11 +- .../bundler-utoopack/tests/dev-worker.test.ts | 143 ++++++++++++++++++ 2 files changed, 150 insertions(+), 4 deletions(-) create mode 100644 packages/bundler-utoopack/tests/dev-worker.test.ts diff --git a/packages/bundler-utoopack/src/adapter/development/dev-worker.ts b/packages/bundler-utoopack/src/adapter/development/dev-worker.ts index 6fd7fc84..d6b994a3 100644 --- a/packages/bundler-utoopack/src/adapter/development/dev-worker.ts +++ b/packages/bundler-utoopack/src/adapter/development/dev-worker.ts @@ -33,6 +33,7 @@ interface ActiveSession { options: UtoopackDevWorkerSessionOptions; serverProject?: Awaited>; closing: boolean; + closeError?: Error; clientClosed?: boolean; nextBuildId: number; publications: Map; @@ -87,10 +88,11 @@ function enqueueCommand(command: UtoopackDevWorkerCommand): void { } if (command.type === "close" && activeSession?.id === command.sessionId) { activeSession.closing = true; + activeSession.closeError ??= new Error( + "[evjs] Utoopack Session closed during build publication.", + ); for (const publication of activeSession.publications.values()) { - publication.reject( - new Error("[evjs] Utoopack Session closed during build publication."), - ); + publication.reject(activeSession.closeError); } } commandQueue = commandQueue @@ -187,8 +189,9 @@ async function startSession( }, ); } catch (error) { - if (!session.closing) throw error; + if (!session.closing || error !== session.closeError) throw error; // serve() has already awaited its own cleanup after the callback rejected. + // Cleanup failures wrap the cancellation error and must poison the owner. // Its SIGTERM handler is gone, so do not run the normal lifecycle twice. session.clientClosed = true; session.lifecycle.dispose(); diff --git a/packages/bundler-utoopack/tests/dev-worker.test.ts b/packages/bundler-utoopack/tests/dev-worker.test.ts new file mode 100644 index 00000000..ccd6d927 --- /dev/null +++ b/packages/bundler-utoopack/tests/dev-worker.test.ts @@ -0,0 +1,143 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { + UtoopackDevWorkerCommand, + UtoopackDevWorkerMessage, +} from "../src/adapter/development/dev-worker-protocol.js"; + +const runtime = vi.hoisted(() => ({ + onMessage: vi.fn(), + postMessage: vi.fn(), + serve: vi.fn(), + close: vi.fn(async () => {}), + dispose: vi.fn(), +})); + +vi.mock("node:worker_threads", () => ({ + parentPort: { + on: runtime.onMessage, + postMessage: runtime.postMessage, + }, +})); + +vi.mock("node:module", () => ({ + createRequire: () => () => ({ serve: runtime.serve }), +})); + +vi.mock("../src/adapter/development/dev-worker-scheduler.js", () => ({ + ensureUtoopackProcessWorkerScheduler: async () => ({ + failure: new Promise(() => {}), + }), +})); + +vi.mock("../src/adapter/development/dev-worker-shutdown.js", () => ({ + createUtoopackReusableSessionLifecycle: () => ({ + close: runtime.close, + dispose: runtime.dispose, + }), +})); + +beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + vi.spyOn(process, "exit").mockImplementation(() => undefined as never); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("Utoopack native owner startup cancellation", () => { + it.each([ + false, + true, + ])("only reuses the owner after successful cleanup (cleanup fails: %s)", async (cleanupFails) => { + runtime.serve.mockImplementation(async (_options, _cwd, _root, server) => { + try { + await server.onClientBuild({ + clientPaths: ["main.js"], + isRebuild: false, + }); + } catch (error) { + // serve() rethrows the callback error after cleanup, and wraps it + // with any cleanup error when native resource release also fails. + if (cleanupFails) { + throw new AggregateError( + [error, new Error("native Project cleanup failed")], + "Failed to start and close the dev server", + ); + } + throw error; + } + }); + await import("../src/adapter/development/dev-worker.js"); + await vi.waitFor(() => + expect(runtime.postMessage).toHaveBeenCalledWith({ + type: "owner-ready", + }), + ); + const send = runtime.onMessage.mock.calls[0]?.[1] as ( + command: UtoopackDevWorkerCommand, + ) => void; + const start = (sessionId: number) => + send({ + type: "start", + sessionId, + options: { + cwd: process.cwd(), + config: { entry: [] }, + pathRewriteFunctionIndexes: [], + server: { + port: 3000, + https: false, + hostname: "127.0.0.1", + logServerInfo: false, + }, + }, + }); + const messages = () => + runtime.postMessage.mock.calls.map( + ([message]) => message as UtoopackDevWorkerMessage, + ); + + start(1); + await vi.waitFor(() => + expect(messages()).toContainEqual( + expect.objectContaining({ type: "client-build", sessionId: 1 }), + ), + ); + send({ type: "close", sessionId: 1 }); + + if (cleanupFails) { + await vi.waitFor(() => + expect(messages()).toContainEqual( + expect.objectContaining({ + type: "session-error", + sessionId: 1, + message: "Failed to start and close the dev server", + }), + ), + ); + await vi.waitFor(() => expect(process.exit).toHaveBeenCalledWith(1)); + expect(messages()).not.toContainEqual({ type: "closed", sessionId: 1 }); + start(2); + await new Promise((resolve) => setImmediate(resolve)); + expect(runtime.serve).toHaveBeenCalledOnce(); + } else { + await vi.waitFor(() => + expect(messages()).toContainEqual({ type: "closed", sessionId: 1 }), + ); + start(2); + await vi.waitFor(() => + expect(messages()).toContainEqual( + expect.objectContaining({ type: "client-build", sessionId: 2 }), + ), + ); + send({ type: "close", sessionId: 2 }); + await vi.waitFor(() => + expect(messages()).toContainEqual({ type: "closed", sessionId: 2 }), + ); + expect(process.exit).not.toHaveBeenCalled(); + } + expect(runtime.close).not.toHaveBeenCalled(); + }); +}); From 59a5d0cf005780bf8eea121f40e01921aad982b6 Mon Sep 17 00:00:00 2001 From: zoomdong Date: Sat, 31 Oct 2026 01:34:16 +0800 Subject: [PATCH 6/6] Revert "fix(utoopack): propagate startup cleanup failures" This reverts commit 455a75aa527a72605c9540cd44fb463d25256662. --- .../src/adapter/development/dev-worker.ts | 11 +- .../bundler-utoopack/tests/dev-worker.test.ts | 143 ------------------ 2 files changed, 4 insertions(+), 150 deletions(-) delete mode 100644 packages/bundler-utoopack/tests/dev-worker.test.ts diff --git a/packages/bundler-utoopack/src/adapter/development/dev-worker.ts b/packages/bundler-utoopack/src/adapter/development/dev-worker.ts index d6b994a3..6fd7fc84 100644 --- a/packages/bundler-utoopack/src/adapter/development/dev-worker.ts +++ b/packages/bundler-utoopack/src/adapter/development/dev-worker.ts @@ -33,7 +33,6 @@ interface ActiveSession { options: UtoopackDevWorkerSessionOptions; serverProject?: Awaited>; closing: boolean; - closeError?: Error; clientClosed?: boolean; nextBuildId: number; publications: Map; @@ -88,11 +87,10 @@ function enqueueCommand(command: UtoopackDevWorkerCommand): void { } if (command.type === "close" && activeSession?.id === command.sessionId) { activeSession.closing = true; - activeSession.closeError ??= new Error( - "[evjs] Utoopack Session closed during build publication.", - ); for (const publication of activeSession.publications.values()) { - publication.reject(activeSession.closeError); + publication.reject( + new Error("[evjs] Utoopack Session closed during build publication."), + ); } } commandQueue = commandQueue @@ -189,9 +187,8 @@ async function startSession( }, ); } catch (error) { - if (!session.closing || error !== session.closeError) throw error; + if (!session.closing) throw error; // serve() has already awaited its own cleanup after the callback rejected. - // Cleanup failures wrap the cancellation error and must poison the owner. // Its SIGTERM handler is gone, so do not run the normal lifecycle twice. session.clientClosed = true; session.lifecycle.dispose(); diff --git a/packages/bundler-utoopack/tests/dev-worker.test.ts b/packages/bundler-utoopack/tests/dev-worker.test.ts deleted file mode 100644 index ccd6d927..00000000 --- a/packages/bundler-utoopack/tests/dev-worker.test.ts +++ /dev/null @@ -1,143 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { - UtoopackDevWorkerCommand, - UtoopackDevWorkerMessage, -} from "../src/adapter/development/dev-worker-protocol.js"; - -const runtime = vi.hoisted(() => ({ - onMessage: vi.fn(), - postMessage: vi.fn(), - serve: vi.fn(), - close: vi.fn(async () => {}), - dispose: vi.fn(), -})); - -vi.mock("node:worker_threads", () => ({ - parentPort: { - on: runtime.onMessage, - postMessage: runtime.postMessage, - }, -})); - -vi.mock("node:module", () => ({ - createRequire: () => () => ({ serve: runtime.serve }), -})); - -vi.mock("../src/adapter/development/dev-worker-scheduler.js", () => ({ - ensureUtoopackProcessWorkerScheduler: async () => ({ - failure: new Promise(() => {}), - }), -})); - -vi.mock("../src/adapter/development/dev-worker-shutdown.js", () => ({ - createUtoopackReusableSessionLifecycle: () => ({ - close: runtime.close, - dispose: runtime.dispose, - }), -})); - -beforeEach(() => { - vi.resetModules(); - vi.clearAllMocks(); - vi.spyOn(process, "exit").mockImplementation(() => undefined as never); -}); - -afterEach(() => { - vi.restoreAllMocks(); -}); - -describe("Utoopack native owner startup cancellation", () => { - it.each([ - false, - true, - ])("only reuses the owner after successful cleanup (cleanup fails: %s)", async (cleanupFails) => { - runtime.serve.mockImplementation(async (_options, _cwd, _root, server) => { - try { - await server.onClientBuild({ - clientPaths: ["main.js"], - isRebuild: false, - }); - } catch (error) { - // serve() rethrows the callback error after cleanup, and wraps it - // with any cleanup error when native resource release also fails. - if (cleanupFails) { - throw new AggregateError( - [error, new Error("native Project cleanup failed")], - "Failed to start and close the dev server", - ); - } - throw error; - } - }); - await import("../src/adapter/development/dev-worker.js"); - await vi.waitFor(() => - expect(runtime.postMessage).toHaveBeenCalledWith({ - type: "owner-ready", - }), - ); - const send = runtime.onMessage.mock.calls[0]?.[1] as ( - command: UtoopackDevWorkerCommand, - ) => void; - const start = (sessionId: number) => - send({ - type: "start", - sessionId, - options: { - cwd: process.cwd(), - config: { entry: [] }, - pathRewriteFunctionIndexes: [], - server: { - port: 3000, - https: false, - hostname: "127.0.0.1", - logServerInfo: false, - }, - }, - }); - const messages = () => - runtime.postMessage.mock.calls.map( - ([message]) => message as UtoopackDevWorkerMessage, - ); - - start(1); - await vi.waitFor(() => - expect(messages()).toContainEqual( - expect.objectContaining({ type: "client-build", sessionId: 1 }), - ), - ); - send({ type: "close", sessionId: 1 }); - - if (cleanupFails) { - await vi.waitFor(() => - expect(messages()).toContainEqual( - expect.objectContaining({ - type: "session-error", - sessionId: 1, - message: "Failed to start and close the dev server", - }), - ), - ); - await vi.waitFor(() => expect(process.exit).toHaveBeenCalledWith(1)); - expect(messages()).not.toContainEqual({ type: "closed", sessionId: 1 }); - start(2); - await new Promise((resolve) => setImmediate(resolve)); - expect(runtime.serve).toHaveBeenCalledOnce(); - } else { - await vi.waitFor(() => - expect(messages()).toContainEqual({ type: "closed", sessionId: 1 }), - ); - start(2); - await vi.waitFor(() => - expect(messages()).toContainEqual( - expect.objectContaining({ type: "client-build", sessionId: 2 }), - ), - ); - send({ type: "close", sessionId: 2 }); - await vi.waitFor(() => - expect(messages()).toContainEqual({ type: "closed", sessionId: 2 }), - ); - expect(process.exit).not.toHaveBeenCalled(); - } - expect(runtime.close).not.toHaveBeenCalled(); - }); -});