Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
103 changes: 103 additions & 0 deletions packages/wasm/src/patchWasmResponse.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
/**
* Streaming wasm registration (`instantiateStreaming` / `compileStreaming`) reads the module URL
* from `Response.url`. Non-streaming paths (`WebAssembly.instantiate` / `compile` with bytes) only
* receive a buffer — no URL — so registration would otherwise be skipped.
*
* This module patches `Response.prototype.arrayBuffer` and `bytes` so that when wasm is fetched
* and then loaded from bytes, we can map the resulting `ArrayBuffer` back to the fetch URL via
* `getWasmSourceUrl()` and register the module in `patchNonStreamingWebAssembly`.
*/
const wasmSourceUrls = new WeakMap<ArrayBuffer, string>();

const PATCHED_SYMBOL = Symbol.for('__sentryWasmPatched');

type MaybePatched = { [PATCHED_SYMBOL]?: boolean };

/**
* Resolves a wasm source buffer back to its fetch URL, when known.
*/
export function getWasmSourceUrl(source: BufferSource): string | undefined {
const buffer = toArrayBuffer(source);
if (!buffer) {
return undefined;
}

return wasmSourceUrls.get(buffer);
}

function toArrayBuffer(source: BufferSource): ArrayBuffer | undefined {
if (source instanceof ArrayBuffer) {
return source;
}

if (ArrayBuffer.isView(source)) {
const { buffer } = source;
return buffer instanceof ArrayBuffer ? buffer : undefined;
}

return undefined;
}

