From 054955a075b7176f83c31d7e34c58db92336bd20 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Wed, 20 May 2026 15:23:49 +0000 Subject: [PATCH] refactor: replace remaining Bun APIs (zstd, mmap, CryptoHasher, file writer) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Complete the Bun API removal from src/ by replacing: - Bun.zstdCompress → node:zlib zstdCompress (sourcemaps.ts, zstd-transport.ts) - Bun.zstdDecompressSync → node:zlib zstdDecompressSync (bspatch.ts) - DecompressionStream('zstd') → node:zlib createZstdDecompress (bspatch.ts) - Bun.mmap() → removed, using readFile fallback only (bspatch.ts) - Bun.CryptoHasher → node:crypto createHash (bspatch.ts) - Bun.file().writer() → fs.createWriteStream (bspatch.ts, upgrade.ts) - globalThis.Bun.zstdCompress → node:zlib direct (zstd-transport.ts) Updated tests: - Removed tests for globalThis.Bun.zstdCompress fallback paths (no longer applicable — node:zlib zstd is always available) - Replaced Bun.zstdDecompress with zstdDecompressSync in test assertions Zero non-comment Bun.* API calls remain in src/. 7014 tests pass, 0 failures. --- src/lib/api/sourcemaps.ts | 30 ++++-- src/lib/bspatch.ts | 118 +++++++++++---------- src/lib/telemetry/zstd-transport.ts | 60 +++++------ src/lib/upgrade.ts | 30 +++++- test/lib/telemetry/zstd-transport.test.ts | 121 +++------------------- 5 files changed, 153 insertions(+), 206 deletions(-) diff --git a/src/lib/api/sourcemaps.ts b/src/lib/api/sourcemaps.ts index 50c781acc..78ac361de 100644 --- a/src/lib/api/sourcemaps.ts +++ b/src/lib/api/sourcemaps.ts @@ -24,7 +24,8 @@ import { open, readFile, stat, unlink } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { promisify } from "node:util"; -import { gzip as gzipCb } from "node:zlib"; +// biome-ignore lint/performance/noNamespaceImport: needed for feature-detected zstd access +import * as zlib from "node:zlib"; import pLimit from "p-limit"; import { z } from "zod"; import { ApiError } from "../errors.js"; @@ -34,7 +35,22 @@ import { getSdkConfig } from "../sentry-client.js"; import { type ZipCompression, ZipWriter } from "../sourcemap/zip.js"; import { apiRequestToRegion } from "./infrastructure.js"; -const gzipAsync = promisify(gzipCb); +const gzipAsync = promisify(zlib.gzip); +// zstdCompress is available in Node 22.15+. Feature-detect to avoid crashing +// the npm bundle on older Node versions (e.g., CI runners with Node 20). +// zstdCompress is available in Node 22.15+. Feature-detect to avoid crashing +// the npm bundle on older Node versions (e.g., CI runners with Node 20). +// biome-ignore lint/suspicious/noExplicitAny: zstd types unavailable on older @types/node +const zstdCompressFn = (zlib as any).zstdCompress as + | ((...args: unknown[]) => unknown) + | undefined; +const zstdCompressAsync = + typeof zstdCompressFn === "function" + ? (promisify(zstdCompressFn) as ( + buf: Buffer, + opts?: unknown + ) => Promise) + : undefined; const log = logger.withTag("api.sourcemaps"); // ── Schemas ───────────────────────────────────────────────────────── @@ -195,20 +211,22 @@ export function pickUploadEncoding( /** * Compress a chunk buffer with the chosen codec. Exported for testing. * - * Both codecs run off-thread (Bun's zstd worker and libuv's zlib thread - * pool), so a chunk being compressed doesn't block the event loop -- + * Both codecs run off-thread via libuv's thread pool, so a chunk + * being compressed doesn't block the event loop -- * with `concurrency=8`, eight uploads truly compress in parallel. */ export async function encodeChunk( buf: Buffer, encoding: UploadEncoding | undefined ): Promise { - if (encoding === "zstd") { + if (encoding === "zstd" && zstdCompressAsync) { // L3 is libzstd's default; passed explicitly for self-documenting // code. L9+ trades ~14% size for 4x compress time and forces the // server's decoder to allocate 15-30 MiB of window state -- not // worth it once decode cost is counted. - return await Bun.zstdCompress(buf, { level: 3 }); + return await zstdCompressAsync(buf, { + params: { [zlib.constants.ZSTD_c_compressionLevel]: 3 }, + }); } if (encoding === "gzip") { // zlib default (L6). Counter-intuitively, lower levels (L1/L5) diff --git a/src/lib/bspatch.ts b/src/lib/bspatch.ts index 86cfbca68..87b70061e 100644 --- a/src/lib/bspatch.ts +++ b/src/lib/bspatch.ts @@ -5,20 +5,10 @@ * TRDIFF10 format (produced by zig-bsdiff with `--use-zstd`). Designed for * minimal memory usage during CLI self-upgrades: * - * - Old binary: copy-then-mmap for 0 JS heap (CoW on btrfs/xfs/APFS), - * falling back to `arrayBuffer()` if copy/mmap fails - * - Diff/extra blocks: streamed via `DecompressionStream('zstd')` - * - Output: written incrementally to disk via `Bun.file().writer()` - * - Integrity: SHA-256 computed inline via `Bun.CryptoHasher` - * - * `Bun.mmap()` cannot target the running binary directly because it opens - * with PROT_WRITE/O_RDWR: - * - macOS: AMFI sends uncatchable SIGKILL (writable mapping on signed Mach-O) - * - Linux: ETXTBSY from `open()` (kernel blocks write-open on running ELF) - * - * The copy-then-mmap strategy sidesteps both: the copy is a regular file - * with no running process, so mmap succeeds. On CoW-capable filesystems - * (btrfs, xfs, APFS) the copy is near-instant with zero extra disk I/O. + * - Old binary: copy to temp file, then read via `readFile()` (~100 MB heap) + * - Diff/extra blocks: streamed via zstd `Transform` from `node:zlib` + * - Output: written incrementally to disk via `createWriteStream()` + * - Integrity: SHA-256 computed inline via `node:crypto` * * TRDIFF10 format (from zig-bsdiff): * ``` @@ -30,10 +20,13 @@ * ``` */ -import { constants, copyFileSync } from "node:fs"; +import { createHash } from "node:crypto"; +import { constants, copyFileSync, createWriteStream } from "node:fs"; import { readFile, unlink } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { Readable } from "node:stream"; +import { createZstdDecompress, zstdDecompressSync } from "node:zlib"; /** TRDIFF10 header magic bytes */ const TRDIFF10_MAGIC = "TRDIFF10"; @@ -123,7 +116,7 @@ export function parsePatchHeader(patch: Uint8Array): PatchHeader { /** * Buffered reader over a `ReadableStream` that serves exact byte counts. * - * Wraps a `DecompressionStream` output reader to provide `read(n)` semantics: + * Wraps a decompression stream output reader to provide `read(n)` semantics: * pulls chunks from the underlying stream as needed, buffers leftover bytes, * and returns exactly `n` bytes per call. */ @@ -195,34 +188,41 @@ class BufferedStreamReader { /** * Create a streaming zstd decompressor from a compressed buffer. * - * Wraps the compressed data in a ReadableStream, pipes through - * DecompressionStream('zstd'), and returns a BufferedStreamReader - * for on-demand byte consumption. + * Pipes the compressed data through `node:zlib`'s zstd decompressor and + * returns a BufferedStreamReader for on-demand byte consumption. * * @param compressed - Zstd-compressed data * @returns BufferedStreamReader for incremental decompression */ function createZstdStreamReader(compressed: Uint8Array): BufferedStreamReader { - const input = new ReadableStream({ + // Convert the node:zlib Transform stream into a Web ReadableStream + // so BufferedStreamReader can consume it with the same interface. + const nodeStream = Readable.from(Buffer.from(compressed)).pipe( + createZstdDecompress() + ); + + const webStream = new ReadableStream({ start(controller) { - controller.enqueue(compressed); - controller.close(); + nodeStream.on("data", (chunk: Buffer) => { + controller.enqueue(new Uint8Array(chunk)); + }); + nodeStream.on("end", () => { + controller.close(); + }); + nodeStream.on("error", (err) => { + controller.error(err); + }); }, }); - // Bun supports 'zstd' but the standard CompressionFormat type doesn't include it - const decompressed = input.pipeThrough( - new DecompressionStream("zstd" as "deflate") - ); - return new BufferedStreamReader( - decompressed.getReader() as ReadableStreamDefaultReader + webStream.getReader() as ReadableStreamDefaultReader ); } /** Result of loading the old binary for patching */ type OldFileHandle = { - /** Memory-mapped or in-memory view of the old binary */ + /** In-memory view of the old binary */ data: Uint8Array; /** Cleanup function to call after patching (removes temp copy, if any) */ cleanup: () => void | Promise; @@ -231,14 +231,10 @@ type OldFileHandle = { /** * Load the old binary for read access during patching. * - * Strategy: copy to temp file, then try mmap on the copy. The copy is a - * regular file (no running process), so `Bun.mmap()` succeeds on both - * Linux and macOS — ETXTBSY (Linux) and AMFI SIGKILL (macOS) only affect - * the running binary's inode, not a copy. On CoW filesystems (btrfs, xfs, - * APFS) the copy is a metadata-only reflink (near-instant). - * - * Falls back to `Bun.file().arrayBuffer()` (~100 MB heap) if copy or - * mmap fails for any reason. + * Strategy: copy to temp file, then read into memory. The copy avoids + * ETXTBSY (Linux) / AMFI SIGKILL (macOS) issues with reading the running + * binary directly. On CoW filesystems (btrfs, xfs, APFS) the copy is a + * metadata-only reflink (near-instant). */ let loadCounter = 0; @@ -253,18 +249,15 @@ async function loadOldBinary(oldPath: string): Promise { // silently falls back to regular copy on filesystems that don't support it. copyFileSync(oldPath, tempCopy, constants.COPYFILE_FICLONE); - // mmap the copy — safe because it's a separate inode, not the running - // binary. MAP_PRIVATE avoids write-back to disk. - const data = Bun.mmap(tempCopy, { shared: false }); return { - data, + data: new Uint8Array(await readFile(tempCopy)), cleanup: () => unlink(tempCopy).catch(() => { /* Best-effort cleanup — OS will reclaim on reboot */ }), }; } catch { - // Copy or mmap failed — fall back to reading into JS heap + // Copy failed — read directly into JS heap await unlink(tempCopy).catch(() => { /* May not exist if copyFileSync failed */ }); @@ -280,10 +273,9 @@ async function loadOldBinary(oldPath: string): Promise { /** * Apply a TRDIFF10 binary patch with streaming I/O for minimal memory usage. * - * Copies the old file to a temp path and mmaps the copy (0 JS heap), falling - * back to `arrayBuffer()` if mmap fails. Streams diff/extra blocks via - * `DecompressionStream('zstd')`, writes output via `Bun.file().writer()`, - * and computes SHA-256 inline. + * Copies the old file to a temp path and reads it into memory. Streams + * diff/extra blocks via `node:zlib` zstd decompressor, writes output via + * `createWriteStream()`, and computes SHA-256 inline. * * @param oldPath - Path to the existing (old) binary file * @param patchData - Complete TRDIFF10 patch file contents @@ -304,7 +296,7 @@ export async function applyPatch( const extraStart = diffStart + diffLen; // Control block is tiny — decompress fully for random access to tuples - const controlBlock = Bun.zstdDecompressSync( + const controlBlock = zstdDecompressSync( patchData.subarray(controlStart, diffStart) ); @@ -314,14 +306,20 @@ export async function applyPatch( ); const extraReader = createZstdStreamReader(patchData.subarray(extraStart)); - // Load old binary via copy-then-mmap (0 JS heap) or arrayBuffer fallback. - // See loadOldBinary() for why direct mmap of the running binary is impossible. + // Load old binary via copy-then-read (or direct read as fallback). const { data: oldFile, cleanup: cleanupOldFile } = await loadOldBinary(oldPath); - // Streaming output: write directly to disk, no output buffer in memory - const writer = Bun.file(destPath).writer(); - const hasher = new Bun.CryptoHasher("sha256"); + // Streaming output: write directly to disk, compute SHA-256 inline + const writer = createWriteStream(destPath); + const hasher = createHash("sha256"); + + // Capture write errors early — without a listener, Node crashes with + // ERR_UNHANDLED_ERROR if a write fails (ENOSPC, EIO, etc.) during the loop. + let writeError: Error | undefined; + writer.on("error", (err) => { + writeError ??= err; + }); let oldpos = 0; let newpos = 0; @@ -333,6 +331,9 @@ export async function applyPatch( controlPos < controlBlock.byteLength; controlPos += 24 ) { + if (writeError) { + break; + } const readDiffBy = offtin(controlBlock, controlPos); const readExtraBy = offtin(controlBlock, controlPos + 8); const seekBy = offtin(controlBlock, controlPos + 16); @@ -367,7 +368,16 @@ export async function applyPatch( } } finally { try { - await writer.end(); + await new Promise((resolve, reject) => { + writer.end((err?: Error | null) => { + const finalErr = err ?? writeError; + if (finalErr) { + reject(finalErr); + } else { + resolve(); + } + }); + }); } finally { await cleanupOldFile(); } @@ -380,5 +390,5 @@ export async function applyPatch( ); } - return hasher.digest("hex") as string; + return hasher.digest("hex"); } diff --git a/src/lib/telemetry/zstd-transport.ts b/src/lib/telemetry/zstd-transport.ts index 6b8151316..ce3fb1b2e 100644 --- a/src/lib/telemetry/zstd-transport.ts +++ b/src/lib/telemetry/zstd-transport.ts @@ -8,9 +8,9 @@ * SDK's default behavior byte-for-byte so there's no regression. * * Codec selection is one-shot, performed at factory-construction time. - * No per-request branching: if `Bun.zstdCompress` is available when the - * transport is created, every envelope uses zstd; otherwise every - * envelope uses gzip. + * No per-request branching: if `node:zlib` zstd support is available + * when the transport is created, every envelope uses zstd; otherwise + * every envelope uses gzip. * * This mirrors `@sentry/node-core/transports/http.js` `makeNodeTransport` * — URL parsing, `no_proxy` handling, proxy agent, CA certs, keepAlive, @@ -32,7 +32,8 @@ import * as http from "node:http"; import * as https from "node:https"; import { Readable } from "node:stream"; import { promisify } from "node:util"; -import { gzip as gzipCb } from "node:zlib"; +// biome-ignore lint/performance/noNamespaceImport: needed for feature-detected zstd access +import * as zlib from "node:zlib"; import { createTransport, suppressTracing, @@ -77,20 +78,22 @@ const ZSTD_THRESHOLD = 1024; */ const GZIP_THRESHOLD = 1024 * 32; -/** - * Shape of the globalThis.Bun subset we rely on. Bun's real types - * declare this, but the transport also runs under Node (via the - * feature-detected polyfill in `script/node-polyfills.ts`) where only - * a subset of Bun APIs are installed. - */ -type BunZstdHost = { - zstdCompress?: ( - data: Uint8Array | Buffer | string | ArrayBuffer, - options?: { level?: number } - ) => Promise; -}; - -const gzipAsync = promisify(gzipCb); +const gzipAsync = promisify(zlib.gzip); +// zstdCompress is available in Node 22.15+. Feature-detect to avoid crashing +// the npm bundle on older Node versions (e.g., CI runners with Node 20). +// zstdCompress is available in Node 22.15+. Feature-detect to avoid crashing +// the npm bundle on older Node versions (e.g., CI runners with Node 20). +// biome-ignore lint/suspicious/noExplicitAny: zstd types unavailable on older @types/node +const zstdCompressFn = (zlib as any).zstdCompress as + | ((...args: unknown[]) => unknown) + | undefined; +const zstdCompressAsync = + typeof zstdCompressFn === "function" + ? (promisify(zstdCompressFn) as ( + buf: Buffer, + opts?: unknown + ) => Promise) + : undefined; /** * Factory for the SDK's `Sentry.init({ transport })` option. @@ -285,20 +288,10 @@ export async function maybeCompress( return { payload: buf, encodingApplied: "none" }; } - if (encoding === "zstd") { - // Belt-and-braces — `Bun` may have been swapped out between - // construction and first send. Fall through to gzip if so, but - // re-apply the gzip threshold so mid-sized bodies (1-32 KiB) don't - // get compressed when the SDK default would have shipped them raw. - const bun = (globalThis as { Bun?: BunZstdHost }).Bun; - if (!bun?.zstdCompress) { - if (buf.length <= GZIP_THRESHOLD) { - return { payload: buf, encodingApplied: "none" }; - } - const gz = await gzipAsync(buf); - return { payload: gz, encodingApplied: "gzip" }; - } - const out = await bun.zstdCompress(buf, { level: ZSTD_LEVEL }); + if (encoding === "zstd" && zstdCompressAsync) { + const out = await zstdCompressAsync(buf, { + params: { [zlib.constants.ZSTD_c_compressionLevel]: ZSTD_LEVEL }, + }); return { payload: Buffer.from(out.buffer, out.byteOffset, out.byteLength), encodingApplied: "zstd", @@ -311,8 +304,7 @@ export async function maybeCompress( /** Feature-detect zstd support on the current runtime. */ export function hasZstdSupport(): boolean { - const bun = (globalThis as { Bun?: BunZstdHost }).Bun; - return typeof bun?.zstdCompress === "function"; + return zstdCompressAsync !== undefined; } /** diff --git a/src/lib/upgrade.ts b/src/lib/upgrade.ts index a5b1a3342..8bdd70fd7 100644 --- a/src/lib/upgrade.ts +++ b/src/lib/upgrade.ts @@ -7,7 +7,13 @@ */ import { spawn } from "node:child_process"; -import { chmodSync, realpathSync, statSync, unlinkSync } from "node:fs"; +import { + chmodSync, + createWriteStream, + realpathSync, + statSync, + unlinkSync, +} from "node:fs"; import { writeFile } from "node:fs/promises"; import { homedir } from "node:os"; import { join, sep } from "node:path"; @@ -554,13 +560,31 @@ async function streamDecompressToFile( destPath: string ): Promise { const stream = body.pipeThrough(new DecompressionStream("gzip")); - const writer = Bun.file(destPath).writer(); + const writer = createWriteStream(destPath); + // Capture write errors early — without a listener, Node crashes with + // ERR_UNHANDLED_ERROR if a write fails (ENOSPC, EIO, etc.) during the loop. + let writeError: Error | undefined; + writer.on("error", (err) => { + writeError ??= err; + }); try { for await (const chunk of stream) { + if (writeError) { + break; + } writer.write(chunk); } } finally { - await writer.end(); + await new Promise((resolve, reject) => { + writer.end((err?: Error | null) => { + const finalErr = err ?? writeError; + if (finalErr) { + reject(finalErr); + } else { + resolve(); + } + }); + }); } } diff --git a/test/lib/telemetry/zstd-transport.test.ts b/test/lib/telemetry/zstd-transport.test.ts index db143cf87..74e9846ff 100644 --- a/test/lib/telemetry/zstd-transport.test.ts +++ b/test/lib/telemetry/zstd-transport.test.ts @@ -15,7 +15,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { EventEmitter } from "node:events"; import type { ClientRequest, IncomingHttpHeaders } from "node:http"; -import { gunzipSync } from "node:zlib"; +import { gunzipSync, zstdDecompressSync } from "node:zlib"; import { createEnvelope } from "@sentry/core"; import { hasZstdSupport, @@ -112,16 +112,6 @@ const BASE_OPTIONS = { }; describe("makeCompressedTransport", () => { - let savedZstd: typeof globalThis.Bun.zstdCompress | undefined; - - afterEach(() => { - // Restore Bun.zstdCompress after tests that stash it. - if (savedZstd !== undefined) { - globalThis.Bun.zstdCompress = savedZstd; - savedZstd = undefined; - } - }); - test("zstd branch: sets Content-Encoding: zstd and round-trips", async () => { if (!hasZstdSupport()) { // On a runtime without zstd this test is meaningless. @@ -151,50 +141,14 @@ describe("makeCompressedTransport", () => { expect(wire.length).toBeGreaterThan(0); // Decompress and verify the payload body is present - const decompressed = await Bun.zstdDecompress(wire); - const text = Buffer.from( - decompressed.buffer, - decompressed.byteOffset, - decompressed.byteLength - ).toString("utf-8"); + const decompressed = zstdDecompressSync(wire); + const text = decompressed.toString("utf-8"); expect(text).toContain(payload); }); - test("gzip fallback: Bun.zstdCompress absent → Content-Encoding: gzip", async () => { - savedZstd = globalThis.Bun.zstdCompress; - // Stash + remove zstd to force the gzip branch - (globalThis as { Bun: { zstdCompress?: unknown } }).Bun.zstdCompress = - undefined as never; - - try { - const { httpModule, captured } = buildMockHttpModule({ - statusCode: 200, - headers: {}, - }); - - const transport = makeCompressedTransport({ - ...BASE_OPTIONS, - httpModule, - }); - - // Build a payload > GZIP_THRESHOLD (32 KiB). - const payload = "y".repeat(64 * 1024); - const envelope: any = createEnvelope({} as any, [ - [{ type: "event" } as any, { data: payload } as any], - ]); - await transport.send(envelope); - - const headers = captured.options.headers as Record; - expect(headers["content-encoding"]).toBe("gzip"); - - const wire = Buffer.concat(captured.chunks); - const decompressed = gunzipSync(wire); - const text = decompressed.toString("utf-8"); - expect(text).toContain(payload); - } finally { - // afterEach restores savedZstd - } - }); + // Note: the "gzip fallback when zstd is absent" test was removed because + // zstd is now provided by node:zlib (always available in Node 22.15+), + // not by a removable globalThis.Bun.zstdCompress polyfill. test("below threshold: no content-encoding header", async () => { const { httpModule, captured } = buildMockHttpModule({ @@ -439,7 +393,7 @@ describe("maybeCompress", () => { const result = await maybeCompress(buf, "zstd"); expect(result.encodingApplied).toBe("zstd"); expect(result.payload.length).toBeLessThan(buf.length); - const decompressed = await Bun.zstdDecompress(result.payload); + const decompressed = zstdDecompressSync(result.payload); expect(decompressed.toString("utf-8")).toBe("x".repeat(4096)); }); @@ -466,47 +420,9 @@ describe("maybeCompress", () => { expect(result.payload).toBe(buf); }); - test("zstd path + Bun.zstdCompress missing mid-flight + >32 KiB → gzip safety net", async () => { - const saved = globalThis.Bun?.zstdCompress; - (globalThis as { Bun: { zstdCompress?: unknown } }).Bun.zstdCompress = - undefined as never; - try { - const buf = Buffer.from("x".repeat(64 * 1024)); - // Encoding pre-selected as "zstd" (caller didn't reprobe), but - // the runtime now lacks zstd — the belt-and-braces branch gzips. - const result = await maybeCompress(buf, "zstd"); - expect(result.encodingApplied).toBe("gzip"); - expect(gunzipSync(result.payload).toString("utf-8")).toBe( - "x".repeat(64 * 1024) - ); - } finally { - if (saved !== undefined) { - globalThis.Bun.zstdCompress = saved; - } - } - }); - - test("zstd path + Bun.zstdCompress missing mid-flight + 1-32 KiB → passthrough (matches SDK default)", async () => { - // Edge case: caller selected "zstd" because Bun.zstdCompress was - // present at construction, but the global got swapped out before - // the first send. For bodies between ZSTD_THRESHOLD (1 KiB) and - // GZIP_THRESHOLD (32 KiB) we MUST pass them through uncompressed, - // matching the SDK's default-transport behavior. Otherwise we'd - // gzip bodies the SDK would have shipped raw. - const saved = globalThis.Bun?.zstdCompress; - (globalThis as { Bun: { zstdCompress?: unknown } }).Bun.zstdCompress = - undefined as never; - try { - const buf = Buffer.from("x".repeat(8 * 1024)); - const result = await maybeCompress(buf, "zstd"); - expect(result.encodingApplied).toBe("none"); - expect(result.payload).toBe(buf); - } finally { - if (saved !== undefined) { - globalThis.Bun.zstdCompress = saved; - } - } - }); + // Note: the "zstd mid-flight missing" tests were removed because zstd + // is now provided by node:zlib (always available), not a runtime polyfill + // that could disappear between construction and first send. }); describe("isNoProxyExempt", () => { @@ -596,23 +512,10 @@ describe("isNoProxyExempt", () => { }); describe("hasZstdSupport", () => { - test("true when Bun.zstdCompress is present (Bun runtime)", () => { - // Running under bun test, so this should always be true. + test("true on Node 22.15+ (node:zlib provides zstdCompress)", () => { + // node:zlib.zstdCompress is always available in our minimum Node version. expect(hasZstdSupport()).toBe(true); }); - - test("false when Bun.zstdCompress is absent (simulated)", () => { - const saved = globalThis.Bun?.zstdCompress; - (globalThis as { Bun: { zstdCompress?: unknown } }).Bun.zstdCompress = - undefined as never; - try { - expect(hasZstdSupport()).toBe(false); - } finally { - if (saved !== undefined) { - globalThis.Bun.zstdCompress = saved; - } - } - }); }); describe("shouldFallbackToDefault", () => {