Skip to content

Commit 70508df

Browse files
authored
fix: avoid Blob.stream for native gzip (#3464)
* fix: avoid Blob.stream for native gzip * test: parameterize gzip support checks * fix: abort gzip writer on write failure
1 parent 82b5b19 commit 70508df

3 files changed

Lines changed: 99 additions & 13 deletions

File tree

.changeset/wise-owls-refuse.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@posthog/core': patch
3+
'posthog-js': patch
4+
---
5+
6+
Avoid using `Blob.stream()` for native async gzip compression to prevent Safari `NotReadableError` stream failures.

packages/core/src/__tests__/gzip.spec.ts

Lines changed: 71 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -34,11 +34,39 @@ describe('gzip', () => {
3434
expect(globalThis.CompressionStream).toBeDefined()
3535
expect(isGzipSupported()).toBe(true)
3636
})
37-
it('should return false if CompressStream not available', () => {
38-
const CompressionStream = globalThis.CompressionStream
39-
delete (globalThis as any).CompressionStream
40-
expect(isGzipSupported()).toBe(false)
41-
;(globalThis as any).CompressionStream = CompressionStream
37+
it.each([
38+
[
39+
'CompressionStream',
40+
() => {
41+
const CompressionStream = globalThis.CompressionStream
42+
delete (globalThis as any).CompressionStream
43+
return () => ((globalThis as any).CompressionStream = CompressionStream)
44+
},
45+
],
46+
[
47+
'TextEncoder',
48+
() => {
49+
const TextEncoder = globalThis.TextEncoder
50+
delete (globalThis as any).TextEncoder
51+
return () => ((globalThis as any).TextEncoder = TextEncoder)
52+
},
53+
],
54+
[
55+
'Response.blob',
56+
() => {
57+
const blob = globalThis.Response.prototype.blob
58+
delete (globalThis.Response.prototype as any).blob
59+
return () => ((globalThis.Response.prototype as any).blob = blob)
60+
},
61+
],
62+
])('should return false if %s not available', (_name, removeDependency) => {
63+
const restoreDependency = removeDependency()
64+
65+
try {
66+
expect(isGzipSupported()).toBe(false)
67+
} finally {
68+
restoreDependency()
69+
}
4270
})
4371
})
4472
describe('isNativeAsyncGzipReadError', () => {
@@ -60,6 +88,44 @@ describe('gzip', () => {
6088
;(globalThis as any).CompressionStream = CompressionStream
6189
})
6290

91+
it('does not read input using Blob.stream', async () => {
92+
const blobStream = Blob.prototype.stream
93+
Blob.prototype.stream = jest.fn(() => {
94+
throw new Error('Blob.stream should not be used')
95+
})
96+
97+
try {
98+
const compressed = await gzipCompress(API_TEST_INPUT, false, { rethrow: true })
99+
expect(compressed).not.toBe(null)
100+
} finally {
101+
Blob.prototype.stream = blobStream
102+
}
103+
})
104+
105+
it('aborts the compression writer when writing input fails', async () => {
106+
const CompressionStream = globalThis.CompressionStream
107+
const writeError = new Error('write failed')
108+
const abort = jest.fn(() => Promise.resolve())
109+
110+
;(globalThis as any).CompressionStream = jest.fn(() => ({
111+
writable: {
112+
getWriter: () => ({
113+
write: () => Promise.reject(writeError),
114+
close: jest.fn(),
115+
abort,
116+
}),
117+
},
118+
readable: new ReadableStream(),
119+
}))
120+
121+
try {
122+
await expect(gzipCompress(API_TEST_INPUT, false, { rethrow: true })).rejects.toBe(writeError)
123+
expect(abort).toHaveBeenCalledWith(writeError)
124+
} finally {
125+
;(globalThis as any).CompressionStream = CompressionStream
126+
}
127+
})
128+
63129
it('compressed random data should match node', async () => {
64130
const compressed = await gzipCompress(RANDOM_TEST_INPUT)
65131
expect(compressed).not.toBe(null)

packages/core/src/gzip.ts

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,12 @@
33
* This API (as of 2025-05-07) is not available on React Native.
44
*/
55
export function isGzipSupported(): boolean {
6-
return 'CompressionStream' in globalThis
6+
return (
7+
'CompressionStream' in globalThis &&
8+
'TextEncoder' in globalThis &&
9+
'Response' in globalThis &&
10+
typeof Response.prototype.blob === 'function'
11+
)
712
}
813

914
export const isNativeAsyncGzipReadError = (error: unknown): boolean => {
@@ -31,15 +36,24 @@ export type GzipCompressOptions = {
3136
*/
3237
export async function gzipCompress(input: string, isDebug = true, options?: GzipCompressOptions): Promise<Blob | null> {
3338
try {
34-
// Turn the string into a stream using a Blob, and then compress it
35-
const dataStream = new Blob([input], {
36-
type: 'text/plain',
37-
}).stream()
39+
const compressedStream = new CompressionStream('gzip')
40+
const writer = compressedStream.writable.getWriter()
3841

39-
const compressedStream = dataStream.pipeThrough(new CompressionStream('gzip'))
42+
const writePromise = writer
43+
.write(new TextEncoder().encode(input))
44+
.then(() => writer.close())
45+
.catch(async (err) => {
46+
try {
47+
await writer.abort(err)
48+
} catch {
49+
// Ignore abort failures and rethrow the original compression error below.
50+
}
51+
throw err
52+
})
53+
const responsePromise = new Response(compressedStream.readable).blob()
4054

41-
// Using a Response to easily extract the readablestream value. Decoding into a string for fetch
42-
return await new Response(compressedStream).blob()
55+
const [compressed] = await Promise.all([responsePromise, writePromise])
56+
return compressed
4357
} catch (error) {
4458
if (options?.rethrow) {
4559
throw error

0 commit comments

Comments
 (0)