diff --git a/.changeset/fix-fileblob-utf8-stream.md b/.changeset/fix-fileblob-utf8-stream.md new file mode 100644 index 000000000000..b4cfd2fc73f9 --- /dev/null +++ b/.changeset/fix-fileblob-utf8-stream.md @@ -0,0 +1,5 @@ +--- +'@vercel/build-utils': patch +--- + +Preserve 4-byte UTF-8 characters when streaming string `FileBlob` contents (fixes Edge Function corruption during `vercel build`). diff --git a/packages/build-utils/src/file-blob.ts b/packages/build-utils/src/file-blob.ts index dfc6509511b0..20f8496e3612 100644 --- a/packages/build-utils/src/file-blob.ts +++ b/packages/build-utils/src/file-blob.ts @@ -59,6 +59,14 @@ export default class FileBlob implements FileBase { } toStream(): NodeJS.ReadableStream { - return intoStream(this.data); + // Encode strings before streaming. into-stream@5 slices strings with + // String#slice at the ~16KiB highWaterMark; a UTF-16 surrogate pair + // (any 4-byte UTF-8 character) on that boundary is split and each half + // becomes U+FFFD when re-encoded, corrupting Edge Function bundles. + const data = + typeof this.data === 'string' + ? Buffer.from(this.data, 'utf8') + : this.data; + return intoStream(data); } } diff --git a/packages/build-utils/test/unit.download.test.ts b/packages/build-utils/test/unit.download.test.ts index fa2bc421e0d0..cd37dfec0574 100644 --- a/packages/build-utils/test/unit.download.test.ts +++ b/packages/build-utils/test/unit.download.test.ts @@ -280,6 +280,34 @@ describe('download()', () => { strictEqual(linkTarget, 'b.txt'); }); + it('should preserve 4-byte UTF-8 characters at the 16KiB stream boundary', async () => { + // into-stream@5 slices string FileBlob data at highWaterMark (~16384 + // UTF-16 code units). A surrogate pair starting at offset 16383 is + // split across chunks and each half becomes U+FFFD when encoded. + const astral = '\u{10437}'; + const source = `${'a'.repeat(16383)}${astral}b`; + const expected = Buffer.from(source, 'utf8'); + expect(expected.includes(Buffer.from('efbfbd', 'hex'))).toBe(false); + + const outDir = path.join(__dirname, 'utf8-stream-out'); + await fs.remove(outDir); + + await download( + { + 'index.js': new FileBlob({ + mode: S_IFREG, + contentType: 'application/javascript', + data: source, + }), + }, + outDir + ); + + const written = await fs.readFile(path.join(outDir, 'index.js')); + expect(written.equals(expected)).toBe(true); + expect(written.includes(Buffer.from('efbfbd', 'hex'))).toBe(false); + }); + it('should create empty directory entries', async () => { const outDir = path.join(__dirname, 'symlinks-out'); await fs.remove(outDir);