Skip to content

Commit fa63510

Browse files
committed
fix(replay): Set worker Blob MIME type and clarify load-failure errors
The compression worker is created from a Blob URL. The Blob was constructed without a MIME type, so it defaulted to an empty type. Safari (especially on iOS) validates the MIME type before executing a Blob as a classic Worker and silently rejects it, firing a bare `error` event with no message — which the SDK surfaced as the misleading "Unknown error ... CSP policy restrictions" message. Tagging the Blob `text/javascript` fixes the Safari load failure. The same "Unknown error" was also captured across Chrome/Edge/Firefox, where the MIME type is irrelevant: those come from the worker fetch being aborted during page navigation/teardown. `WorkerHandler` now distinguishes an aborted load (document hidden/unloading) from a genuine failure, so the captured signal is accurate instead of always blaming CSP/network.
1 parent 1e26f76 commit fa63510

4 files changed

Lines changed: 126 additions & 7 deletions

File tree

packages/replay-internal/src/eventBuffer/WorkerHandler.ts

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,37 @@
1+
import { WINDOW } from '../constants';
12
import { DEBUG_BUILD } from '../debug-build';
23
import type { WorkerRequest, WorkerResponse } from '../types';
34
import { debug } from '../util/logger';
45

6+
/**
7+
* Build the error thrown when the compression worker fails to load.
8+
*
9+
* The worker's `error` event is a bare `Event` (not an `ErrorEvent`) when the
10+
* script fails to *load* — it carries no `message`, so we can't read a cause
11+
* off it. Rather than always blaming CSP/network, we check whether the document
12+
* is no longer visible: when the page is navigating away or backgrounded, an
13+
* in-flight worker fetch is aborted and this error is expected teardown noise,
14+
* not a real load failure. Distinguishing the two keeps the captured signal
15+
* accurate across browsers (this fires on Safari, Chrome, Firefox alike).
16+
*/
17+
function _getWorkerLoadError(error: unknown): Error {
18+
if (error instanceof ErrorEvent && error.message) {
19+
return new Error(`Failed to load Replay compression worker: ${error.message}`);
20+
}
21+
22+
// `document` may be undefined in non-browser worker/SSR contexts.
23+
const isDocumentHidden = WINDOW.document && WINDOW.document.visibilityState !== 'visible';
24+
if (isDocumentHidden) {
25+
return new Error(
26+
'Failed to load Replay compression worker: the page was hidden or unloaded before the worker loaded.',
27+
);
28+
}
29+
30+
return new Error(
31+
'Failed to load Replay compression worker: Unknown error. This can happen due to CSP policy restrictions, network issues, or the worker script failing to load.',
32+
);
33+
}
34+
535
interface PendingRequest {
636
method: WorkerRequest['method'];
737
resolve: (value: unknown) => void;
@@ -56,11 +86,7 @@ export class WorkerHandler {
5686
'error',
5787
error => {
5888
DEBUG_BUILD && debug.warn('Failed to load Replay compression worker', error);
59-
reject(
60-
new Error(
61-
`Failed to load Replay compression worker: ${error instanceof ErrorEvent && error.message ? error.message : 'Unknown error. This can happen due to CSP policy restrictions, network issues, or the worker script failing to load.'}`,
62-
),
63-
);
89+
reject(_getWorkerLoadError(error));
6490
},
6591
{ once: true },
6692
);

packages/replay-internal/test/unit/eventBuffer/WorkerHandler.test.ts

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
* @vitest-environment jsdom
33
*/
44

5-
import { describe, expect, it } from 'vitest';
5+
import { afterEach, describe, expect, it } from 'vitest';
66
import { WorkerHandler } from '../../../src/eventBuffer/WorkerHandler';
77
import type { WorkerResponse } from '../../../src/types';
88

@@ -62,6 +62,16 @@ class MockWorker implements Pick<Worker, 'addEventListener' | 'removeEventListen
6262
this._dispatch('message', { data: response } as MessageEvent);
6363
}
6464

