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
10 changes: 0 additions & 10 deletions packages/bundler-utoopack/src/adapter/dev-worker-client.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import fs from "node:fs";
import { Worker } from "node:worker_threads";
import type {
ConfigComplete,
Expand Down Expand Up @@ -56,8 +55,6 @@ export interface UtoopackDevWorkerHandle {
/** Rejects on unexpected exit and remains pending after an intentional close. */
failure: Promise<never>;
throwIfFailed(): void;
/** Notify the persistent compiler after Core finishes generated input. */
invalidate(files: readonly string[]): Promise<void>;
close(): Promise<void>;
}

Expand Down Expand Up @@ -155,13 +152,6 @@ export function startUtoopackDevWorker(
throwIfFailed() {
if (failureReason !== undefined) throw failureReason;
},
async invalidate(files) {
for (const file of new Set(files)) {
const stats = await fs.promises.stat(file);
const nextMtimeMs = Math.max(Date.now() + 1_000, stats.mtimeMs + 1_000);
await fs.promises.utimes(file, stats.atime, new Date(nextMtimeMs));
}
},
close() {
closePromise ??= (async () => {
closing = true;
Expand Down
195 changes: 36 additions & 159 deletions packages/bundler-utoopack/src/adapter/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,95 +166,6 @@ function waitForPollingDelay(
});
}

function collectGeneratedEntryInvalidation(
cwd: string,
plan: BuildPlan,
): {
files: string[];
statsPaths: string[];
} {
const generatedRoot = path.resolve(cwd, ".ev");
const environments = new Set<"client" | "server">();
const files = new Set<string>();
for (const entry of plan.entries) {
if (!entry.import.startsWith(".") && !path.isAbsolute(entry.import)) {
continue;
}
const absolute = path.resolve(cwd, entry.import);
const relative = path.relative(generatedRoot, absolute);
if (
relative === "" ||
relative.startsWith(`..${path.sep}`) ||
path.isAbsolute(relative)
) {
continue;
}
files.add(absolute);
environments.add(entry.environment);
}

const outputPaths = resolveBuildOutputPaths(cwd, plan);
return {
files: [...files],
statsPaths: [
...(environments.has("client")
? [path.join(outputPaths.clientDir, "stats.json")]
: []),
...(environments.has("server")
? [path.join(outputPaths.serverDir, "stats.json")]
: []),
],
};
}

async function readStatsVersions(
statsPaths: readonly string[],
): Promise<Map<string, string | undefined>> {
return new Map(
await Promise.all(
statsPaths.map(
async (statsPath) =>
[statsPath, await readServerStatsVersion(statsPath)] as const,
),
),
);
}

async function waitForStatsVersionsToAdvance(
statsPaths: readonly string[],
previous: ReadonlyMap<string, string | undefined>,
timeoutMs: number,
signal?: AbortSignal,
): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (true) {
throwIfPollingAborted(
signal,
"[evjs] Utoopack development session closed during update.",
);
const current = await readStatsVersions(statsPaths);
if (
statsPaths.every((statsPath) => {
const version = current.get(statsPath);
return version !== undefined && version !== previous.get(statsPath);
})
) {
return;
}
if (Date.now() >= deadline) {
throw new Error(
`[evjs] Timed out waiting for Utoopack to compile final generated input (${statsPaths
.map((statsPath) => JSON.stringify(statsPath))
.join(", ")}). Restart ev dev to recover safely.`,
);
}
await waitForPollingDelay(
signal,
"[evjs] Utoopack development session closed during update.",
);
}
}

function requireUtoopack(): UtoopackRuntime {
// @utoo/pack's import condition targets ESM .js files; Node 18 parses them as CJS.
return require("@utoo/pack") as UtoopackRuntime;
Expand Down Expand Up @@ -394,14 +305,14 @@ async function startUtoopackDev(
"[evjs] Core discarded the initial Utoopack facts snapshot.",
);
}
controller.markBuildPublished(initialFacts, initialServerStatsVersion);
worker.throwIfFailed();

if (hasRuntimeServerEntry(plan)) {
await callbacks.onServerBundleReady(generation);
worker.throwIfFailed();
}
if (hasServerEntries(plan)) {
controller.markServerStatsPublished(initialServerStatsVersion);
const monitor = startUtoopackServerStatsMonitor({
statsPath: serverStatsPath,
initialVersion: initialServerStatsVersion,
Expand Down Expand Up @@ -473,6 +384,7 @@ class UtoopackDevController implements BundlerDevController<ConfigComplete> {
private serverStatsMonitor: UtoopackServerStatsMonitor | undefined;
private devWorkQueue: Promise<void> = Promise.resolve();
private pendingPlanTransition: UtoopackDevPlanTransition | undefined;
private publishedFacts: BundlerBuildFacts | undefined;
private publishedServerStatsVersion: string | undefined;
private closing = false;
private closed = false;
Expand Down Expand Up @@ -502,8 +414,12 @@ class UtoopackDevController implements BundlerDevController<ConfigComplete> {
this.serverStatsMonitor = monitor;
}

markServerStatsPublished(version: string | undefined): void {
this.publishedServerStatsVersion = version;
markBuildPublished(
facts: BundlerBuildFacts,
serverStatsVersion: string | undefined,
): void {
this.publishedFacts = facts;
this.publishedServerStatsVersion = serverStatsVersion;
}

async close(): Promise<void> {
Expand Down Expand Up @@ -547,14 +463,14 @@ class UtoopackDevController implements BundlerDevController<ConfigComplete> {
const initialGeneration = this.options.generation;
const transition = createUtoopackDevPlanTransition({
onOpenAccept: () => async () => {
const publish = await this.prepareBuildPublication(
const publish = this.prepareArtifactPublication(
initialPlan,
initialGeneration,
);
await publish();
},
onOpenRollback: () => async () => {
const publish = await this.prepareBuildPublication(
const publish = this.prepareArtifactPublication(
initialPlan,
initialGeneration,
);
Expand Down Expand Up @@ -637,8 +553,9 @@ class UtoopackDevController implements BundlerDevController<ConfigComplete> {
return false;
});
}
// The observed stats may come from any intermediate `.ev` snapshot. Drop
// and acknowledge it only after Core selects and opens the final state.
// The observed stats may come from any intermediate `.ev` snapshot. Defer
// it until Core selects the final state, then leave the monitor baseline
// unchanged so that state is collected on the next polling cycle.
return transition.defer();
}

Expand All @@ -657,6 +574,9 @@ class UtoopackDevController implements BundlerDevController<ConfigComplete> {
{ isRebuild: true },
facts,
);
if (disposition === "published") {
this.publishedFacts = facts;
}
if (
disposition === "published" &&
hasRuntimeServerEntry(plan) &&
Expand Down Expand Up @@ -688,7 +608,7 @@ class UtoopackDevController implements BundlerDevController<ConfigComplete> {
this.options.generation = options.generation;
transition.stage({
publish: async () => {
const publish = await this.prepareBuildPublication(
const publish = this.prepareArtifactPublication(
update.next,
options.generation,
);
Expand All @@ -699,7 +619,7 @@ class UtoopackDevController implements BundlerDevController<ConfigComplete> {
this.options.generation = previousGeneration;
if (this.closed) return async () => {};
return async () => {
const publish = await this.prepareBuildPublication(
const publish = this.prepareArtifactPublication(
previousPlan,
previousGeneration,
);
Expand All @@ -716,29 +636,6 @@ class UtoopackDevController implements BundlerDevController<ConfigComplete> {
}
}

private async collectFinalBuildFacts(plan: BuildPlan): Promise<{
facts: BundlerBuildFacts;
serverStatsVersion: string | undefined;
}> {
await this.waitForFinalCompilerState(plan);
// A later stats version must never be acknowledged for an earlier facts
// snapshot. Reading the version first is deliberately conservative: a
// concurrent rebuild may cause one duplicate cycle, but cannot be lost.
const serverStatsVersion = hasServerEntries(plan)
? await readServerStatsVersion(
path.join(
resolveBuildOutputPaths(this.options.cwd, plan).serverDir,
"stats.json",
),
)
: undefined;
const facts = await this.waitForReadableStats(
plan,
INITIAL_DEV_STATS_TIMEOUT_MS,
);
return { facts, serverStatsVersion };
}

waitForReadableStats(
plan: BuildPlan,
timeoutMs: number,
Expand All @@ -754,12 +651,19 @@ class UtoopackDevController implements BundlerDevController<ConfigComplete> {
]);
}

private async prepareBuildPublication(
private prepareArtifactPublication(
plan: BuildPlan,
generation: BundlerDevGeneration,
): Promise<() => Promise<void>> {
const { facts, serverStatsVersion } =
await this.collectFinalBuildFacts(plan);
): () => Promise<void> {
const facts = this.publishedFacts;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Relink generated updates from fresh client facts

When a generated client module changes its imports—for example, an existing plugin module starts importing a stylesheet—the BuildPlan can remain artifact-only while Utoopack's entrypoint asset inventory gains main.css. This path always republishes the cached facts from before the generated-input rebuild, so the regenerated HTML and manifest omit that asset; client-only sessions have no stats monitor to correct them, and mixed sessions can also read server stats before the client rebuild finishes. Wait for fresh client stats or monitor and relink after the client compile instead of treating topology preservation as asset preservation.

AGENTS.md reference: AGENTS.md:L42-L43

Useful? React with 👍 / 👎.

if (!facts) {
throw new Error(
"[evjs] Utoopack cannot relink development artifacts before its initial build facts are published.",
);
}
// Artifact-only updates preserve entry and output identity. Reuse the last
// published asset inventory to relink framework-owned HTML/manifests while
// Utoopack independently watches and rebuilds generated `.ev` input.
return async () => {
if (this.closed) return;
const disposition = await generateDevArtifacts(
Expand All @@ -775,39 +679,9 @@ class UtoopackDevController implements BundlerDevController<ConfigComplete> {
"[evjs] Core discarded the selected Utoopack facts snapshot before publication completed.",
);
}
if (hasRuntimeServerEntry(plan) && !this.closed) {
await this.options.onServerBundleReady(generation);
}
if (hasServerEntries(plan) && !this.closed) {
this.publishedServerStatsVersion = serverStatsVersion;
this.serverStatsMonitor?.advance(serverStatsVersion);
}
};
}

private async waitForFinalCompilerState(plan: BuildPlan): Promise<void> {
const invalidation = collectGeneratedEntryInvalidation(
this.options.cwd,
plan,
);
if (invalidation.files.length === 0) return;
for (let pass = 0; pass < 2; pass += 1) {
this.options.worker.throwIfFailed();
const versions = await readStatsVersions(invalidation.statsPaths);
await this.options.worker.invalidate(invalidation.files);
await Promise.race([
waitForStatsVersionsToAdvance(
invalidation.statsPaths,
versions,
INITIAL_DEV_STATS_TIMEOUT_MS,
this.closingController.signal,
),
this.options.worker.failure,
]);
}
this.options.worker.throwIfFailed();
}

private enqueueDevWork<T>(work: () => Promise<T>): Promise<T> {
const result = this.devWorkQueue.then(work);
this.devWorkQueue = result.then(
Expand Down Expand Up @@ -857,8 +731,8 @@ function createUtoopackDevPlanTransition(options: {
let selectedPublish: (() => void | Promise<void>) | undefined;
let aborted = false;
const deferred: Array<(consumed: boolean) => void> = [];
const releaseDeferred = () => {
for (const resolve of deferred.splice(0)) resolve(true);
const releaseDeferred = (consumed: boolean) => {
for (const resolve of deferred.splice(0)) resolve(consumed);
};
const assertSelectable = (operation: string) => {
if (
Expand Down Expand Up @@ -909,7 +783,7 @@ function createUtoopackDevPlanTransition(options: {
if (aborted || state === "settled") return;
aborted = true;
options.onSettled();
releaseDeferred();
releaseDeferred(true);
},
stage(next) {
assertSelectable("stage a candidate");
Expand Down Expand Up @@ -962,7 +836,10 @@ function createUtoopackDevPlanTransition(options: {
}
state = "settled";
if (!aborted) options.onSettled();
releaseDeferred();
// Accepted state must retry an observation that may have been produced
// before its final `.ev` snapshot was visible. Rollback discards that
// candidate observation and waits for a later restored-state rebuild.
releaseDeferred(outcome !== "accept");
},
};
}
Expand Down
Loading