Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/messenger-attachment-arraybuffer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@cloudflare/think": patch
---

Messenger attachments now load when a `chat` adapter's `fetchData` resolves to a plain `ArrayBuffer`, which `chat` releases inside Think's `^4.31.0` range allow. Previously the conversion read `Buffer`-only fields, so it threw on an `ArrayBuffer` and returned empty bytes for a `Buffer` backed by a `SharedArrayBuffer`.
24 changes: 16 additions & 8 deletions packages/think/src/messengers/chat-sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -852,14 +852,7 @@ export function toMessengerAttachment(
fetch: attachment.fetchData
? async () => {
const data = await attachment.fetchData?.();
if (!data) {
return new ArrayBuffer(0);
}
const copy = data.buffer.slice(
data.byteOffset,
data.byteOffset + data.byteLength
);
return copy instanceof ArrayBuffer ? copy : new ArrayBuffer(0);
return data ? attachmentBytes(data) : new ArrayBuffer(0);
}
: undefined,
fetchMetadata: fetchMetadata ? { ...fetchMetadata } : undefined,
Expand All @@ -872,6 +865,21 @@ export function toMessengerAttachment(
};
}

/**
* `chat` adapters resolve `fetchData` to a `Buffer` or, from `chat@4.41`
* (inside this package's `^4.31.0` range), a plain `ArrayBuffer`. A `Buffer` is
* a view that may share a pooled or `SharedArrayBuffer` backing store with
* unrelated bytes, so only its own range is copied out.
*/
function attachmentBytes(data: ArrayBuffer | ArrayBufferView): ArrayBuffer {
if (data instanceof ArrayBuffer) {
return data;
}
const copy = new Uint8Array(data.byteLength);
copy.set(new Uint8Array(data.buffer, data.byteOffset, data.byteLength));
return copy.buffer;
}

const FETCH_METADATA_ID_KEYS = ["id", "fileId", "mediaId", "fileUniqueId"];

/**
Expand Down
52 changes: 52 additions & 0 deletions packages/think/src/tests/messengers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -626,6 +626,58 @@ describe("think messengers core", () => {
expect(attachment.raw).toBeDefined();
});

describe("attachment fetch", () => {
function fetched(fetchData: () => Promise<unknown>) {
return toMessengerAttachment({
// `chat@4.31` types this as `Promise<Buffer>`; later releases inside
// the declared range widen it to `Promise<Buffer | ArrayBuffer>`.
fetchData: fetchData as () => Promise<Buffer>,
mimeType: "text/plain",
name: "hi.txt",
type: "file"
}).fetch?.();
}

it("copies only a Buffer view's own bytes out of its backing store", async () => {
const pool = new Uint8Array([0, 0, 104, 105, 0, 0]);
const data = await fetched(() =>
Promise.resolve(Buffer.from(pool.buffer, 2, 2))
);

expect(data).toBeInstanceOf(ArrayBuffer);
expect(new TextDecoder().decode(data)).toBe("hi");
pool[2] = 0;
expect(new TextDecoder().decode(data)).toBe("hi");
});

it("returns an ArrayBuffer from the adapter unchanged", async () => {
const bytes = new TextEncoder().encode("hello").buffer;
const data = await fetched(() => Promise.resolve(bytes));

expect(data).toBe(bytes);
expect(new TextDecoder().decode(data)).toBe("hello");
});

it.skipIf(typeof SharedArrayBuffer === "undefined")(
"copies a view backed by a SharedArrayBuffer instead of dropping it",
async () => {
const shared = new Uint8Array(new SharedArrayBuffer(3));
shared.set([104, 105, 33]);
const data = await fetched(() => Promise.resolve(shared));

expect(data).toBeInstanceOf(ArrayBuffer);
expect(new TextDecoder().decode(data)).toBe("hi!");
}
);

it("returns an empty ArrayBuffer when the adapter resolves nothing", async () => {
const data = await fetched(() => Promise.resolve(undefined));

expect(data).toBeInstanceOf(ArrayBuffer);
expect(data?.byteLength).toBe(0);
});
});

it("leaves attachment id undefined when fetchMetadata has no known id key", () => {
const attachment = toMessengerAttachment({
fetchMetadata: { region: "us-east" },
Expand Down
Loading