function looksLikeWasmResponse(response: Response): boolean {
const contentType = response.headers.get('content-type');
if (contentType?.includes('application/wasm')) {
return true;
}

const { url } = response;
return Boolean(url && /\.wasm(?:\?|#|$)/i.test(url));
}

function tagResponseBuffer(response: Response, buffer: ArrayBuffer): void {
if (looksLikeWasmResponse(response) && response.url) {
wasmSourceUrls.set(buffer, response.url);
}
}

/**
* Patches Response body readers so wasm bytes remember their fetch URL.
*/
export function patchWasmResponseBodyReaders(): void {
if (typeof Response === 'undefined') {
return;
}

const responseProto = Response.prototype as MaybePatched;
if (responseProto[PATCHED_SYMBOL]) {
return;
}

responseProto[PATCHED_SYMBOL] = true;

// oxlint-disable-next-line typescript/unbound-method
const origArrayBuffer: (this: Response) => Promise<ArrayBuffer> = Response.prototype.arrayBuffer;
Response.prototype.arrayBuffer = function arrayBuffer(this: Response): Promise<ArrayBuffer> {
const bufferPromise: Promise<ArrayBuffer> = origArrayBuffer.call(this);
return bufferPromise.then((buffer: ArrayBuffer) => {
tagResponseBuffer(this, buffer);
return buffer;
});
};

if ('bytes' in Response.prototype) {
// oxlint-disable-next-line typescript/unbound-method
const origBytes: (this: Response) => Promise<Uint8Array> = Response.prototype.bytes;
Response.prototype.bytes = function bytes(this: Response) {
const bytesPromise: Promise<Uint8Array> = origBytes.call(this);
return bytesPromise.then((bytes: Uint8Array) => {
const { buffer } = bytes;
if (buffer instanceof ArrayBuffer) {
tagResponseBuffer(this, buffer);
}
Comment thread
andreiborza marked this conversation as resolved.
Outdated
return bytes;
});
} as typeof Response.prototype.bytes;
}
}

/** @internal */
export function _resetResponsePatchForTests(): void {
if (typeof Response !== 'undefined') {
(Response.prototype as MaybePatched)[PATCHED_SYMBOL] = false;
}
}
67 changes: 66 additions & 1 deletion packages/wasm/src/patchWebAssembly.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
import { getWasmSourceUrl, patchWasmResponseBodyReaders } from './patchWasmResponse';

export type RegisterModuleCallback = (module: WebAssembly.Module, url: string) => void;

let nonStreamingPatched = false;

/**
* Patches the WebAssembly streaming APIs so that every compiled module gets
* registered as a debug image under the URL of the response it was compiled
* from.
*
* @param registerModule callback invoked for every successfully compiled module
*/
export function patchWebAssembly(registerModule: RegisterModuleCallback): void {
export function patchStreamingWebAssembly(registerModule: RegisterModuleCallback): void {
if ('instantiateStreaming' in WebAssembly) {
const origInstantiateStreaming = WebAssembly.instantiateStreaming as (
response: unknown,
Expand Down Expand Up @@ -56,3 +60,64 @@ function registerSafely(registerModule: RegisterModuleCallback, module: WebAssem
// a registration failure must never break the user's WebAssembly call
}
}

function registerFromBufferSource(
registerModule: RegisterModuleCallback,
module: WebAssembly.Module,
source: BufferSource,
): void {
const url = getWasmSourceUrl(source);
if (url) {
registerSafely(registerModule, module, url);
}
}

/**
* Patches the non-streaming web assembly runtime.
*/
function patchNonStreamingWebAssembly(registerModule: RegisterModuleCallback): void {
if (nonStreamingPatched) {
return;
}
Comment thread
andreiborza marked this conversation as resolved.
Comment thread
andreiborza marked this conversation as resolved.

nonStreamingPatched = true;

// Double-cast, because the overloaded native signature (buffer vs. module
// first argument) cannot be widened to a pass-through shape in one step.
const origInstantiate = WebAssembly.instantiate as unknown as (
source: unknown,
...rest: unknown[]
) => Promise<WebAssembly.WebAssemblyInstantiatedSource>;
WebAssembly.instantiate = function instantiate(source: BufferSource | WebAssembly.Module, ...rest: unknown[]) {
if (source instanceof WebAssembly.Module) {
return origInstantiate(source, ...rest);
}

return origInstantiate(source, ...rest).then(result => {
registerFromBufferSource(registerModule, result.module, source);
return result;
});
} as typeof WebAssembly.instantiate;

const origCompile = WebAssembly.compile as (source: unknown, ...rest: unknown[]) => Promise<WebAssembly.Module>;
WebAssembly.compile = function compile(source: BufferSource, ...rest: unknown[]): Promise<WebAssembly.Module> {
return origCompile(source, ...rest).then(module => {
registerFromBufferSource(registerModule, module, source);
return module;
});
};
}

/**
* Patches the web assembly runtime.
*/
export function patchWebAssembly(registerModule: RegisterModuleCallback): void {
patchWasmResponseBodyReaders();
patchNonStreamingWebAssembly(registerModule);
patchStreamingWebAssembly(registerModule);
}

/** @internal */
export function _resetNonStreamingPatchForTests(): void {
nonStreamingPatched = false;
}
144 changes: 138 additions & 6 deletions packages/wasm/test/patchWebAssembly.test.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,47 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { patchWebAssembly } from '../src/patchWebAssembly';
import { getImage, IMAGES, registerModule } from '../src/registry';
import { restoreWasmGlobals, saveWasmGlobals } from './wasmTestHelpers';

const RESPONSE = { url: 'http://localhost:8001/main.wasm' } as Response;
const MODULE = {} as WebAssembly.Module;

describe('patchWebAssembly()', () => {
const originalInstantiateStreaming = WebAssembly.instantiateStreaming;
const originalCompileStreaming = WebAssembly.compileStreaming;
const testDir = path.dirname(fileURLToPath(import.meta.url));
const SIMPLE_WASM_PATH = path.resolve(
testDir,
'../../../dev-packages/browser-integration-tests/suites/wasm/simple.wasm',
);

const WASM_URL = 'https://example.com/simple.wasm';

const WASM_IMPORTS = {
env: {
external_func: () => {},
},
};

async function loadWasmBytes(): Promise<Uint8Array> {
return new Uint8Array(fs.readFileSync(SIMPLE_WASM_PATH));
}

async function fetchWasmBytes(): Promise<ArrayBuffer> {
const bytes = await loadWasmBytes();
const response = new Response(bytes, {
headers: { 'Content-Type': 'application/wasm' },
});
Object.defineProperty(response, 'url', { value: WASM_URL });

return response.arrayBuffer();
}

describe('patchWebAssembly() streaming registration', () => {
const savedGlobals = saveWasmGlobals();

afterEach(() => {
WebAssembly.instantiateStreaming = originalInstantiateStreaming;
WebAssembly.compileStreaming = originalCompileStreaming;
restoreWasmGlobals(savedGlobals);
});

it('forwards every argument to instantiateStreaming and registers the module', async () => {
Expand Down Expand Up @@ -70,3 +101,104 @@ describe('patchWebAssembly()', () => {
await expect(WebAssembly.compileStreaming(RESPONSE)).resolves.toBe(MODULE);
});
});

describe('patchWebAssembly() non-streaming registration', () => {
const savedGlobals = saveWasmGlobals();

beforeAll(() => {
patchWebAssembly(registerModule);
});

afterAll(() => {
restoreWasmGlobals(savedGlobals);
});

beforeEach(() => {
IMAGES.length = 0;
});

it('registers modules loaded via fetch → arrayBuffer → instantiate', async () => {
const buffer = await fetchWasmBytes();

await WebAssembly.instantiate(buffer, WASM_IMPORTS);

expect(getImage(WASM_URL)).toBe(0);
expect(IMAGES[0]?.code_file).toBe(WASM_URL);
expect(IMAGES[0]?.code_id).toBe('0ba020cdd2444f7eafdd25999a8e9010');
});

it('registers modules loaded via fetch → arrayBuffer → Uint8Array → instantiate', async () => {
const buffer = await fetchWasmBytes();
const view = new Uint8Array(buffer);

await WebAssembly.instantiate(view, WASM_IMPORTS);

expect(getImage(WASM_URL)).toBe(0);
expect(IMAGES[0]?.code_file).toBe(WASM_URL);
});

it('registers modules loaded via fetch → arrayBuffer → compile', async () => {
const buffer = await fetchWasmBytes();

await WebAssembly.compile(buffer);

expect(getImage(WASM_URL)).toBe(0);
expect(IMAGES[0]?.code_file).toBe(WASM_URL);
});

it('does not register modules when the buffer has no tagged URL', async () => {
const bytes = await loadWasmBytes();

await WebAssembly.instantiate(bytes, WASM_IMPORTS);

expect(IMAGES).toHaveLength(0);
});
});
Comment thread
cursor[bot] marked this conversation as resolved.

describe('patchWebAssembly() non-streaming argument forwarding', () => {
const savedGlobals = saveWasmGlobals();

afterEach(() => {
restoreWasmGlobals(savedGlobals);
});

it('forwards every argument to instantiate', async () => {
const orig = vi.fn().mockResolvedValue({ module: MODULE, instance: {} });
WebAssembly.instantiate = orig as unknown as typeof WebAssembly.instantiate;

patchWebAssembly(registerModule);

const bytes = new Uint8Array(8);
const compileOptions = { builtins: ['js-string'] };
await (WebAssembly.instantiate as unknown as (...args: unknown[]) => Promise<unknown>)(
bytes,
WASM_IMPORTS,
compileOptions,
);

expect(orig).toHaveBeenCalledWith(bytes, WASM_IMPORTS, compileOptions);
});

it('forwards every argument to compile', async () => {
const orig = vi.fn().mockResolvedValue(MODULE);
WebAssembly.compile = orig as unknown as typeof WebAssembly.compile;

patchWebAssembly(registerModule);

const bytes = new Uint8Array(8);
const compileOptions = { builtins: ['js-string'] };
await (WebAssembly.compile as unknown as (...args: unknown[]) => Promise<unknown>)(bytes, compileOptions);

expect(orig).toHaveBeenCalledWith(bytes, compileOptions);
});

it('resolves the original result even if registration throws', async () => {
patchWebAssembly(() => {
throw new Error('registration failed');
});

const buffer = await fetchWasmBytes();

await expect(WebAssembly.compile(buffer)).resolves.toBeInstanceOf(WebAssembly.Module);
});
});
39 changes: 39 additions & 0 deletions packages/wasm/test/wasmTestHelpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { _resetResponsePatchForTests } from '../src/patchWasmResponse';
import { _resetNonStreamingPatchForTests } from '../src/patchWebAssembly';

export type SavedWasmGlobals = {
instantiate: typeof WebAssembly.instantiate;
compile: typeof WebAssembly.compile;
instantiateStreaming?: typeof WebAssembly.instantiateStreaming;
compileStreaming?: typeof WebAssembly.compileStreaming;
arrayBuffer: typeof Response.prototype.arrayBuffer;
bytes?: typeof Response.prototype.bytes;
};

export function saveWasmGlobals(): SavedWasmGlobals {
return {
instantiate: WebAssembly.instantiate,
compile: WebAssembly.compile,
instantiateStreaming: WebAssembly.instantiateStreaming,
compileStreaming: WebAssembly.compileStreaming,
arrayBuffer: Response.prototype.arrayBuffer,
bytes: 'bytes' in Response.prototype ? Response.prototype.bytes : undefined,
};
}

export function restoreWasmGlobals(saved: SavedWasmGlobals): void {
WebAssembly.instantiate = saved.instantiate;
WebAssembly.compile = saved.compile;
if (saved.instantiateStreaming) {
WebAssembly.instantiateStreaming = saved.instantiateStreaming;
}
if (saved.compileStreaming) {
WebAssembly.compileStreaming = saved.compileStreaming;
}
Response.prototype.arrayBuffer = saved.arrayBuffer;
if (saved.bytes) {
Response.prototype.bytes = saved.bytes;
}
_resetNonStreamingPatchForTests();
_resetResponsePatchForTests();
}
Loading
Loading