diff --git a/src/jsc/bindings/ModuleLoader.cpp b/src/jsc/bindings/ModuleLoader.cpp index 7c5ba80bb6a3..02bdc6e24900 100644 --- a/src/jsc/bindings/ModuleLoader.cpp +++ b/src/jsc/bindings/ModuleLoader.cpp @@ -301,7 +301,10 @@ OnLoadResult handleOnLoadResultNotPromise(Zig::GlobalObject* globalObject, JSC:: result.value.sourceText.value = contentsValue; } } else if (JSC::JSArrayBufferView* view = dynamicDowncast(contentsValue)) { - result.value.sourceText.string = ZigString { reinterpret_cast(view->vector()), view->byteLength() }; + // Raw bytes from a typed array are opaque to us: tag as UTF-8 so the + // Rust side's ZigString::to_slice() does not Latin-1 -> UTF-8 transcode + // bytes >= 0x80 (which would corrupt UTF-8 source and binary payloads). + result.value.sourceText.string = ZigString { Zig::taggedUTF8Ptr(reinterpret_cast(view->vector())), view->byteLength() }; result.value.sourceText.value = contentsValue; } } diff --git a/src/jsc/bindings/helpers.h b/src/jsc/bindings/helpers.h index cb9bc987755c..301bc3f5116c 100644 --- a/src/jsc/bindings/helpers.h +++ b/src/jsc/bindings/helpers.h @@ -268,6 +268,11 @@ static const unsigned char* taggedUTF16Ptr(const char16_t* ptr) return reinterpret_cast(reinterpret_cast(ptr) | (static_cast(1) << 63)); } +static const unsigned char* taggedUTF8Ptr(const unsigned char* ptr) +{ + return reinterpret_cast(reinterpret_cast(ptr) | (static_cast(1) << 61)); +} + static ZigString toZigString(WTF::String* str) { return str->isEmpty() diff --git a/test/js/bun/plugin/plugins.test.ts b/test/js/bun/plugin/plugins.test.ts index 2bedc9618eb9..6ff5edb14a20 100644 --- a/test/js/bun/plugin/plugins.test.ts +++ b/test/js/bun/plugin/plugins.test.ts @@ -196,6 +196,39 @@ plugin({ }, }); +plugin({ + name: "typed array contents", + setup(builder) { + // UTF-8 source with a non-ASCII literal. The UTF-8 bytes for "é" are + // [0xC3, 0xA9]; if those bytes were (wrongly) Latin-1 decoded and then + // re-encoded as UTF-8 the parser would see "é" instead. + const jsWithUtf8 = Buffer.from('export default "é";\n', "utf8"); + + builder.module("buffer-module-sync", () => ({ + contents: jsWithUtf8, + loader: "js", + })); + + builder.module("buffer-module-async", async () => { + await 1; + return { contents: new Uint8Array(jsWithUtf8), loader: "js" }; + }); + + builder.onResolve({ filter: /.*/, namespace: "buffer-contents" }, ({ path }) => ({ + path, + namespace: "buffer-contents", + })); + builder.onLoad({ filter: /^sync$/, namespace: "buffer-contents" }, () => ({ + contents: jsWithUtf8, + loader: "js", + })); + builder.onLoad({ filter: /^async$/, namespace: "buffer-contents" }, async () => { + await 1; + return { contents: jsWithUtf8, loader: "js" }; + }); + }, +}); + // This is to test that it works when imported from a separate file import { bunEnv, bunExe, tempDir } from "harness"; import { render as svelteRender } from "svelte/server"; @@ -303,6 +336,19 @@ describe("module", () => { }); }); +describe("typed array contents", () => { + it.each([ + ["build.module sync", "buffer-module-sync"], + ["build.module async", "buffer-module-async"], + ["onLoad sync", "buffer-contents:sync"], + ["onLoad async", "buffer-contents:async"], + ])("passes raw bytes through for %s", async (_, specifier) => { + const { default: value } = await import(specifier); + expect(value).toBe("é"); + expect([...Buffer.from(value, "utf8")]).toEqual([0xc3, 0xa9]); + }); +}); + describe("dynamic import", () => { it("SSRs `

Hello world!

` with Svelte", async () => { const { default: App }: any = await import("./hello.svelte");