65+
/** Dispatch the worker's readiness message (consumed by `ensureReady`). */
66+
public dispatchReady(success = true): void {
67+
this._dispatch('message', { data: { success } } as MessageEvent);
68+
}
69+
70+
/** Dispatch an `error` event, as fired when the worker script fails to load. */
71+
public dispatchError(event: Event): void {
72+
this._dispatch('error', event as MessageEvent);
73+
}
74+
6575
public get pendingCount(): number {
6676
return this._pendingRequests.length;
6777
}
@@ -171,4 +181,56 @@ describe('Unit | eventBuffer | WorkerHandler', () => {
171181
expect(worker.terminated).toBe(true);
172182
expect(worker.listenerCount).toBe(0);
173183
});
184+
185+
describe('ensureReady', () => {
186+
const originalVisibility = Object.getOwnPropertyDescriptor(Document.prototype, 'visibilityState');
187+
188+
const setVisibility = (state: DocumentVisibilityState): void => {
189+
Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => state });
190+
};
191+
192+
afterEach(() => {
193+
if (originalVisibility) {
194+
Object.defineProperty(Document.prototype, 'visibilityState', originalVisibility);
195+
}
196+
});
197+
198+
it('resolves once the worker reports readiness', async () => {
199+
const { worker, handler } = makeHandler();
200+
const ready = handler.ensureReady();
201+
worker.dispatchReady();
202+
await expect(ready).resolves.toBeUndefined();
203+
});
204+
205+
it('caches the ready promise so listeners are only attached once', () => {
206+
const { handler } = makeHandler();
207+
expect(handler.ensureReady()).toBe(handler.ensureReady());
208+
});
209+
210+
it("uses an ErrorEvent's message when one is present", async () => {
211+
const { worker, handler } = makeHandler();
212+
const ready = handler.ensureReady();
213+
worker.dispatchError(new ErrorEvent('error', { message: 'CSP blocked worker-src' }));
214+
await expect(ready).rejects.toThrow('Failed to load Replay compression worker: CSP blocked worker-src');
215+
});
216+
217+
it('attributes a bare error event to teardown when the document is hidden', async () => {
218+
setVisibility('hidden');
219+
const { worker, handler } = makeHandler();
220+
const ready = handler.ensureReady();
221+
// A load failure fires a plain Event with no message (Safari/Chrome/Firefox alike).
222+
worker.dispatchError(new Event('error'));
223+
await expect(ready).rejects.toThrow(
224+
'Failed to load Replay compression worker: the page was hidden or unloaded before the worker loaded.',
225+
);
226+
});
227+
228+
it('falls back to the generic message for a bare error event while visible', async () => {
229+
setVisibility('visible');
230+
const { worker, handler } = makeHandler();
231+
const ready = handler.ensureReady();
232+
worker.dispatchError(new Event('error'));
233+
await expect(ready).rejects.toThrow(/Unknown error\. This can happen due to CSP policy restrictions/);
234+
});
235+
});
174236
});

packages/replay-worker/src/index.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,12 @@ import workerString from './worker';
44
* Get the URL for a web worker.
55
*/
66
export function getWorkerURL(): string {
7-
const workerBlob = new Blob([workerString]);
7+
// Safari (particularly on iOS) validates the MIME type of a Blob before it
8+
// will execute it as a classic Worker script. A Blob created without an
9+
// explicit `type` defaults to an empty string, which WebKit may reject,
10+
// firing a bare `error` event with no message. Blink/Gecko are lenient here,
11+
// so this only manifests on Safari. Set an explicit JavaScript MIME type so
12+
// the worker loads across browsers.
13+
const workerBlob = new Blob([workerString], { type: 'text/javascript' });
814
return URL.createObjectURL(workerBlob);
915
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
/**
2+
* @vitest-environment jsdom
3+
*/
4+
5+
import { describe, expect, it, vi } from 'vitest';
6+
import { getWorkerURL } from '../../src';
7+
8+
describe('getWorkerURL', () => {
9+
// Safari (esp. iOS) rejects executing a Blob worker without a JavaScript MIME
10+
// type, firing a bare `error` event. The Blob must be tagged `text/javascript`
11+
// so the worker loads across browsers. See WorkerHandler for the load-error path.
12+
it('creates the worker Blob with a JavaScript MIME type', () => {
13+
// jsdom does not implement `URL.createObjectURL`, so stub it and capture the Blob.
14+
const createObjectURL = vi.fn<(blob: Blob) => string>().mockReturnValue('blob:mock');
15+
URL.createObjectURL = createObjectURL as unknown as typeof URL.createObjectURL;
16+
17+
const url = getWorkerURL();
18+
19+
expect(url).toBe('blob:mock');
20+
expect(createObjectURL).toHaveBeenCalledTimes(1);
21+
const blob = createObjectURL.mock.calls[0]![0];
22+
expect(blob).toBeInstanceOf(Blob);
23+
expect(blob.type).toBe('text/javascript');
24+
});
25+
});

0 commit comments

Comments
 (0)