From 196e78d2fafff0ad11e00790c4504d9383622685 Mon Sep 17 00:00:00 2001 From: zoomdong <1344492820@qq.com> Date: Mon, 3 Aug 2026 13:48:13 +0800 Subject: [PATCH 1/3] feat(utoopack): support multiple server entries --- package-lock.json | 72 +++--- packages/bundler-utoopack/package.json | 2 +- .../src/adapter/create-config.ts | 78 +++++- .../bundler-utoopack/src/adapter/index.ts | 2 +- .../src/manifest-generator.ts | 68 +++-- .../bundler-utoopack/tests/adapter.test.ts | 102 +++++++- .../tests/create-config.test.ts | 63 +++-- .../tests/manifest-generator.test.ts | 170 ++++++++++++- .../tests/multi-server-entry.test.ts | 235 ++++++++++++++++++ 9 files changed, 699 insertions(+), 93 deletions(-) create mode 100644 packages/bundler-utoopack/tests/multi-server-entry.test.ts diff --git a/package-lock.json b/package-lock.json index 8853161e..840215c6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8838,9 +8838,9 @@ } }, "node_modules/@utoo/pack-darwin-arm64": { - "version": "1.4.32", - "resolved": "https://registry.npmjs.org/@utoo/pack-darwin-arm64/-/pack-darwin-arm64-1.4.32.tgz", - "integrity": "sha512-wJ5BEMGYwDz1NgUVdIVNejf2+4j2nycW1/4Xd3dA+jslQEXuvanheTH4EWJy1AFSb2UF15aw6k0impaqVz0CpQ==", + "version": "1.5.0-alpha.2", + "resolved": "https://registry.npmjs.org/@utoo/pack-darwin-arm64/-/pack-darwin-arm64-1.5.0-alpha.2.tgz", + "integrity": "sha512-A467C+wVcqfGXzYzmILNSqaDV5NVmchosJapok4m8Dxn1WcOW5Js0ivU3y3uu2K5W7rtSBc0I0bNLneH/ibc5A==", "cpu": [ "arm64" ], @@ -8854,9 +8854,9 @@ } }, "node_modules/@utoo/pack-darwin-x64": { - "version": "1.4.32", - "resolved": "https://registry.npmjs.org/@utoo/pack-darwin-x64/-/pack-darwin-x64-1.4.32.tgz", - "integrity": "sha512-aNgg7gyo8WAvNWAJ5mzReQody7/ildb4FQlxl672PcmvHfWqm3aiwpNCn+LImrQUAZyE4JLjkrdxvPJ2XwyQaw==", + "version": "1.5.0-alpha.2", + "resolved": "https://registry.npmjs.org/@utoo/pack-darwin-x64/-/pack-darwin-x64-1.5.0-alpha.2.tgz", + "integrity": "sha512-kyxuo2odyUSmhXZgFycT0eCOuXRmSLgWiMp8QYWyRDjtxRcPCsASDEawxfR4jcUdTrtsMF7RBepRx4m0rxm9eA==", "cpu": [ "x64" ], @@ -8870,9 +8870,9 @@ } }, "node_modules/@utoo/pack-linux-arm64-gnu": { - "version": "1.4.32", - "resolved": "https://registry.npmjs.org/@utoo/pack-linux-arm64-gnu/-/pack-linux-arm64-gnu-1.4.32.tgz", - "integrity": "sha512-v8Wmo/B/jDQa7JZU1LyWRH1pqKRuDwvhMvb8QN3TapQKlzUCflWoB83+H66L4W3P5HmJYhNwaqIipYrEGmJgsA==", + "version": "1.5.0-alpha.2", + "resolved": "https://registry.npmjs.org/@utoo/pack-linux-arm64-gnu/-/pack-linux-arm64-gnu-1.5.0-alpha.2.tgz", + "integrity": "sha512-EorBbAH/wv9+pB9Y7tUOeoXIsoMzbMJGz2UHz9SZwnpUxZTu87k4V513GbOlicUpsI7TLqXTIKO4V3l+7IncMQ==", "cpu": [ "arm64" ], @@ -8886,9 +8886,9 @@ } }, "node_modules/@utoo/pack-linux-arm64-musl": { - "version": "1.4.32", - "resolved": "https://registry.npmjs.org/@utoo/pack-linux-arm64-musl/-/pack-linux-arm64-musl-1.4.32.tgz", - "integrity": "sha512-d/sbIWhfJZAwIbO/2LBTVwrNK0IctMBezJGnMXGV2bxupzI9d1W/I8lYLszQyKdtadTJ1hIFRuqOsSrKnA9EnA==", + "version": "1.5.0-alpha.2", + "resolved": "https://registry.npmjs.org/@utoo/pack-linux-arm64-musl/-/pack-linux-arm64-musl-1.5.0-alpha.2.tgz", + "integrity": "sha512-oZX14sfugT9UrhUzU6aI2bFIE/dlm6JA7pn9N1KhHmsrxF4xJy2wxEGbntNkwXZPjGb2z/SGUzdHYNg6zVojtw==", "cpu": [ "arm64" ], @@ -8902,9 +8902,9 @@ } }, "node_modules/@utoo/pack-linux-x64-gnu": { - "version": "1.4.32", - "resolved": "https://registry.npmjs.org/@utoo/pack-linux-x64-gnu/-/pack-linux-x64-gnu-1.4.32.tgz", - "integrity": "sha512-lfB35mbkxQmmVbf5bhCyR4yHxYWXRp2YMktbqEhixKZh0dw5MRvdJLijNFoefo6hg8YyauaX8T88eYwpLwC8jA==", + "version": "1.5.0-alpha.2", + "resolved": "https://registry.npmjs.org/@utoo/pack-linux-x64-gnu/-/pack-linux-x64-gnu-1.5.0-alpha.2.tgz", + "integrity": "sha512-1YBmbjxx48SyrNCUt8mxFWxktRgfhOk4oTblPBX9wzjJi3fIxTZp4Pj3I5qw3fiAXBYZSSEr40jLhLtHDSpziw==", "cpu": [ "x64" ], @@ -8918,9 +8918,9 @@ } }, "node_modules/@utoo/pack-linux-x64-musl": { - "version": "1.4.32", - "resolved": "https://registry.npmjs.org/@utoo/pack-linux-x64-musl/-/pack-linux-x64-musl-1.4.32.tgz", - "integrity": "sha512-Hexn23o8sq3KW3gYg/ut+PynXZ2jdE/bM1OeW5JzgAqq7u4SIYJvoniR/PBQlbOlZQv/rJMmlAhfastKh6yw1A==", + "version": "1.5.0-alpha.2", + "resolved": "https://registry.npmjs.org/@utoo/pack-linux-x64-musl/-/pack-linux-x64-musl-1.5.0-alpha.2.tgz", + "integrity": "sha512-o6QQowD8Y+vEeJqr26Df/ewDrJpk2GYnhsQ8UuyAfLbjlbqpk+qLlsgFzhW9s7zpr4bPY+FHo5iMzdcyNmJYNA==", "cpu": [ "x64" ], @@ -8934,9 +8934,9 @@ } }, "node_modules/@utoo/pack-shared": { - "version": "1.4.32", - "resolved": "https://registry.npmjs.org/@utoo/pack-shared/-/pack-shared-1.4.32.tgz", - "integrity": "sha512-fg2cv242CUndwqatCMbbcI/gNaRAYuficWk5oE34Fc4pjTXPYV/iZhgzToLKVnFMkxTRTOiaVNcjHAJCkUw/Wg==", + "version": "1.5.0-alpha.2", + "resolved": "https://registry.npmjs.org/@utoo/pack-shared/-/pack-shared-1.5.0-alpha.2.tgz", + "integrity": "sha512-FwFai1oQVG9sY9h7w0DLq2QnyfiwF+Qh0WUqNB2tRTswVIdf0A2FKp4sISgdeI3D1EZPgF4YDjLRG433ipszQQ==", "license": "MIT", "dependencies": { "@babel/code-frame": "7.22.5", @@ -8959,9 +8959,9 @@ } }, "node_modules/@utoo/pack-win32-x64-msvc": { - "version": "1.4.32", - "resolved": "https://registry.npmjs.org/@utoo/pack-win32-x64-msvc/-/pack-win32-x64-msvc-1.4.32.tgz", - "integrity": "sha512-cZem74qIpwqIOgm1HIUqep1Ca9VRH/aa9qIkouaT0MJzzC3Q5c2fqGBdyA7SSVJ2NrsdIYf8cqDPm6a9zGTxCw==", + "version": "1.5.0-alpha.2", + "resolved": "https://registry.npmjs.org/@utoo/pack-win32-x64-msvc/-/pack-win32-x64-msvc-1.5.0-alpha.2.tgz", + "integrity": "sha512-9ZWlM6WIdhrr157/XfZX9Rq2EowaNmxGKEfxlET4ZSSco4k0CYcBqPH0MOoStR8KDCkTIXVolBm1rhhxBEtRaw==", "cpu": [ "x64" ], @@ -24545,7 +24545,7 @@ "@evjs/ev": "*", "@evjs/shared": "*", "@logtape/logtape": "^2.0.4", - "@utoo/pack": "^1.4.32", + "@utoo/pack": "^1.5.0-alpha.2", "chokidar": "^3.6.0", "fast-glob": "^3.3.3", "less": "4.1.3", @@ -24578,16 +24578,16 @@ } }, "packages/bundler-utoopack/node_modules/@utoo/pack": { - "version": "1.4.32", - "resolved": "https://registry.npmjs.org/@utoo/pack/-/pack-1.4.32.tgz", - "integrity": "sha512-6ZtASwXg34SygLEt2tzZXLpoZ6R2YNy8vCJLeteAqK+WkGDV4xfNg1XjhQnf8z6FXy4wfpsH5/cNFAtuwSpzbA==", + "version": "1.5.0-alpha.2", + "resolved": "https://registry.npmjs.org/@utoo/pack/-/pack-1.5.0-alpha.2.tgz", + "integrity": "sha512-eIBh51642he2m6Gwo+t4VOzfXT+EH0xZKL+RH/S7mCcTgPvBxRtgOFsf4xS8GDYh3mozk/alcfKGCTx8YPwh9g==", "license": "MIT", "dependencies": { "@babel/code-frame": "7.22.5", "@hono/node-server": "^1.19.11", "@hono/node-ws": "^1.3.0", "@swc/helpers": "0.5.15", - "@utoo/pack-shared": "1.4.32", + "@utoo/pack-shared": "1.5.0-alpha.2", "domparser-rs": "^0.0.7", "find-up": "4.1.0", "get-port": "5.1.1", @@ -24602,13 +24602,13 @@ "node": ">= 20" }, "optionalDependencies": { - "@utoo/pack-darwin-arm64": "1.4.32", - "@utoo/pack-darwin-x64": "1.4.32", - "@utoo/pack-linux-arm64-gnu": "1.4.32", - "@utoo/pack-linux-arm64-musl": "1.4.32", - "@utoo/pack-linux-x64-gnu": "1.4.32", - "@utoo/pack-linux-x64-musl": "1.4.32", - "@utoo/pack-win32-x64-msvc": "1.4.32" + "@utoo/pack-darwin-arm64": "1.5.0-alpha.2", + "@utoo/pack-darwin-x64": "1.5.0-alpha.2", + "@utoo/pack-linux-arm64-gnu": "1.5.0-alpha.2", + "@utoo/pack-linux-arm64-musl": "1.5.0-alpha.2", + "@utoo/pack-linux-x64-gnu": "1.5.0-alpha.2", + "@utoo/pack-linux-x64-musl": "1.5.0-alpha.2", + "@utoo/pack-win32-x64-msvc": "1.5.0-alpha.2" }, "peerDependencies": { "less": "^4.0.0", diff --git a/packages/bundler-utoopack/package.json b/packages/bundler-utoopack/package.json index 070d71ad..7eb4a335 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.4.32", + "@utoo/pack": "^1.5.0-alpha.2", "chokidar": "^3.6.0", "fast-glob": "^3.3.3", "less": "4.1.3", diff --git a/packages/bundler-utoopack/src/adapter/create-config.ts b/packages/bundler-utoopack/src/adapter/create-config.ts index 57ad34de..f1490aaa 100644 --- a/packages/bundler-utoopack/src/adapter/create-config.ts +++ b/packages/bundler-utoopack/src/adapter/create-config.ts @@ -108,7 +108,7 @@ export async function createUtoopackConfig( ...(spaHistoryFallbackRule ? [spaHistoryFallbackRule] : []), ]; - const finalServerEntry = resolveServerEntry(plan); + const finalServerEntry = resolveServerEntries(plan); const outputPaths = resolveBuildOutputPaths(cwd, plan); await assertSafeBuildOutputPaths(cwd, outputPaths); @@ -258,17 +258,39 @@ export async function createUtoopackConfig( function assertUtoopackServerEntryMatchesPlan( config: ConfigComplete, - expected: string | undefined, + expected: UtoopackServerEntry | undefined, ): void { const actual = config.server?.entry; - if (actual === expected) return; + if (isSameUtoopackServerEntry(actual, expected)) return; throw new Error( `[evjs] Utoopack server.entry ${formatUtoopackServerEntry(actual)} must remain the exact framework-owned BuildPlan server.entry ${formatUtoopackServerEntry(expected)}. configureBundler hooks cannot override the framework server entry.`, ); } -function formatUtoopackServerEntry(value: string | undefined): string { +function isSameUtoopackServerEntry( + actual: UtoopackServerEntry | undefined, + expected: UtoopackServerEntry | undefined, +): boolean { + if (typeof actual === "string" || typeof expected === "string") { + return actual === expected; + } + if (actual === undefined || expected === undefined) { + return actual === expected; + } + return ( + actual.length === expected.length && + actual.every( + (entry, index) => + entry.name === expected[index]?.name && + entry.import === expected[index]?.import, + ) + ); +} + +function formatUtoopackServerEntry( + value: UtoopackServerEntry | undefined, +): string { return value === undefined ? "" : JSON.stringify(value); } @@ -551,9 +573,18 @@ function hasClientEntries(plan: BuildPlan): boolean { } function validateUtoopackPlanSupport(plan: BuildPlan): void { + const serverRuntimeEntries = plan.entries.filter( + (entry) => + entry.environment === "server" && entry.kind === "server-runtime", + ); + if (serverRuntimeEntries.length > 1) { + throw new Error( + `[evjs] The Utoopack adapter supports exactly one server-runtime entry; found ${serverRuntimeEntries.length}: ${serverRuntimeEntries.map((entry) => JSON.stringify(entry.name)).join(", ")}.`, + ); + } + const unsupportedServerEntries = plan.entries.filter( (entry) => - entry.kind === "page-server" || entry.kind === "rsc-page" || entry.kind === "ppr-shell" || entry.kind === "ppr-region", @@ -567,7 +598,7 @@ function validateUtoopackPlanSupport(plan: BuildPlan): void { ...new Set(unsupportedServerEntries.map((entry) => entry.kind)), ].join(", "); throw new Error( - `[evjs] The Utoopack adapter cannot build framework server page entries (${details}). Unsupported entry kinds: ${kinds}. Use a bundler adapter that supports multiple server entries for SSR/PPR/RSC validation.`, + `[evjs] The Utoopack adapter cannot build framework server entries (${details}). Unsupported entry kinds: ${kinds}. Use a bundler adapter that supports PPR/RSC validation.`, ); } @@ -592,9 +623,38 @@ function formatBuildEntryOwner( return parts.join(", ") || undefined; } -function resolveServerEntry(plan: BuildPlan): string | undefined { - const entry = plan.server.entry; - if (!entry) return undefined; +type UtoopackServerEntry = NonNullable< + NonNullable["entry"] +>; + +function resolveServerEntries( + plan: BuildPlan, +): UtoopackServerEntry | undefined { + const serverEntries = plan.entries.filter( + (entry) => + entry.environment === "server" && + (entry.kind === "server-runtime" || entry.kind === "page-server"), + ); + if (serverEntries.length === 0) return undefined; + + const serverRuntimeEntry = serverEntries.find( + (entry) => entry.kind === "server-runtime", + ); + if (serverEntries.length === 1 && serverRuntimeEntry) { + return resolveServerImport(serverRuntimeEntry.import); + } + + const orderedEntries = [ + ...(serverRuntimeEntry ? [serverRuntimeEntry] : []), + ...serverEntries.filter((entry) => entry !== serverRuntimeEntry), + ]; + return orderedEntries.map((entry) => ({ + name: entry.name, + import: resolveServerImport(entry.import), + })); +} + +function resolveServerImport(entry: string): string { if (entry.startsWith(".") || path.isAbsolute(entry)) return entry; return require.resolve(entry); } diff --git a/packages/bundler-utoopack/src/adapter/index.ts b/packages/bundler-utoopack/src/adapter/index.ts index f67c6a34..565c3051 100644 --- a/packages/bundler-utoopack/src/adapter/index.ts +++ b/packages/bundler-utoopack/src/adapter/index.ts @@ -264,7 +264,7 @@ export const utoopackAdapter: BundlerAdapter = { name: "utoopack", capabilities: { build: { - server: false, + server: true, rsc: false, ppr: false, }, diff --git a/packages/bundler-utoopack/src/manifest-generator.ts b/packages/bundler-utoopack/src/manifest-generator.ts index 7b0df66d..fe1b8f50 100644 --- a/packages/bundler-utoopack/src/manifest-generator.ts +++ b/packages/bundler-utoopack/src/manifest-generator.ts @@ -156,12 +156,13 @@ export class UtoopackManifestGenerator { ); } byName = projectNativeServerRuntimeEntrypoint(this.plan, byName); + byName = selectServerEntrypointAssets(this.plan, byName); this.serverEntryAssets = resolveBundlerServerEntryAssets( this.plan, byName, "Utoopack server stats", ); - assertExactServerJavaScriptInventory( + assertServerJavaScriptInventory( stats.assets, Object.values(this.serverEntryAssets).flatMap((assets) => assets.js), ); @@ -206,10 +207,10 @@ export class UtoopackManifestGenerator { } /** - * Utoopack's server API accepts one entry import but no entry name, so its - * stats expose that entry under a generated native name. Canonicalize only the - * one shape the adapter can identify without guessing: one planned runtime - * entry, owned by plan.server.entry, and one emitted stats entrypoint. + * Utoopack's legacy scalar server entry accepts one import but no entry name, + * so its stats expose that entry under a generated native name. Canonicalize + * only the one shape the adapter can identify without guessing: one planned + * runtime entry, owned by plan.server.entry, and one emitted stats entrypoint. */ function projectNativeServerRuntimeEntrypoint( plan: BuildPlan, @@ -295,6 +296,52 @@ function isRecord(value: unknown): value is Record { return Boolean(value) && typeof value === "object" && !Array.isArray(value); } +function selectServerEntrypointAssets( + plan: BuildPlan, + available: Record, +): Record { + const plannedNames = new Set( + plan.entries + .filter((entry) => entry.environment === "server") + .map((entry) => entry.name), + ); + const selected: Record = {}; + + for (const [name, assets] of Object.entries(available)) { + defineRecordValue( + selected, + name, + plannedNames.has(name) + ? { + js: [selectServerJavaScriptAsset(name, assets)], + css: [...assets.css], + } + : assets, + ); + } + + return selected; +} + +function selectServerJavaScriptAsset( + entryName: string, + assets: AssetGroup, +): string { + if (assets.js.length === 1) return assets.js[0] as string; + const candidates = assets.js.filter((asset) => + isNamedEntryAsset(entryName, asset), + ); + if (candidates.length === 1) return candidates[0] as string; + throw new Error( + `[evjs] Utoopack server stats entrypoint "${entryName}" must identify exactly one JavaScript entry asset; found ${assets.js.length} JavaScript assets and ${candidates.length} named candidates.`, + ); +} + +function isNamedEntryAsset(entryName: string, asset: string): boolean { + const fileName = asset.split("/").pop() ?? asset; + return fileName === `${entryName}.js` || fileName.startsWith(`${entryName}.`); +} + function readEmittedFiles(stats: { assets?: UtoopackStatsAsset[]; }): string[] | undefined { @@ -343,7 +390,7 @@ function defineRecordValue( }); } -function assertExactServerJavaScriptInventory( +function assertServerJavaScriptInventory( assets: UtoopackStatsAsset[] | undefined, ownedJavaScript: readonly string[], ): void { @@ -365,19 +412,12 @@ function assertExactServerJavaScriptInventory( ), ); } - const owned = new Set(ownedJavaScript); - for (const asset of owned) { + for (const asset of new Set(ownedJavaScript)) { if (emittedJavaScript.has(asset)) continue; throw new Error( `[evjs] Utoopack server stats are missing exact server entry JavaScript asset "${asset}" from the complete emitted inventory.`, ); } - for (const asset of emittedJavaScript) { - if (owned.has(asset)) continue; - throw new Error( - `[evjs] Utoopack server stats emitted unowned JavaScript asset "${asset}". Every server entry must be self-contained in its exact entry asset.`, - ); - } } function isJavaScriptAsset(name: string): boolean { diff --git a/packages/bundler-utoopack/tests/adapter.test.ts b/packages/bundler-utoopack/tests/adapter.test.ts index 870e503d..c740d8a3 100644 --- a/packages/bundler-utoopack/tests/adapter.test.ts +++ b/packages/bundler-utoopack/tests/adapter.test.ts @@ -153,16 +153,26 @@ const utoopackMock = vi.hoisted(() => ({ if (config.server) { const serverOutDir = config.server.output.path; await fs.promises.mkdir(serverOutDir, { recursive: true }); - await fs.promises.writeFile(path.join(serverOutDir, "server.js"), ""); + const serverEntryNames: string[] = Array.isArray(config.server.entry) + ? config.server.entry.map((entry: { name: string }) => entry.name) + : ["server"]; + await Promise.all( + serverEntryNames.map((name) => + fs.promises.writeFile(path.join(serverOutDir, `${name}.js`), ""), + ), + ); await fs.promises.writeFile( path.join(serverOutDir, "stats.json"), JSON.stringify({ - assets: [{ name: "server.js" }], - entrypoints: { - server: { - assets: [{ name: "server.js" }], - }, - }, + assets: serverEntryNames.map((name) => ({ + name: `${name}.js`, + })), + entrypoints: Object.fromEntries( + serverEntryNames.map((name) => [ + name, + { assets: [{ name: `${name}.js` }] }, + ]), + ), }), ); } @@ -966,6 +976,84 @@ describe("utoopackAdapter dev", () => { } }); + it("keeps all configured page-server entries after a server stats rebuild", async () => { + const cwd = await makeProject(); + const config = await resolveProjectConfig(cwd, { + output: { client: "dist/client", server: "dist/server" }, + routing: { mode: "spa" }, + }); + const baseContext = await createBuildContext(config, cwd); + const graph = structuredClone(baseContext.graph); + const page = graph.pages.index; + if (!page) throw new Error("Expected index Page."); + page.render = "ssr"; + const plan = createBuildPlan(config, graph, { mode: "development" }); + const onServerBundleReady = vi.fn(); + const onBuildOutput = vi.fn(); + const controller = await utoopackAdapter.dev({ + config, + cwd, + generation: createDevGeneration(), + plan, + callbacks: createFrameworkCallbacks({ + config, + cwd, + graph, + plan, + onBuildOutput, + onServerBundleReady, + }), + hooks: [], + }); + if (!controller) throw new Error("Expected Utoopack dev controller"); + + try { + expect(onBuildOutput.mock.calls[0]?.[0].server.renderers).toMatchObject({ + "page-server-index": { + kind: "page-server", + assets: { js: ["page-server-index.js"], css: [] }, + }, + }); + await fs.promises.writeFile( + path.join(cwd, "dist/server/stats.json"), + JSON.stringify({ + assets: [ + { name: "server.js" }, + { name: "page-server-index.updated.js" }, + { name: "server-shared.updated.js" }, + ], + entrypoints: { + server: { + assets: [ + { name: "server.js" }, + { name: "server-shared.updated.js" }, + ], + }, + "page-server-index": { + assets: [ + { name: "page-server-index.updated.js" }, + { name: "server-shared.updated.js" }, + ], + }, + }, + }), + ); + + await vi.waitFor(() => { + expect(onBuildOutput).toHaveBeenCalledTimes(2); + }); + expect(onServerBundleReady).toHaveBeenCalledTimes(2); + expect(onBuildOutput.mock.calls[1]?.[0].server.renderers).toMatchObject({ + "page-server-index": { + kind: "page-server", + assets: { js: ["page-server-index.updated.js"], css: [] }, + }, + }); + } finally { + await controller.close?.(); + } + }); + it("fails clearly for entry-changing dev plan updates", async () => { const cwd = await makeProject("home"); const config = await resolveProjectConfig(cwd, { diff --git a/packages/bundler-utoopack/tests/create-config.test.ts b/packages/bundler-utoopack/tests/create-config.test.ts index 9035cb34..8333e0d6 100644 --- a/packages/bundler-utoopack/tests/create-config.test.ts +++ b/packages/bundler-utoopack/tests/create-config.test.ts @@ -1402,7 +1402,7 @@ describe("createUtoopackConfig", () => { ); }); - it("fails clearly when the plan contains framework server renderer entries", async () => { + it("maps server-runtime and page-server entries to named Utoopack server entries", async () => { const config = createResolvedConfig({ server: { basePath: "/__evjs", @@ -1445,20 +1445,26 @@ describe("createUtoopackConfig", () => { }, ], }); - const message = await expectRejectedMessage(() => - createUtoopackConfig(config, plan, process.cwd(), []), + const utoopackConfig = await createUtoopackConfig( + config, + plan, + process.cwd(), + [], ); - expect(message).toContain( - "Utoopack adapter cannot build framework server page entries", - ); - expect(message).toContain( - 'page-server-dashboard (page-server, page "dashboard", route "dashboard")', - ); - expect(message).toContain("Unsupported entry kinds: page-server"); + expect(utoopackConfig.server?.entry).toEqual([ + { + name: "server", + import: require.resolve("@evjs/ev/_internal/server/fetch"), + }, + { + name: "page-server-dashboard", + import: "./src/pages/Dashboard.tsx", + }, + ]); }); - it("rejects framework server page entries without multi-entry support", async () => { + it("continues to reject unsupported RSC and PPR server entries", async () => { const config = createResolvedConfig({ server: { basePath: "/__evjs", @@ -1511,9 +1517,7 @@ describe("createUtoopackConfig", () => { createUtoopackConfig(config, plan, process.cwd(), []), ); - expect(message).toContain( - 'dashboard-server (page-server, page "dashboard")', - ); + expect(message).not.toContain("dashboard-server"); expect(message).toContain('insights-rsc (rsc-page, page "insights")'); expect(message).toContain( 'campaign-ppr-shell (ppr-shell, page "campaign")', @@ -1522,9 +1526,36 @@ describe("createUtoopackConfig", () => { 'campaign-offer-ppr-region (ppr-region, page "campaign", region "offer")', ); expect(message).toContain( - "Unsupported entry kinds: page-server, rsc-page, ppr-shell, ppr-region", + "Unsupported entry kinds: rsc-page, ppr-shell, ppr-region", + ); + expect(message).toContain("PPR/RSC validation"); + }); + + it("rejects multiple server-runtime entries", async () => { + const config = createResolvedConfig(); + const plan = await createPlan(config, { + serverRoutes: [ + { + id: "src/apis/health/api.ts:/health:GET", + module: "src/apis/health/api.ts", + path: "/health", + methods: ["GET"], + }, + ], + }); + plan.entries.push({ + name: "server-secondary", + import: "./src/server-secondary.ts", + environment: "server", + runtime: "node", + kind: "server-runtime", + }); + + await expect( + createUtoopackConfig(config, plan, process.cwd(), []), + ).rejects.toThrow( + 'Utoopack adapter supports exactly one server-runtime entry; found 2: "server", "server-secondary"', ); - expect(message).toContain("SSR/PPR/RSC validation"); }); }); diff --git a/packages/bundler-utoopack/tests/manifest-generator.test.ts b/packages/bundler-utoopack/tests/manifest-generator.test.ts index d6c32abb..d0f15861 100644 --- a/packages/bundler-utoopack/tests/manifest-generator.test.ts +++ b/packages/bundler-utoopack/tests/manifest-generator.test.ts @@ -282,7 +282,7 @@ describe("UtoopackManifestGenerator", () => { }); }); - it("rejects extra JavaScript emitted beside a native server runtime entry", async () => { + it("keeps shared JavaScript emitted beside a native server runtime entry", async () => { const cwd = await makeProject(); await fs.promises.writeFile( path.join(cwd, "dist/server/stats.json"), @@ -299,9 +299,14 @@ describe("UtoopackManifestGenerator", () => { await expect( new UtoopackManifestGenerator(cwd, createPlan(graph)).build(), - ).rejects.toThrow( - 'Utoopack server stats emitted unowned JavaScript asset "chunks/lazy.js"', - ); + ).resolves.toMatchObject({ + serverEntryAssets: { + server: { js: ["index.12345678.js"], css: [] }, + }, + emittedFiles: { + server: ["index.12345678.js", "chunks/lazy.js", "stats.json"], + }, + }); }); it("rejects a server entrypoint without a JavaScript asset", async () => { @@ -324,7 +329,7 @@ describe("UtoopackManifestGenerator", () => { await expect( new UtoopackManifestGenerator(cwd, plan).build(), ).rejects.toThrow( - 'Utoopack server stats entrypoint "server" must emit exactly one self-contained JavaScript asset', + 'Utoopack server stats entrypoint "server" must identify exactly one JavaScript entry asset; found 0 JavaScript assets and 0 named candidates', ); }); @@ -349,7 +354,149 @@ describe("UtoopackManifestGenerator", () => { await expect( new UtoopackManifestGenerator(cwd, createPlan(graph)).build(), ).rejects.toThrow( - 'Utoopack server stats entrypoint "server" must emit exactly one self-contained JavaScript asset; found 2', + 'Utoopack server stats entrypoint "server" must identify exactly one JavaScript entry asset; found 2 JavaScript assets and 0 named candidates', + ); + }); + + it("maps named server entries without exposing shared chunks as entry files", async () => { + const cwd = await makeProject(); + await fs.promises.writeFile( + path.join(cwd, "dist/server/stats.json"), + JSON.stringify({ + assets: [ + { name: "server.11111111.js" }, + { name: "page-server-dashboard.22222222.js" }, + { name: "page-server-detail.33333333.js" }, + { name: "server-shared.aaaaaaaa.js" }, + ], + entrypoints: { + server: { + assets: [ + { name: "server.11111111.js" }, + { name: "server-shared.aaaaaaaa.js" }, + ], + }, + "page-server-dashboard": { + assets: [ + { name: "page-server-dashboard.22222222.js" }, + { name: "server-shared.aaaaaaaa.js" }, + ], + }, + "page-server-detail": { + assets: [ + { name: "page-server-detail.33333333.js" }, + { name: "server-shared.aaaaaaaa.js" }, + ], + }, + }, + }), + ); + const graph = createGraph({ + cwd, + routingMode: "spa", + pages: [ + { + id: "dashboard", + routeId: "dashboard", + path: "/dashboard", + module: "./src/pages/dashboard/page.tsx", + render: "ssr", + }, + { + id: "detail", + routeId: "detail", + path: "/detail", + module: "./src/pages/detail/page.tsx", + render: "ssr", + }, + ], + }); + const plan = createPlan(graph); + const renderers = [ + { + name: "page-server-dashboard", + import: "./src/pages/dashboard/page.tsx", + kind: "page-server" as const, + owner: { pageId: "dashboard", routeId: "dashboard" }, + }, + { + name: "page-server-detail", + import: "./src/pages/detail/page.tsx", + kind: "page-server" as const, + owner: { pageId: "detail", routeId: "detail" }, + }, + ]; + plan.entries.splice( + plan.entries.length - 1, + 0, + ...renderers.map((renderer) => ({ + ...renderer, + environment: "server" as const, + runtime: "node" as const, + })), + ); + plan.server.renderers = renderers; + + const facts = await new UtoopackManifestGenerator(cwd, plan).build(); + const manifest = linkTestManifest(graph, plan, facts); + + expect(facts.serverEntryAssets).toEqual({ + server: { js: ["server.11111111.js"], css: [] }, + "page-server-dashboard": { + js: ["page-server-dashboard.22222222.js"], + css: [], + }, + "page-server-detail": { + js: ["page-server-detail.33333333.js"], + css: [], + }, + }); + expect(facts.emittedFiles?.server).toContain("server-shared.aaaaaaaa.js"); + expect(manifest.server.renderers).toMatchObject({ + "page-server-dashboard": { + kind: "page-server", + assets: { js: ["page-server-dashboard.22222222.js"], css: [] }, + }, + "page-server-detail": { + kind: "page-server", + assets: { js: ["page-server-detail.33333333.js"], css: [] }, + }, + }); + }); + + it("does not fall back to the runtime when a page-server entry is missing", async () => { + const cwd = await makeProject(); + const graph = createGraph({ + cwd, + routingMode: "spa", + pages: [ + { + id: "dashboard", + routeId: "dashboard", + path: "/dashboard", + module: "./src/pages/dashboard/page.tsx", + render: "ssr", + }, + ], + }); + const plan = createPlan(graph); + const renderer = { + name: "page-server-dashboard", + import: "./src/pages/dashboard/page.tsx", + kind: "page-server" as const, + owner: { pageId: "dashboard", routeId: "dashboard" }, + }; + plan.entries.splice(plan.entries.length - 1, 0, { + ...renderer, + environment: "server", + runtime: "node", + }); + plan.server.renderers = [renderer]; + + await expect( + new UtoopackManifestGenerator(cwd, plan).build(), + ).rejects.toThrow( + 'Utoopack server stats do not identify server BuildPlan entrypoint "page-server-dashboard" exactly', ); }); @@ -604,9 +751,14 @@ describe("UtoopackManifestGenerator", () => { }, }), ); - await expect(generator.build()).rejects.toThrow( - 'Utoopack server stats emitted unowned JavaScript asset "chunks/server-lazy.js"', - ); + await expect(generator.build()).resolves.toMatchObject({ + serverEntryAssets: { + server: { js: ["server.js"], css: [] }, + }, + emittedFiles: { + server: ["server.js", "chunks/server-lazy.js", "stats.json"], + }, + }); }); it("reads stats from the build plan distDir", async () => { diff --git a/packages/bundler-utoopack/tests/multi-server-entry.test.ts b/packages/bundler-utoopack/tests/multi-server-entry.test.ts new file mode 100644 index 00000000..67527e5a --- /dev/null +++ b/packages/bundler-utoopack/tests/multi-server-entry.test.ts @@ -0,0 +1,235 @@ +import fs from "node:fs"; +import { createRequire } from "node:module"; +import path from "node:path"; +import type { BuildPlan } from "@evjs/shared/manifest"; +import { build as utoopackBuild } from "@utoo/pack"; +import { afterEach, describe, expect, it } from "vitest"; +import { createUtoopackConfig } from "../src/adapter/create-config.js"; +import { runUtoopackBuild } from "../src/adapter/runtime.js"; +import { UtoopackManifestGenerator } from "../src/manifest-generator.js"; + +const require = createRequire(import.meta.url); +const tempDirs: string[] = []; + +afterEach(async () => { + await Promise.all( + tempDirs + .splice(0) + .map((dir) => fs.promises.rm(dir, { recursive: true, force: true })), + ); +}); + +describe("Utoopack multi-server entries", () => { + it("builds independently loadable runtime and page entries with shared chunks", async () => { + const cwd = await fs.promises.mkdtemp( + path.join(process.cwd(), ".tmp-utoopack-multi-server-"), + ); + tempDirs.push(cwd); + await writeFixture(cwd); + const plan = createPlan(); + const config = await createUtoopackConfig( + createResolvedConfig(), + plan, + cwd, + [], + ); + + expect(config.server?.entry).toEqual([ + { name: "server", import: "./src/server.ts" }, + { + name: "page-server-dashboard", + import: "./src/dashboard.server.ts", + }, + { + name: "page-server-detail", + import: "./src/detail.server.ts", + }, + ]); + + await runUtoopackBuild({ build: utoopackBuild }, config, cwd); + await fs.promises.writeFile( + path.join(cwd, "dist/server/package.json"), + JSON.stringify({ type: "commonjs" }), + ); + + const facts = await new UtoopackManifestGenerator(cwd, plan).build(); + const stats = JSON.parse( + await fs.promises.readFile( + path.join(cwd, "dist/server/stats.json"), + "utf-8", + ), + ) as { + entrypoints: Record }>; + }; + + expect(Object.keys(stats.entrypoints).sort()).toEqual([ + "page-server-dashboard", + "page-server-detail", + "server", + ]); + expect(Object.keys(facts.serverEntryAssets ?? {}).sort()).toEqual([ + "page-server-dashboard", + "page-server-detail", + "server", + ]); + + const sharedAssets = [ + ...new Set( + Object.values(stats.entrypoints) + .flatMap((entrypoint) => entrypoint.assets) + .map((asset) => asset.name.replace(/^\.\//, "")) + .filter((asset) => /(?:^|\/)server-shared(?:[-.])/.test(asset)), + ), + ]; + expect(sharedAssets.length).toBeGreaterThan(0); + await expect( + Promise.all( + sharedAssets.map((asset) => + fs.promises.access(path.join(cwd, "dist/server", asset)), + ), + ), + ).resolves.toBeDefined(); + + for (const entry of Object.values(facts.serverEntryAssets ?? {})) { + const entryAsset = entry.js[0]; + expect(entryAsset).toBeDefined(); + expect(() => + require(path.join(cwd, "dist/server", entryAsset as string)), + ).not.toThrow(); + } + }, 120_000); +}); + +function createResolvedConfig(): Parameters[0] { + return { + conventions: true, + routing: { + mode: "spa", + html: "./index.html", + mount: "#app", + routes: [], + }, + output: { + client: "dist/client", + server: "dist/server", + crossOriginLoading: "anonymous", + }, + dev: { + port: 41234, + https: false, + proxy: [], + }, + server: { + basePath: "/__evjs", + runtime: { + basePath: "/__evjs", + fn: "__evjs/fn", + ppr: "__evjs/ppr", + }, + dev: { + port: 3001, + https: false, + }, + }, + transport: {}, + plugins: [], + }; +} + +function createPlan(): BuildPlan { + const renderers = [ + { + name: "page-server-dashboard", + import: "./src/dashboard.server.ts", + kind: "page-server" as const, + owner: { pageId: "dashboard", routeId: "dashboard" }, + }, + { + name: "page-server-detail", + import: "./src/detail.server.ts", + kind: "page-server" as const, + owner: { pageId: "detail", routeId: "detail" }, + }, + ]; + return { + version: 1, + buildId: "multi-server-test", + mode: "production", + distDir: "dist", + output: { + clientDir: "dist/client", + serverDir: "dist/server", + }, + entries: [ + { + name: "main", + import: "./src/client.ts", + environment: "client", + runtime: "browser", + kind: "app-client", + owner: { appId: "default" }, + }, + ...renderers.map((renderer) => ({ + ...renderer, + environment: "server" as const, + runtime: "node" as const, + })), + { + name: "server", + import: "./src/server.ts", + environment: "server", + runtime: "node", + kind: "server-runtime", + }, + ], + html: [], + server: { + entry: "./src/server.ts", + renderers, + }, + dev: { + clientRoutes: [], + serverRequestRoutePaths: [], + serverRenderedPagePaths: ["/dashboard", "/detail"], + hasPpr: false, + }, + runtime: { + publicPath: "auto", + server: { + basePath: "/__evjs", + fn: "__evjs/fn", + }, + }, + }; +} + +async function writeFixture(cwd: string): Promise { + const sourceDir = path.join(cwd, "src"); + await fs.promises.mkdir(sourceDir, { recursive: true }); + await Promise.all([ + fs.promises.writeFile( + path.join(sourceDir, "client.ts"), + 'console.log("client");\n', + ), + fs.promises.writeFile( + path.join(sourceDir, "shared-all.ts"), + 'export const sharedAll = "shared by every server entry";\n', + ), + fs.promises.writeFile( + path.join(sourceDir, "shared-primary.ts"), + 'import { sharedAll } from "./shared-all";\nexport const sharedPrimary = "primary " + sharedAll;\n', + ), + fs.promises.writeFile( + path.join(sourceDir, "server.ts"), + 'import { sharedPrimary } from "./shared-primary";\nconsole.log("server", sharedPrimary);\n', + ), + fs.promises.writeFile( + path.join(sourceDir, "dashboard.server.ts"), + 'import { sharedPrimary } from "./shared-primary";\nconsole.log("dashboard", sharedPrimary);\n', + ), + fs.promises.writeFile( + path.join(sourceDir, "detail.server.ts"), + 'import { sharedAll } from "./shared-all";\nconsole.log("detail", sharedAll);\n', + ), + ]); +} From a01cd141ff90e3d4ded843d492ce3b290cf5f59c Mon Sep 17 00:00:00 2001 From: zoomdong <1344492820@qq.com> Date: Mon, 3 Aug 2026 15:52:49 +0800 Subject: [PATCH 2/3] chore(utoopack): bump @utoo/pack to 1.5.0 --- package-lock.json | 72 +++++++++---------- packages/bundler-utoopack/package.json | 2 +- .../bundler-utoopack/tests/adapter.test.ts | 2 +- 3 files changed, 38 insertions(+), 38 deletions(-) diff --git a/package-lock.json b/package-lock.json index 840215c6..c4da7477 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8838,9 +8838,9 @@ } }, "node_modules/@utoo/pack-darwin-arm64": { - "version": "1.5.0-alpha.2", - "resolved": "https://registry.npmjs.org/@utoo/pack-darwin-arm64/-/pack-darwin-arm64-1.5.0-alpha.2.tgz", - "integrity": "sha512-A467C+wVcqfGXzYzmILNSqaDV5NVmchosJapok4m8Dxn1WcOW5Js0ivU3y3uu2K5W7rtSBc0I0bNLneH/ibc5A==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@utoo/pack-darwin-arm64/-/pack-darwin-arm64-1.5.0.tgz", + "integrity": "sha512-bymMwDKlAJYRDN4aL8PV3cykB/82eKrpmqvgvJg0t1aAJ/7ym6BoAzuTAUV4DG9ZP5sSlyKRJTUbup/Iu2QWTQ==", "cpu": [ "arm64" ], @@ -8854,9 +8854,9 @@ } }, "node_modules/@utoo/pack-darwin-x64": { - "version": "1.5.0-alpha.2", - "resolved": "https://registry.npmjs.org/@utoo/pack-darwin-x64/-/pack-darwin-x64-1.5.0-alpha.2.tgz", - "integrity": "sha512-kyxuo2odyUSmhXZgFycT0eCOuXRmSLgWiMp8QYWyRDjtxRcPCsASDEawxfR4jcUdTrtsMF7RBepRx4m0rxm9eA==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@utoo/pack-darwin-x64/-/pack-darwin-x64-1.5.0.tgz", + "integrity": "sha512-qH8/Y0cl47SMsQr6EcwsJPsjcB/ycBAeuDx4sdC5y8ZhYL5kJWNIzKENtPK7fWMCoFRcICMF+nK8ujvRWZll7Q==", "cpu": [ "x64" ], @@ -8870,9 +8870,9 @@ } }, "node_modules/@utoo/pack-linux-arm64-gnu": { - "version": "1.5.0-alpha.2", - "resolved": "https://registry.npmjs.org/@utoo/pack-linux-arm64-gnu/-/pack-linux-arm64-gnu-1.5.0-alpha.2.tgz", - "integrity": "sha512-EorBbAH/wv9+pB9Y7tUOeoXIsoMzbMJGz2UHz9SZwnpUxZTu87k4V513GbOlicUpsI7TLqXTIKO4V3l+7IncMQ==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@utoo/pack-linux-arm64-gnu/-/pack-linux-arm64-gnu-1.5.0.tgz", + "integrity": "sha512-06kznzqnv5yscFWJALr1KZQ6PsoNua/wkk3CygWFvS468lJM6eUDie3smSRkpbadnDkpWUGJMCGo0duYULmGng==", "cpu": [ "arm64" ], @@ -8886,9 +8886,9 @@ } }, "node_modules/@utoo/pack-linux-arm64-musl": { - "version": "1.5.0-alpha.2", - "resolved": "https://registry.npmjs.org/@utoo/pack-linux-arm64-musl/-/pack-linux-arm64-musl-1.5.0-alpha.2.tgz", - "integrity": "sha512-oZX14sfugT9UrhUzU6aI2bFIE/dlm6JA7pn9N1KhHmsrxF4xJy2wxEGbntNkwXZPjGb2z/SGUzdHYNg6zVojtw==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@utoo/pack-linux-arm64-musl/-/pack-linux-arm64-musl-1.5.0.tgz", + "integrity": "sha512-ENu7j6pwb/AIkPN0cgumbtlSAAhg5iZHh+3G7Fm3ZR0OIGpMBjhCEhUd5C9myM5sKtYcTNz66H725wuifuujyA==", "cpu": [ "arm64" ], @@ -8902,9 +8902,9 @@ } }, "node_modules/@utoo/pack-linux-x64-gnu": { - "version": "1.5.0-alpha.2", - "resolved": "https://registry.npmjs.org/@utoo/pack-linux-x64-gnu/-/pack-linux-x64-gnu-1.5.0-alpha.2.tgz", - "integrity": "sha512-1YBmbjxx48SyrNCUt8mxFWxktRgfhOk4oTblPBX9wzjJi3fIxTZp4Pj3I5qw3fiAXBYZSSEr40jLhLtHDSpziw==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@utoo/pack-linux-x64-gnu/-/pack-linux-x64-gnu-1.5.0.tgz", + "integrity": "sha512-dys9SrvU4L0RBPAQ+i8P9QbBGHKHAlUcXBdlBORrJeH5XIX47bbM8Kt26ORpvySNmV4aTvJ5d6mtkayR0Yz2GA==", "cpu": [ "x64" ], @@ -8918,9 +8918,9 @@ } }, "node_modules/@utoo/pack-linux-x64-musl": { - "version": "1.5.0-alpha.2", - "resolved": "https://registry.npmjs.org/@utoo/pack-linux-x64-musl/-/pack-linux-x64-musl-1.5.0-alpha.2.tgz", - "integrity": "sha512-o6QQowD8Y+vEeJqr26Df/ewDrJpk2GYnhsQ8UuyAfLbjlbqpk+qLlsgFzhW9s7zpr4bPY+FHo5iMzdcyNmJYNA==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@utoo/pack-linux-x64-musl/-/pack-linux-x64-musl-1.5.0.tgz", + "integrity": "sha512-LpR5lO+ebZhYI2liyXsFCEL6u54rl42voMnKxS+MlEf/FJMreeQ5XPUSWObx+YCPDdRaBjh9JkiSnaOFGMTD+A==", "cpu": [ "x64" ], @@ -8934,9 +8934,9 @@ } }, "node_modules/@utoo/pack-shared": { - "version": "1.5.0-alpha.2", - "resolved": "https://registry.npmjs.org/@utoo/pack-shared/-/pack-shared-1.5.0-alpha.2.tgz", - "integrity": "sha512-FwFai1oQVG9sY9h7w0DLq2QnyfiwF+Qh0WUqNB2tRTswVIdf0A2FKp4sISgdeI3D1EZPgF4YDjLRG433ipszQQ==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@utoo/pack-shared/-/pack-shared-1.5.0.tgz", + "integrity": "sha512-0kbGUf82T7GCaTqHzsvR8d6l2F25VZ552kZdp4+8zz0qAXydjRMW3mQrzfj6UUIFDoN1M8g+O297XO3mMJ7kGw==", "license": "MIT", "dependencies": { "@babel/code-frame": "7.22.5", @@ -8959,9 +8959,9 @@ } }, "node_modules/@utoo/pack-win32-x64-msvc": { - "version": "1.5.0-alpha.2", - "resolved": "https://registry.npmjs.org/@utoo/pack-win32-x64-msvc/-/pack-win32-x64-msvc-1.5.0-alpha.2.tgz", - "integrity": "sha512-9ZWlM6WIdhrr157/XfZX9Rq2EowaNmxGKEfxlET4ZSSco4k0CYcBqPH0MOoStR8KDCkTIXVolBm1rhhxBEtRaw==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@utoo/pack-win32-x64-msvc/-/pack-win32-x64-msvc-1.5.0.tgz", + "integrity": "sha512-YQ8IpsQUAlVtey+6jwv2zmm+FBg9NgdidpR+Mx1o86fMY3T20o2ZWybQjVa7xwU2Sz/XqGH5e6sKKjZXSsVvZQ==", "cpu": [ "x64" ], @@ -24545,7 +24545,7 @@ "@evjs/ev": "*", "@evjs/shared": "*", "@logtape/logtape": "^2.0.4", - "@utoo/pack": "^1.5.0-alpha.2", + "@utoo/pack": "^1.5.0", "chokidar": "^3.6.0", "fast-glob": "^3.3.3", "less": "4.1.3", @@ -24578,16 +24578,16 @@ } }, "packages/bundler-utoopack/node_modules/@utoo/pack": { - "version": "1.5.0-alpha.2", - "resolved": "https://registry.npmjs.org/@utoo/pack/-/pack-1.5.0-alpha.2.tgz", - "integrity": "sha512-eIBh51642he2m6Gwo+t4VOzfXT+EH0xZKL+RH/S7mCcTgPvBxRtgOFsf4xS8GDYh3mozk/alcfKGCTx8YPwh9g==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@utoo/pack/-/pack-1.5.0.tgz", + "integrity": "sha512-1hLRitEju/parkZdI8LUauz7tccVoNf6Dp8X63l5pJuYUlBG1xnBekryAoTMf4dbLPO4FaOEPkuAQxaLOgH1AQ==", "license": "MIT", "dependencies": { "@babel/code-frame": "7.22.5", "@hono/node-server": "^1.19.11", "@hono/node-ws": "^1.3.0", "@swc/helpers": "0.5.15", - "@utoo/pack-shared": "1.5.0-alpha.2", + "@utoo/pack-shared": "1.5.0", "domparser-rs": "^0.0.7", "find-up": "4.1.0", "get-port": "5.1.1", @@ -24602,13 +24602,13 @@ "node": ">= 20" }, "optionalDependencies": { - "@utoo/pack-darwin-arm64": "1.5.0-alpha.2", - "@utoo/pack-darwin-x64": "1.5.0-alpha.2", - "@utoo/pack-linux-arm64-gnu": "1.5.0-alpha.2", - "@utoo/pack-linux-arm64-musl": "1.5.0-alpha.2", - "@utoo/pack-linux-x64-gnu": "1.5.0-alpha.2", - "@utoo/pack-linux-x64-musl": "1.5.0-alpha.2", - "@utoo/pack-win32-x64-msvc": "1.5.0-alpha.2" + "@utoo/pack-darwin-arm64": "1.5.0", + "@utoo/pack-darwin-x64": "1.5.0", + "@utoo/pack-linux-arm64-gnu": "1.5.0", + "@utoo/pack-linux-arm64-musl": "1.5.0", + "@utoo/pack-linux-x64-gnu": "1.5.0", + "@utoo/pack-linux-x64-musl": "1.5.0", + "@utoo/pack-win32-x64-msvc": "1.5.0" }, "peerDependencies": { "less": "^4.0.0", diff --git a/packages/bundler-utoopack/package.json b/packages/bundler-utoopack/package.json index 7eb4a335..a6d4b5e0 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.0-alpha.2", + "@utoo/pack": "^1.5.0", "chokidar": "^3.6.0", "fast-glob": "^3.3.3", "less": "4.1.3", diff --git a/packages/bundler-utoopack/tests/adapter.test.ts b/packages/bundler-utoopack/tests/adapter.test.ts index c740d8a3..0ba799a6 100644 --- a/packages/bundler-utoopack/tests/adapter.test.ts +++ b/packages/bundler-utoopack/tests/adapter.test.ts @@ -1041,8 +1041,8 @@ describe("utoopackAdapter dev", () => { await vi.waitFor(() => { expect(onBuildOutput).toHaveBeenCalledTimes(2); + expect(onServerBundleReady).toHaveBeenCalledTimes(2); }); - expect(onServerBundleReady).toHaveBeenCalledTimes(2); expect(onBuildOutput.mock.calls[1]?.[0].server.renderers).toMatchObject({ "page-server-index": { kind: "page-server", From c8f1ee691b9ce198f4bc37192a7a03f664c00f3c Mon Sep 17 00:00:00 2001 From: zoomdong <1344492820@qq.com> Date: Mon, 3 Aug 2026 16:15:24 +0800 Subject: [PATCH 3/3] fix(utoopack): address multi-server review feedback --- .../src/adapter/create-config.ts | 11 ++++-- .../src/manifest-generator.ts | 30 +++++++++++++--- .../bundler-utoopack/tests/adapter.test.ts | 11 +++--- .../tests/create-config.test.ts | 35 +++++++++++++++++++ .../tests/manifest-generator.test.ts | 31 ++++++++++++++++ .../tests/multi-server-entry.test.ts | 29 +++++++++------ 6 files changed, 125 insertions(+), 22 deletions(-) diff --git a/packages/bundler-utoopack/src/adapter/create-config.ts b/packages/bundler-utoopack/src/adapter/create-config.ts index f1490aaa..afb436f8 100644 --- a/packages/bundler-utoopack/src/adapter/create-config.ts +++ b/packages/bundler-utoopack/src/adapter/create-config.ts @@ -109,6 +109,7 @@ export async function createUtoopackConfig( ]; const finalServerEntry = resolveServerEntries(plan); + const expectedServerEntry = snapshotUtoopackServerEntry(finalServerEntry); const outputPaths = resolveBuildOutputPaths(cwd, plan); await assertSafeBuildOutputPaths(cwd, outputPaths); @@ -225,7 +226,7 @@ export async function createUtoopackConfig( for (const h of hooks) { if (h.configureBundler) { await h.configureBundler(utoopackConfig, ctx); - assertUtoopackServerEntryMatchesPlan(utoopackConfig, finalServerEntry); + assertUtoopackServerEntryMatchesPlan(utoopackConfig, expectedServerEntry); assertUtoopackOutputPathsMatchPlan(cwd, utoopackConfig, outputPaths, { requireServerOutput: finalServerEntry !== undefined, }); @@ -235,7 +236,7 @@ export async function createUtoopackConfig( } } - assertUtoopackServerEntryMatchesPlan(utoopackConfig, finalServerEntry); + assertUtoopackServerEntryMatchesPlan(utoopackConfig, expectedServerEntry); assertUtoopackOutputPathsMatchPlan(cwd, utoopackConfig, outputPaths, { requireServerOutput: finalServerEntry !== undefined, }); @@ -627,6 +628,12 @@ type UtoopackServerEntry = NonNullable< NonNullable["entry"] >; +function snapshotUtoopackServerEntry( + entry: UtoopackServerEntry | undefined, +): UtoopackServerEntry | undefined { + return Array.isArray(entry) ? entry.map((item) => ({ ...item })) : entry; +} + function resolveServerEntries( plan: BuildPlan, ): UtoopackServerEntry | undefined { diff --git a/packages/bundler-utoopack/src/manifest-generator.ts b/packages/bundler-utoopack/src/manifest-generator.ts index fe1b8f50..5690b8eb 100644 --- a/packages/bundler-utoopack/src/manifest-generator.ts +++ b/packages/bundler-utoopack/src/manifest-generator.ts @@ -300,9 +300,20 @@ function selectServerEntrypointAssets( plan: BuildPlan, available: Record, ): Record { - const plannedNames = new Set( - plan.entries - .filter((entry) => entry.environment === "server") + const plannedEntries = plan.entries.filter( + (entry) => entry.environment === "server", + ); + const plannedNames = new Set(plannedEntries.map((entry) => entry.name)); + const hasPageServerEntry = plannedEntries.some( + (entry) => entry.kind === "page-server", + ); + const strictNamedEntryNames = new Set( + plannedEntries + .filter( + (entry) => + entry.kind === "page-server" || + (hasPageServerEntry && entry.kind === "server-runtime"), + ) .map((entry) => entry.name), ); const selected: Record = {}; @@ -313,7 +324,13 @@ function selectServerEntrypointAssets( name, plannedNames.has(name) ? { - js: [selectServerJavaScriptAsset(name, assets)], + js: [ + selectServerJavaScriptAsset( + name, + assets, + !strictNamedEntryNames.has(name), + ), + ], css: [...assets.css], } : assets, @@ -326,12 +343,15 @@ function selectServerEntrypointAssets( function selectServerJavaScriptAsset( entryName: string, assets: AssetGroup, + allowUnmatchedSingleAsset: boolean, ): string { - if (assets.js.length === 1) return assets.js[0] as string; const candidates = assets.js.filter((asset) => isNamedEntryAsset(entryName, asset), ); if (candidates.length === 1) return candidates[0] as string; + if (allowUnmatchedSingleAsset && assets.js.length === 1) { + return assets.js[0] as string; + } throw new Error( `[evjs] Utoopack server stats entrypoint "${entryName}" must identify exactly one JavaScript entry asset; found ${assets.js.length} JavaScript assets and ${candidates.length} named candidates.`, ); diff --git a/packages/bundler-utoopack/tests/adapter.test.ts b/packages/bundler-utoopack/tests/adapter.test.ts index 0ba799a6..a9df3ab6 100644 --- a/packages/bundler-utoopack/tests/adapter.test.ts +++ b/packages/bundler-utoopack/tests/adapter.test.ts @@ -1039,10 +1039,13 @@ describe("utoopackAdapter dev", () => { }), ); - await vi.waitFor(() => { - expect(onBuildOutput).toHaveBeenCalledTimes(2); - expect(onServerBundleReady).toHaveBeenCalledTimes(2); - }); + await vi.waitFor( + () => { + expect(onBuildOutput).toHaveBeenCalledTimes(2); + expect(onServerBundleReady).toHaveBeenCalledTimes(2); + }, + { timeout: 5_000 }, + ); expect(onBuildOutput.mock.calls[1]?.[0].server.renderers).toMatchObject({ "page-server-index": { kind: "page-server", diff --git a/packages/bundler-utoopack/tests/create-config.test.ts b/packages/bundler-utoopack/tests/create-config.test.ts index 8333e0d6..3c4f4d04 100644 --- a/packages/bundler-utoopack/tests/create-config.test.ts +++ b/packages/bundler-utoopack/tests/create-config.test.ts @@ -1283,6 +1283,41 @@ describe("createUtoopackConfig", () => { expect(events).toEqual(["mutate"]); }); + it("rejects in-place mutation of framework-owned server entries", async () => { + const config = createResolvedConfig(); + const plan = await createPlan(config, { + serverRoutes: [ + { + id: "src/apis/health/api.ts:/health:GET", + module: "src/apis/health/api.ts", + path: "/health", + methods: ["GET"], + }, + ], + }); + plan.entries.push({ + name: "page-server-dashboard", + import: "./src/pages/Dashboard.tsx", + environment: "server", + runtime: "node", + kind: "page-server", + owner: { pageId: "dashboard", routeId: "dashboard" }, + }); + + await expect( + createUtoopackConfig(config, plan, process.cwd(), [ + { + configureBundler(utoopackConfig) { + const entry = utoopackConfig.server?.entry; + if (Array.isArray(entry)) entry.splice(0, 1); + }, + }, + ]), + ).rejects.toThrow( + "configureBundler hooks cannot override the framework server entry", + ); + }); + it("preserves framework-owned server function runtimes", async () => { const cases: Array<{ expected: string; diff --git a/packages/bundler-utoopack/tests/manifest-generator.test.ts b/packages/bundler-utoopack/tests/manifest-generator.test.ts index d0f15861..b9f8482a 100644 --- a/packages/bundler-utoopack/tests/manifest-generator.test.ts +++ b/packages/bundler-utoopack/tests/manifest-generator.test.ts @@ -358,6 +358,37 @@ describe("UtoopackManifestGenerator", () => { ); }); + it("rejects a mismatched sole asset for a named server entry", async () => { + const cwd = await makeProject(); + await fs.promises.writeFile( + path.join(cwd, "dist/server/stats.json"), + JSON.stringify({ + entrypoints: { + server: { assets: [{ name: "server.js" }] }, + "page-server-dashboard": { + assets: [{ name: "server-shared.js" }], + }, + }, + }), + ); + const graph = createGraph({ cwd, routingMode: "spa", pages: [] }); + const plan = createPlan(graph); + plan.entries.splice(plan.entries.length - 1, 0, { + name: "page-server-dashboard", + import: "./src/pages/dashboard/page.tsx", + environment: "server", + runtime: "node", + kind: "page-server", + owner: { pageId: "dashboard", routeId: "dashboard" }, + }); + + await expect( + new UtoopackManifestGenerator(cwd, plan).build(), + ).rejects.toThrow( + 'Utoopack server stats entrypoint "page-server-dashboard" must identify exactly one JavaScript entry asset; found 1 JavaScript assets and 0 named candidates', + ); + }); + it("maps named server entries without exposing shared chunks as entry files", async () => { const cwd = await makeProject(); await fs.promises.writeFile( diff --git a/packages/bundler-utoopack/tests/multi-server-entry.test.ts b/packages/bundler-utoopack/tests/multi-server-entry.test.ts index 67527e5a..dfa2eb7b 100644 --- a/packages/bundler-utoopack/tests/multi-server-entry.test.ts +++ b/packages/bundler-utoopack/tests/multi-server-entry.test.ts @@ -73,14 +73,21 @@ describe("Utoopack multi-server entries", () => { "server", ]); - const sharedAssets = [ - ...new Set( - Object.values(stats.entrypoints) - .flatMap((entrypoint) => entrypoint.assets) - .map((asset) => asset.name.replace(/^\.\//, "")) - .filter((asset) => /(?:^|\/)server-shared(?:[-.])/.test(asset)), - ), - ]; + const assetReferenceCounts = new Map(); + for (const entrypoint of Object.values(stats.entrypoints)) { + const entrypointAssets = new Set( + entrypoint.assets.map((asset) => asset.name.replace(/^\.\//, "")), + ); + for (const asset of entrypointAssets) { + assetReferenceCounts.set( + asset, + (assetReferenceCounts.get(asset) ?? 0) + 1, + ); + } + } + const sharedAssets = [...assetReferenceCounts] + .filter(([, references]) => references > 1) + .map(([asset]) => asset); expect(sharedAssets.length).toBeGreaterThan(0); await expect( Promise.all( @@ -221,15 +228,15 @@ async function writeFixture(cwd: string): Promise { ), fs.promises.writeFile( path.join(sourceDir, "server.ts"), - 'import { sharedPrimary } from "./shared-primary";\nconsole.log("server", sharedPrimary);\n', + 'import { sharedPrimary } from "./shared-primary";\nexport const serverEntry = sharedPrimary;\n', ), fs.promises.writeFile( path.join(sourceDir, "dashboard.server.ts"), - 'import { sharedPrimary } from "./shared-primary";\nconsole.log("dashboard", sharedPrimary);\n', + 'import { sharedPrimary } from "./shared-primary";\nexport const dashboardEntry = sharedPrimary;\n', ), fs.promises.writeFile( path.join(sourceDir, "detail.server.ts"), - 'import { sharedAll } from "./shared-all";\nconsole.log("detail", sharedAll);\n', + 'import { sharedAll } from "./shared-all";\nexport const detailEntry = sharedAll;\n', ), ]); }