From 116d5839720ac385c2fc3973f18a972ee0627621 Mon Sep 17 00:00:00 2001 From: backryun Date: Tue, 28 Jul 2026 05:24:23 +0900 Subject: [PATCH] refactor(sse): declare the ArrayBuffer backing on media byte producers (#8665) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six declarations spell a byte buffer as bare `Buffer` / `Uint8Array`. Without its type argument that widens to `ArrayBufferLike`, which also admits `SharedArrayBuffer` — so the value is rejected at every Web API boundary it is actually passed to: `BodyInit` for `new Response(...)` and `BlobPart` for `new Blob([...])`. Every one of them is already ArrayBuffer-backed at runtime. `hexToBytes()` allocates with `new Uint8Array(len)`; `synthesizeGtts()` and `pcmToWav()` return `Buffer.concat(...)`; `fetchRemoteImage()` returns `Buffer.from(await response.arrayBuffer())`; `readPageResponseBody()` returns `Buffer.from(body)`, which copies. The declarations were simply less specific than the values, so this states what the code already guarantees. Same fix #8533 applied to the multipart and gRPC-web bodies. Fixes 5 of the 208 `tsc -p open-sse/tsconfig.json` diagnostics with no new ones: 3 in audioSpeech.ts (MiniMax hex, gTTS, Vertex Gemini TTS), 1 in imageGeneration.ts (Topaz Blob upload) and 1 in browserBackedChat.ts. Refs #8484 --- open-sse/executors/gtts.ts | 2 +- open-sse/executors/vertexMedia.ts | 9 ++- open-sse/handlers/audioSpeech.ts | 4 +- open-sse/services/browserPool.ts | 2 +- src/shared/network/remoteImageFetch.ts | 2 +- .../media-body-arraybuffer-backing.test.ts | 80 +++++++++++++++++++ 6 files changed, 92 insertions(+), 7 deletions(-) create mode 100644 tests/unit/media-body-arraybuffer-backing.test.ts diff --git a/open-sse/executors/gtts.ts b/open-sse/executors/gtts.ts index a72c44b2f7..ad70feba9d 100644 --- a/open-sse/executors/gtts.ts +++ b/open-sse/executors/gtts.ts @@ -198,7 +198,7 @@ async function synthesizeGttsChunk( export async function synthesizeGtts( input: GttsSynthInput, fetchImpl: FetchLike = fetch -): Promise { +): Promise> { const lang = normalizeGttsLang(input.lang); const tld = (typeof input.tld === "string" && input.tld.trim()) || DEFAULT_TLD; const chunks = chunkGttsText(input.text); diff --git a/open-sse/executors/vertexMedia.ts b/open-sse/executors/vertexMedia.ts index 278230c1e1..c21becedcd 100644 --- a/open-sse/executors/vertexMedia.ts +++ b/open-sse/executors/vertexMedia.ts @@ -131,7 +131,12 @@ async function vertexError(res: Response): Promise { } /** Wrap raw little-endian 16-bit PCM mono samples in a minimal WAV container. */ -export function pcmToWav(pcm: Buffer, sampleRate = 24000, channels = 1, bitsPerSample = 16): Buffer { +export function pcmToWav( + pcm: Buffer, + sampleRate = 24000, + channels = 1, + bitsPerSample = 16 +): Buffer { const blockAlign = (channels * bitsPerSample) / 8; const byteRate = sampleRate * blockAlign; const header = Buffer.alloc(44); @@ -190,7 +195,7 @@ function extractText(data: unknown): string { export async function vertexGenerateSpeech( credentials: VertexMediaCredentials, options: { model: string; input: string; voice?: string } -): Promise<{ audio: Buffer; contentType: string }> { +): Promise<{ audio: Buffer; contentType: string }> { const auth = await resolveVertexAuth(credentials); const { url, headers } = buildModelRequest(auth, options.model, "generateContent"); const payload = { diff --git a/open-sse/handlers/audioSpeech.ts b/open-sse/handlers/audioSpeech.ts index 41e6ddf197..b931f6d543 100644 --- a/open-sse/handlers/audioSpeech.ts +++ b/open-sse/handlers/audioSpeech.ts @@ -618,7 +618,7 @@ async function handleXiaomiMimoSpeech(providerConfig, body, modelId, token, cred * `base_resp.status_code` (0 = success). * Port of decolua/9router#1043 by toanalien . */ -function hexToBytes(audioHex) { +function hexToBytes(audioHex): Uint8Array { const clean = typeof audioHex === "string" ? audioHex.trim() : ""; if (!clean) throw new Error("MiniMax TTS returned no audio"); if (clean.length % 2 !== 0 || !/^[0-9a-f]+$/i.test(clean)) { @@ -684,7 +684,7 @@ async function handleMinimaxSpeech(providerConfig, body, modelId, token) { } const audioField = (data.data as Record | undefined)?.audio; - let bytes: Uint8Array; + let bytes: Uint8Array; try { bytes = hexToBytes(audioField); } catch (err) { diff --git a/open-sse/services/browserPool.ts b/open-sse/services/browserPool.ts index 9c07f72ff5..205d771904 100644 --- a/open-sse/services/browserPool.ts +++ b/open-sse/services/browserPool.ts @@ -502,7 +502,7 @@ export function __resetBrowserPoolMetricsForTest(): void { export async function readPageResponseBody( response: import("playwright").Response -): Promise<{ status: number; headers: Record; body: Buffer }> { +): Promise<{ status: number; headers: Record; body: Buffer }> { const headers: Record = {}; for (const [name, value] of Object.entries(response.headers())) { headers[name] = value; diff --git a/src/shared/network/remoteImageFetch.ts b/src/shared/network/remoteImageFetch.ts index c58ec592a0..a77955b92a 100644 --- a/src/shared/network/remoteImageFetch.ts +++ b/src/shared/network/remoteImageFetch.ts @@ -39,7 +39,7 @@ export interface RemoteImageFetchOptions { } export interface RemoteImageFetchResult { - buffer: Buffer; + buffer: Buffer; contentType: string; url: string; } diff --git a/tests/unit/media-body-arraybuffer-backing.test.ts b/tests/unit/media-body-arraybuffer-backing.test.ts new file mode 100644 index 0000000000..c7874a87f2 --- /dev/null +++ b/tests/unit/media-body-arraybuffer-backing.test.ts @@ -0,0 +1,80 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { pcmToWav } from "../../open-sse/executors/vertexMedia.ts"; +import { synthesizeGtts } from "../../open-sse/executors/gtts.ts"; +import { readPageResponseBody } from "../../open-sse/services/browserPool.ts"; +import { fetchRemoteImage } from "@/shared/network/remoteImageFetch"; + +/** + * Each producer below is handed straight to a Web API that only accepts an + * `ArrayBuffer`-backed view — `new Response(body)` (BodyInit) or + * `new Blob([part])` (BlobPart). A `Buffer` / `Uint8Array` written without its + * type argument widens to `ArrayBufferLike`, which also admits + * `SharedArrayBuffer` and is therefore rejected at that boundary. + * + * The existing tests for these functions assert their *contents* — the RIFF + * header, the concatenated chunks, the decoded base64. None assert the backing, + * which is the single property the narrowed return types exist to guarantee and + * the one a plausible refactor to a shared or pooled allocation would break. + * + * So each case asserts both: the backing itself, and that the value really is + * accepted by the Web API it is passed to in production. + */ + +test("pcmToWav returns an ArrayBuffer-backed WAV that new Response accepts", () => { + const wav = pcmToWav(Buffer.from([0x01, 0x02, 0x03, 0x04]), 24000); + + assert.ok(wav.buffer instanceof ArrayBuffer, "WAV buffer must not be SharedArrayBuffer-backed"); + // open-sse/handlers/audioSpeech.ts hands this to `new Response(audio, …)`. + assert.doesNotThrow(() => new Response(wav)); +}); + +test("synthesizeGtts returns an ArrayBuffer-backed buffer that new Response accepts", async () => { + // Same batchexecute envelope shape gtts-provider.test.ts builds; "aGk=" is + // base64 for "hi". + const inner = JSON.stringify(["aGk=", null, null, null, null, null, []]); + const outer = JSON.stringify([["wrb.fr", "jQ1olc", inner, null, null, null, "generic"]]); + const fetchImpl = async () => + new Response(`)]}'\n\n${outer.length}\n${outer}\n`, { status: 200 }); + + const audio = await synthesizeGtts({ text: "hi", lang: "en" }, fetchImpl); + + assert.ok(audio.buffer instanceof ArrayBuffer, "gTTS audio must not be SharedArrayBuffer-backed"); + assert.doesNotThrow(() => new Response(audio)); +}); + +test("fetchRemoteImage returns an ArrayBuffer-backed buffer that new Blob accepts", async () => { + const result = await fetchRemoteImage("https://cdn.example.com/image.png", { + fetchImpl: async () => + new Response(new Uint8Array([1, 2, 3]), { + status: 200, + headers: { "content-type": "image/png" }, + }), + guard: "public-only", + lookup: async () => [{ address: "203.0.113.5", family: 4 }], + }); + + assert.ok(result.buffer.buffer instanceof ArrayBuffer, "image bytes must be ArrayBuffer-backed"); + // open-sse/handlers/imageGeneration.ts wraps this in `new Blob([…])` for the + // Topaz multipart upload. + assert.doesNotThrow(() => new Blob([result.buffer], { type: "image/png" })); +}); + +test("readPageResponseBody returns an ArrayBuffer-backed body", async () => { + const fakeResponse = { + status: () => 200, + headers: () => ({ "content-type": "application/json" }), + body: async () => Buffer.from('{"ok":true}'), + }; + + const captured = await readPageResponseBody( + fakeResponse as unknown as Parameters[0] + ); + + assert.equal(captured.status, 200); + assert.equal(captured.body.toString(), '{"ok":true}'); + // browserBackedChat.ts assigns this into a `Buffer.alloc(0)`-seeded local + // that it later returns as the response body. + assert.ok(captured.body.buffer instanceof ArrayBuffer); +});