diff --git a/.changeset/old-boxes-invite.md b/.changeset/old-boxes-invite.md new file mode 100644 index 000000000000..bbba9f632849 --- /dev/null +++ b/.changeset/old-boxes-invite.md @@ -0,0 +1,5 @@ +--- +'astro': patch +--- + +Fixes nondeterministic ordering of the server manifest's `assets` array, ensuring builds with identical inputs produce byte-identical output diff --git a/packages/astro/src/core/build/plugins/plugin-manifest.ts b/packages/astro/src/core/build/plugins/plugin-manifest.ts index 282d666e7001..af5acec6db1e 100644 --- a/packages/astro/src/core/build/plugins/plugin-manifest.ts +++ b/packages/astro/src/core/build/plugins/plugin-manifest.ts @@ -427,7 +427,7 @@ async function buildManifest( clientDirectives: Array.from(settings.clientDirectives), entryModules, inlinedScripts: Array.from(internals.inlinedScripts), - assets: staticFiles.map(prefixAssetPath), + assets: staticFiles.map(prefixAssetPath).sort(), i18n: i18nManifest, buildFormat: settings.config.build.format, checkOrigin: diff --git a/packages/astro/test/units/build/plugin-manifest.test.ts b/packages/astro/test/units/build/plugin-manifest.test.ts index e3ffb1043ebe..0e41cbec2f4d 100644 --- a/packages/astro/test/units/build/plugin-manifest.test.ts +++ b/packages/astro/test/units/build/plugin-manifest.test.ts @@ -41,6 +41,102 @@ function enableSsrMinification(): Plugin { } describe('Build: Manifest injection', () => { + it('serializes manifest assets in sorted order', async () => { + const root = new URL('./_temp-fixtures/', import.meta.url); + const rootPath = fileURLToPath(root); + + // Create public files in multiple directories to seed the assets array + const publicDir = path.join(rootPath, 'public'); + for (const dir of ['zeta', 'alpha', 'mu']) { + const dirPath = path.join(publicDir, dir); + await fs.mkdir(dirPath, { recursive: true }); + for (let i = 1; i <= 3; i++) { + await fs.writeFile(path.join(dirPath, `f${i}.txt`), `${dir} ${i}`); + } + } + + const settings = await createBasicSettings({ + root: rootPath, + output: 'server', + adapter: { + name: 'test-adapter', + hooks: { + 'astro:config:done': ({ setAdapter }) => { + setAdapter({ + name: 'test-adapter', + serverEntrypoint: 'astro/app', + exports: ['manifest', 'createApp'], + supportedAstroFeatures: { + serverOutput: 'stable', + }, + adapterFeatures: { + buildOutput: 'server', + }, + }); + }, + }, + }, + vite: { + plugins: [ + virtualAstroModules(root, { + 'src/pages/index.astro': [ + '---', + '---', + '', + 'Test', + '

Hello

', + '', + ].join('\n'), + }), + ], + }, + }); + + const routesList = { + routes: [ + parseRoute('index.astro', settings, { + component: 'src/pages/index.astro', + prerender: false, + }), + ], + }; + + process.env.ASTRO_KEY = 'eKBaVEuI7YjfanEXHuJe/pwZKKt3LkAHeMxvTU7aR0M='; + + try { + const builder = new AstroBuilder(settings, { + logger: defaultLogger, + mode: 'production', + runtimeMode: 'production', + routesList, + sync: false, + }); + await builder.run(); + } finally { + delete process.env.ASTRO_KEY; + // Clean up the public files + await fs.rm(publicDir, { recursive: true, force: true }); + } + + const serverOutputDir = fileURLToPath(settings.config.build.server); + const outputFiles = await readFilesRecursive(serverOutputDir); + + let assets: string[] | null = null; + for (const file of outputFiles) { + if (!file.endsWith('.mjs') && !file.endsWith('.js')) continue; + const content = await fs.readFile(file, 'utf-8'); + const match = /"assets":\[([^\]]*)\]/.exec(content); + if (match) { + assets = JSON.parse(`[${match[1]}]`); + break; + } + } + + assert.ok(assets, 'Should find the assets array in the manifest chunk'); + const sorted = [...assets].sort(); + assert.deepEqual(assets, sorted, 'Manifest assets should be in sorted order'); + }); + it('replaces manifest placeholder when server build is minified', async () => { const root = new URL('./_temp-fixtures/', import.meta.url);