diff --git a/CHANGELOG.md b/CHANGELOG.md index 40efbaa0..df2c49b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,13 @@ All notable changes to evjs are documented here. Releases follow [Semantic Versi ## [Unreleased] +### Bug Fixes + +- Refresh Utoopack development HTML and asset metadata after client rebuilds + change entry chunks, so new visits and browser reloads load the latest code + without restarting the development server. Preserve the native server-build + publication and API readiness lifecycle. + --- ## [0.3.26] — 2026-10-30 diff --git a/package-lock.json b/package-lock.json index cc048a3f..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,8 +24880,7 @@ "@evjs/ev": "*", "@evjs/shared": "*", "@logtape/logtape": "^2.0.4", - "@utoo/pack": "^1.5.28", - "chokidar": "^3.6.0", + "@utoo/pack": "^1.5.36", "fast-glob": "^3.3.3", "less": "4.1.3", "less-loader": "12.3.2" @@ -24894,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", @@ -24929,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 e5e333ba..a47cbebb 100644 --- a/packages/bundler-utoopack/package.json +++ b/packages/bundler-utoopack/package.json @@ -35,8 +35,7 @@ "@evjs/ev": "*", "@evjs/shared": "*", "@logtape/logtape": "^2.0.4", - "@utoo/pack": "^1.5.28", - "chokidar": "^3.6.0", + "@utoo/pack": "^1.5.36", "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 f3800ee9..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, @@ -47,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 @@ -77,81 +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 { - 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")] - : []), - ]; - if (requiredStats.length === 0) { - return new UtoopackManifestGenerator(cwd, plan).collectBuildFacts(); - } - - while (true) { - throwIfPollingAborted( - signal, - "[evjs] Utoopack development session closed while waiting for build stats.", - ); - try { - return await new UtoopackManifestGenerator(cwd, plan).collectBuildFacts(); - } 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( @@ -261,6 +184,9 @@ async function startUtoopackDev( const worker = startUtoopackDevWorker({ cwd, config: utoopackConfig, + async onClientBuild({ isRebuild }) { + await controller.processClientBuild(isRebuild); + }, async onServerBuild() { await controller.processServerBuild(); }, @@ -341,7 +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(); worker.throwIfFailed(); return controller; } catch (error) { @@ -390,6 +315,10 @@ class UtoopackDevController implements BundlerDevController { private clientMiddlewareServer: ClientDevMiddlewareServerHandle | undefined; private devWorkQueue: Promise = Promise.resolve(); private hasEmittedDevArtifacts = false; + private acceptedFacts: BundlerBuildFacts | undefined; + private clientFacts: BundlerBuildFacts = { clientEntryAssets: {} }; + private readonly clientFactsReady: Promise; + private resolveClientFactsReady!: () => void; private closed = false; private closePromise: Promise | undefined; private readonly closingController = new AbortController(); @@ -404,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; }); @@ -435,19 +375,6 @@ 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}`; - } - }); - } - async close(): Promise { this.closePromise ??= (async () => { this.closed = true; @@ -479,39 +406,68 @@ 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); }); } - waitForReadableStats(plan: BuildPlan): Promise { - return Promise.race([ - waitForReadableDevStats( - this.options.cwd, - plan, - this.closingController.signal, - ), - this.options.worker.failure, - ]); + 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; + if (!isRebuild && this.acceptedFacts) return; + await this.publishBuildFacts( + this.combineFacts(this.acceptedFacts), + false, + ); + }); } - private async processBuildFacts( - isRebuild: boolean, + private combineFacts(serverFacts?: BundlerBuildFacts): BundlerBuildFacts { + return { + ...serverFacts, + ...this.clientFacts, + emittedFiles: { + ...(serverFacts?.emittedFiles?.server + ? { server: serverFacts.emittedFiles.server } + : {}), + ...this.clientFacts.emittedFiles, + }, + }; + } + + private async publishBuildFacts( + facts: BundlerBuildFacts, + notifyServerReady: boolean, ): Promise { if (this.closed) return "discarded"; - const facts = await this.waitForReadableStats(this.options.plan); - if (this.closed) return "discarded"; - const disposition = await generateDevArtifacts( - this.options.cwd, - this.options.plan, - this.options.onBuildFacts, - { isRebuild: isRebuild || this.hasEmittedDevArtifacts }, - facts, - ); + const disposition = await this.options.onBuildFacts(facts, { + isRebuild: this.hasEmittedDevArtifacts, + }); if (disposition === "published") { this.hasEmittedDevArtifacts = true; - if (hasRuntimeServerEntry(this.options.plan) && !this.closed) { + if ( + notifyServerReady && + hasRuntimeServerEntry(this.options.plan) && + !this.closed + ) { await this.options.onServerBundleReady(); } + this.acceptedFacts = facts; } return disposition; } @@ -525,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/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 4cc3595b..dc145eb4 100644 --- a/packages/bundler-utoopack/src/adapter/output/manifest-generator.ts +++ b/packages/bundler-utoopack/src/adapter/output/manifest-generator.ts @@ -173,28 +173,39 @@ export class UtoopackManifestGenerator { this.serverEmittedFiles = readEmittedFiles(stats); } - async collectBuildFacts(): Promise { - await this.loadClientStats(); - await this.loadServerStats(); + async collectBuildFacts({ + environment, + }: { + environment?: "client" | "server"; + } = {}): Promise { + if (environment !== "server") await this.loadClientStats(); + if (environment !== "client") await this.loadServerStats(); const emittedFiles = { - ...(this.clientEmittedFiles + ...(environment !== "server" && this.clientEmittedFiles ? { client: includeAdapterStatsFile(this.clientEmittedFiles) } : {}), - ...(this.serverEmittedFiles + ...(environment !== "client" && this.serverEmittedFiles ? { server: includeAdapterStatsFile(this.serverEmittedFiles) } : {}), }; const facts: BundlerBuildFacts = { ...(Object.keys(emittedFiles).length > 0 ? { emittedFiles } : {}), - clientEntryAssets: cloneEntryAssets(this.clientEntryAssets), - serverEntryAssets: cloneEntryAssets(this.serverEntryAssets), + 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 8c317659..0f582ef5 100644 --- a/packages/bundler-utoopack/tests/adapter.test.ts +++ b/packages/bundler-utoopack/tests/adapter.test.ts @@ -25,27 +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; }) => { @@ -69,6 +71,7 @@ const utoopackMock = vi.hoisted(() => ({ void runtime .serve({ config }, undefined, undefined, { ...(server as object), + onClientBuild, onReady(context: { port: number; hostname: string; @@ -90,7 +93,9 @@ const utoopackMock = vi.hoisted(() => ({ }, }) .then(async () => { - if (config.server) await onServerBuild?.(); + if (config.server && !utoopackMock.deferInitialServerBuild) { + await onServerBuild?.(); + } }) .catch((error) => { onServerError?.( @@ -128,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; @@ -170,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", @@ -246,6 +246,99 @@ async function waitForCondition( } } +function clientStatsFor(assets: string[], includeInventory = true): string { + const values = assets.map((name) => ({ name })); + return JSON.stringify({ + ...(includeInventory ? { assets: values } : {}), + entrypoints: { main: { assets: values } }, + }); +} + +async function writeClientStats( + cwd: string, + assets: string[], + inventory = true, +) { + const clientDir = path.join(cwd, "dist/client"); + await fs.promises.mkdir(clientDir, { recursive: true }); + await Promise.all( + assets.map((asset) => + fs.promises.writeFile(path.join(clientDir, asset), ""), + ), + ); + await fs.promises.writeFile( + path.join(clientDir, "stats.json"), + clientStatsFor(assets, inventory), + ); +} + +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, + }); + 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"), + clientBuild: (isRebuild = true) => + utoopackMock.startUtoopackDevWorker.mock.calls + .at(-1)?.[0] + .onClientBuild?.({ clientPaths: [], isRebuild }), + serverBuild: () => + utoopackMock.startUtoopackDevWorker.mock.calls + .at(-1)?.[0] + .onServerBuild?.(), + }; +} + async function makeProject(pageId = "index") { const cwd = await fs.promises.mkdtemp(path.join(os.tmpdir(), "evjs-dev-")); tempDirs.push(cwd); @@ -281,10 +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(); @@ -506,60 +599,150 @@ describe("utoopackAdapter browser log forwarding config", () => { }); 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" }, + 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"); }); - const buildContext = await createBuildContext(config, cwd); - const abortController = new AbortController(); - const pending = utoopackAdapterTesting.waitForReadableDevStats( - cwd, - buildContext.plan, - abortController.signal, - ); - setTimeout(() => abortController.abort(), 50); + 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(); + } + }); - await expect(pending).rejects.toThrow( - "closed while waiting for build stats", - ); + it("automatically republishes HTML and metadata and recovers from incomplete client stats", async () => { + const app = await startDevFixture(); + try { + 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 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( + await fs.promises.readFile( + path.join(app.cwd, "dist/deployment-metadata.json"), + "utf-8", + ), + ); + expect(metadata.documents[0].assets).toEqual({ + js: ["root-new.js", "main.js"], + css: ["new.css"], + }); + expect(app.rebuildFlags).toEqual([false, true]); + expect(app.onServerBundleReady).not.toHaveBeenCalled(); + } finally { + await app.controller.close(); + } + await writeClientStats(app.cwd, ["root-after-close.js", "main.js"]); + await app.clientBuild(); + expect(app.outputs).toHaveLength(2); + expect(await app.html()).toContain('src="/root-new.js"'); }); - 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" }, + it("gates initial server publication and reuses accepted server facts for client rebuilds", async () => { + utoopackMock.deferInitialServerBuild = true; + const app = await startDevFixture(true); + try { + await writeClientStats(app.cwd, ["root-before-server.js", "main.js"]); + await app.clientBuild(); + 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({ + entrypoints: { + 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(app.cwd, "dist/server/stats.json"), + serverStats, + ); + 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); + } + expect(app.outputs).toHaveLength(3); + expect(app.facts.at(-1)?.emittedFiles?.client).toBeUndefined(); + } finally { + await app.controller.close(); + } + }); + + it("serializes automatic client publication with a concurrent server batch", async () => { + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; }); - 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: [], + const app = await startDevFixture(true, async (isRebuild) => { + if (isRebuild) await gate; }); - 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); + 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 Promise.all([clientPublication, serverPublication]); + expect(app.rebuildFlags).toEqual([false, true, true]); + expect(app.outputs).toHaveLength(3); + expect(app.onServerBundleReady).toHaveBeenCalledTimes(2); } finally { - await controller.close(); + release(); + await app.controller.close(); } }); @@ -613,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, { @@ -735,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(), @@ -746,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(() => {});