Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,13 @@ All notable changes to evjs are documented here. Releases follow [Semantic Versi

## [Unreleased]

### 🐛 Bug Fixes

- **Client development HTML updates** — Regenerate Utoopack development HTML and
manifests when client rebuilds change the entry assets, so adding a component
dependency no longer leaves refreshes loading an older chunk. Preserve the
last published HTML while build stats are incomplete.

---

## [0.2.17] — 2026-10-19
Expand Down
85 changes: 85 additions & 0 deletions packages/bundler-utoopack/src/adapter/client-stats-watcher.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import fs from "node:fs";
import path from "node:path";
import { watch } from "chokidar";

export async function readClientStatsVersion(
statsPath: string,
): Promise<string | 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;
}
}

export interface ClientStatsWatcher {
advance(version: string | undefined): void;
close(): Promise<void>;
}

/** Watch the directory so atomic replacement does not detach the listener. */
export function watchClientStats(options: {
statsPath: string;
initialVersion: string | undefined;
onChange(): Promise<void>;
onError(error: unknown): void;
}): ClientStatsWatcher {
const statsPath = path.resolve(options.statsPath);
let version = options.initialVersion;
let closed = false;
let scheduled = false;
let queue = Promise.resolve();
let closePromise: Promise<void> | undefined;
const watcher = watch(path.dirname(statsPath), {
atomic: true,
awaitWriteFinish: { stabilityThreshold: 50, pollInterval: 10 },
depth: 0,
disableGlobbing: true,
ignoreInitial: true,
});

function schedule(): void {
if (closed || scheduled) return;
scheduled = true;
queue = queue.then(async () => {
scheduled = false;
if (closed) return;
try {
const nextVersion = await readClientStatsVersion(statsPath);
if (!nextVersion || nextVersion === version || closed) return;
// Only a successful publication advances the baseline. Incomplete
// stats can then recover on the next real write without losing it.
await options.onChange();
} catch (error) {
if (!closed) options.onError(error);
}
});
}

const onFileChange = (file: string) => {
if (path.resolve(file) === statsPath) schedule();
};
watcher.on("add", onFileChange);
watcher.on("change", onFileChange);
watcher.on("error", (error) => {
if (!closed) options.onError(error);
});
// Catch writes between the initial publication and watcher attachment.
watcher.on("ready", schedule);

return {
advance(nextVersion) {
version = nextVersion;
},
close() {
closePromise ??= (async () => {
closed = true;
await watcher.close();
await queue;
})();
return closePromise;
},
};
}
224 changes: 161 additions & 63 deletions packages/bundler-utoopack/src/adapter/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,15 @@ import type { BuildPlan, BuildPlanUpdate } from "@evjs/ev/_internal/manifest";
import type { ResolvedConfig } from "@evjs/ev/config";
import { getLogger } from "@logtape/logtape";
import type { ConfigComplete } from "@utoo/pack";
import { UtoopackManifestGenerator } from "../manifest-generator.js";
import {
IncompleteClientStatsError,
UtoopackManifestGenerator,
} from "../manifest-generator.js";
import {
type ClientStatsWatcher,
readClientStatsVersion,
watchClientStats,
} from "./client-stats-watcher.js";
import { getOutputPaths } from "./output-paths.js";

const logger = getLogger(["evjs", "bundler-utoopack"]);
Expand Down Expand Up @@ -49,15 +57,17 @@ async function generateDevArtifacts(
options?: { isRebuild?: boolean },
) => void | Promise<void>,
options: { isRebuild?: boolean } = {},
facts?: BundlerBuildFacts,
): Promise<boolean> {
const outputPaths = getOutputPaths(cwd, config.output, plan.distDir);
const clientStatsPath = path.join(outputPaths.clientDir, "stats.json");
if (!fs.existsSync(clientStatsPath)) return false;

logger.info`Generating development manifest and HTML...`;
const generator = new UtoopackManifestGenerator(cwd, plan);
const facts = await generator.collectBuildFacts();
await onBuildFacts(facts, options);
const buildFacts =
facts ??
(await new UtoopackManifestGenerator(cwd, plan).collectBuildFacts());
await onBuildFacts(buildFacts, options);
return true;
}

Expand Down Expand Up @@ -122,59 +132,59 @@ export const utoopackAdapter: BundlerAdapter<ConfigComplete> = {
},
});

await generateDevArtifacts(config, cwd, plan, callbacks.onBuildFacts, {
isRebuild: false,
});
await callbacks.onDevServerReady?.({ origin: devServerOrigin });
if (!hasRuntimeServerEntry(plan)) {
return new UtoopackDevController({
config,
cwd,
onBuildFacts: callbacks.onBuildFacts,
});
}

const outDir = getOutputPaths(cwd, config.output, plan.distDir).serverDir;

if (!fs.existsSync(outDir)) {
fs.mkdirSync(outDir, { recursive: true });
}

let ready = false;
const checkReady = async (filename?: string) => {
if (ready) return;
const hasBundle = filename
? filename === "stats.json" || filename.endsWith(".js")
: (await fs.promises.readdir(outDir).catch(() => [])).some(
(f) => f === "stats.json" || f.endsWith(".js"),
);

if (hasBundle) {
ready = true;
try {
await callbacks.onServerBundleReady();
serverReadyWatcher?.close();
} catch (err) {
logger.error`Server bundle ready callback failed: ${err}`;
ready = false;
}
}
};

serverReadyWatcher = fs.watch(outDir, (_eventType, filename) => {
if (filename) void checkReady(filename);
});

