diff --git a/package-lock.json b/package-lock.json index 8853161e..c4da7477 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", + "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.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", + "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.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", + "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.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", + "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.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", + "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.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", + "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.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", + "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.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", + "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.4.32", + "@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.4.32", - "resolved": "https://registry.npmjs.org/@utoo/pack/-/pack-1.4.32.tgz", - "integrity": "sha512-6ZtASwXg34SygLEt2tzZXLpoZ6R2YNy8vCJLeteAqK+WkGDV4xfNg1XjhQnf8z6FXy4wfpsH5/cNFAtuwSpzbA==", + "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.4.32", + "@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.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", + "@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 070d71ad..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.4.32", + "@utoo/pack": "^1.5.0", "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..afb436f8 100644 --- a/packages/bundler-utoopack/src/adapter/create-config.ts +++ b/packages/bundler-utoopack/src/adapter/create-config.ts @@ -108,7 +108,8 @@ export async function createUtoopackConfig( ...(spaHistoryFallbackRule ? [spaHistoryFallbackRule] : []), ]; - const finalServerEntry = resolveServerEntry(plan); + 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, }); @@ -258,17 +259,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 +574,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 +599,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 +624,44 @@ 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 snapshotUtoopackServerEntry( + entry: UtoopackServerEntry | undefined, +): UtoopackServerEntry | undefined { + return Array.isArray(entry) ? entry.map((item) => ({ ...item })) : 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..5690b8eb 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,72 @@ function isRecord(value: unknown): value is Record { return Boolean(value) && typeof value === "object" && !Array.isArray(value); } +function selectServerEntrypointAssets( + plan: BuildPlan, + available: Record, +): Record { + 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 = {}; + + for (const [name, assets] of Object.entries(available)) { + defineRecordValue( + selected, + name, + plannedNames.has(name) + ? { + js: [ + selectServerJavaScriptAsset( + name, + assets, + !strictNamedEntryNames.has(name), + ), + ], + css: [...assets.css], + } + : assets, + ); + } + + return selected; +} + +function selectServerJavaScriptAsset( + entryName: string, + assets: AssetGroup, + allowUnmatchedSingleAsset: boolean, +): 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.`, + ); +} + +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 +410,7 @@ function defineRecordValue( }); } -function assertExactServerJavaScriptInventory( +function assertServerJavaScriptInventory( assets: UtoopackStatsAsset[] | undefined, ownedJavaScript: readonly string[], ): void { @@ -365,19 +432,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..a9df3ab6 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,87 @@ 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); + }, + { timeout: 5_000 }, + ); + 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..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; @@ -1402,7 +1437,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 +1480,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 +1552,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 +1561,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..b9f8482a 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,180 @@ 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("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( + 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 +782,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..dfa2eb7b --- /dev/null +++ b/packages/bundler-utoopack/tests/multi-server-entry.test.ts @@ -0,0 +1,242 @@ +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 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( + 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";\nexport const serverEntry = sharedPrimary;\n', + ), + fs.promises.writeFile( + path.join(sourceDir, "dashboard.server.ts"), + 'import { sharedPrimary } from "./shared-primary";\nexport const dashboardEntry = sharedPrimary;\n', + ), + fs.promises.writeFile( + path.join(sourceDir, "detail.server.ts"), + 'import { sharedAll } from "./shared-all";\nexport const detailEntry = sharedAll;\n', + ), + ]); +}