// Initial check in case it was written before the watcher attached
await checkReady();
return new UtoopackDevController({
const controller = new UtoopackDevController({
config,
cwd,
plan,
onBuildFacts: callbacks.onBuildFacts,
closeWatcher() {
serverReadyWatcher?.close();
},
});

try {
await controller.initialize();
await callbacks.onDevServerReady?.({ origin: devServerOrigin });
if (!hasRuntimeServerEntry(plan)) return controller;

const outDir = getOutputPaths(cwd, config.output, plan.distDir).serverDir;

if (!fs.existsSync(outDir)) {
fs.mkdirSync(outDir, { recursive: true });
}

let ready = false;
const checkReady = async (filename?: string) => {
if (ready) return;
const hasBundle = filename
? filename === "stats.json" || filename.endsWith(".js")
: (await fs.promises.readdir(outDir).catch(() => [])).some(
(f) => f === "stats.json" || f.endsWith(".js"),
);

if (hasBundle) {
ready = true;
try {
await callbacks.onServerBundleReady();
serverReadyWatcher?.close();
} catch (err) {
logger.error`Server bundle ready callback failed: ${err}`;
ready = false;
}
}
};

serverReadyWatcher = fs.watch(outDir, (_eventType, filename) => {
if (filename) void checkReady(filename);
});

// Initial check in case it was written before the watcher attached
await checkReady();
return controller;
} catch (error) {
await controller.close();
throw error;
}
},
};

Expand All @@ -196,40 +206,128 @@ function formatDevServerOrigin(
}

class UtoopackDevController implements BundlerDevController {
private plan: BuildPlan;
private clientStatsWatcher: ClientStatsWatcher | undefined;
private publishedClientStatsVersion: string | undefined;
private hasPublishedArtifacts = false;
private publicationQueue: Promise<void> = Promise.resolve();
private closed = false;
private closePromise: Promise<void> | undefined;

constructor(
private options: {
config: ResolvedConfig<ConfigComplete>;
cwd: string;
plan: BuildPlan;
onBuildFacts: BundlerDevContext<ConfigComplete>["callbacks"]["onBuildFacts"];
closeWatcher?: () => void;
},
) {}
) {
this.plan = options.plan;
}

close(): void {
this.options.closeWatcher?.();
private get clientStatsPath(): string {
return path.join(
getOutputPaths(
this.options.cwd,
this.options.config.output,
this.plan.distDir,
).clientDir,
"stats.json",
);
}

async updatePlan(update: BuildPlanUpdate): Promise<void> {
if (isEmptyPlanUpdate(update)) return;
async initialize(): Promise<void> {
await this.enqueue(() => this.publishArtifacts(this.plan, false));
if (this.closed) return;
this.clientStatsWatcher = watchClientStats({
statsPath: this.clientStatsPath,
initialVersion: this.publishedClientStatsVersion,
onChange: () =>
this.enqueue(async () => {
const version = await readClientStatsVersion(this.clientStatsPath);
if (!version || version === this.publishedClientStatsVersion) return;
await this.publishArtifacts(this.plan, this.hasPublishedArtifacts);
}),
onError(error) {
logger.warn`Failed to publish Utoopack client rebuild: ${error}`;
},
});
}

if (!isHtmlOnlyUpdate(update)) {
throw new Error(
`[evjs] Utoopack dev cannot apply framework plan changes without restarting ev dev (${formatUnsupportedPlanUpdate(update)}). HTML-only framework plan updates are supported; entry additions, removals, server changes, and route metadata changes still require a lower-layer Utoopack update API.`,
);
close(): Promise<void> {
this.closePromise ??= (async () => {
this.closed = true;
this.options.closeWatcher?.();
await this.clientStatsWatcher?.close();
await this.publicationQueue;
})();
return this.closePromise;
}

private enqueue<T>(work: () => Promise<T>): Promise<T> {
const result = this.publicationQueue.then(work);
this.publicationQueue = result.then(
() => undefined,
() => undefined,
);
return result;
}

private async publishArtifacts(
plan: BuildPlan,
isRebuild: boolean,
): Promise<boolean> {
if (this.closed) return false;
const versionBefore = await readClientStatsVersion(this.clientStatsPath);
if (!versionBefore) return false;
let facts: BundlerBuildFacts;
try {
facts = await new UtoopackManifestGenerator(
this.options.cwd,
plan,
).collectBuildFacts({ strictClientStats: true });
} catch (error) {
if (error instanceof IncompleteClientStatsError) return false;
throw error;
}
const versionAfter = await readClientStatsVersion(this.clientStatsPath);
if (this.closed || versionBefore !== versionAfter) return false;

const emitted = await generateDevArtifacts(
this.options.config,
this.options.cwd,
update.next,
plan,
this.options.onBuildFacts,
{ isRebuild: true },
{ isRebuild },
facts,
);
if (!emitted) {
if (emitted) {
this.hasPublishedArtifacts = true;
this.publishedClientStatsVersion = versionAfter;
this.clientStatsWatcher?.advance(versionAfter);
}
return emitted;
}

async updatePlan(update: BuildPlanUpdate): Promise<void> {
if (isEmptyPlanUpdate(update)) return;

if (!isHtmlOnlyUpdate(update)) {
throw new Error(
"[evjs] Utoopack dev cannot regenerate framework artifacts before client build stats are available.",
`[evjs] Utoopack dev cannot apply framework plan changes without restarting ev dev (${formatUnsupportedPlanUpdate(update)}). HTML-only framework plan updates are supported; entry additions, removals, server changes, and route metadata changes still require a lower-layer Utoopack update API.`,
);
}

await this.enqueue(async () => {
const emitted = await this.publishArtifacts(update.next, true);
if (!emitted) {
throw new Error(
"[evjs] Utoopack dev cannot regenerate framework artifacts before client build stats are available.",
);
}
this.plan = update.next;
});
}
}

Expand Down
Loading