Skip to content

Commit f652595

Browse files
committed
refactor: replace remaining Bun APIs (zstd, mmap, CryptoHasher, file writer)
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.
1 parent 6096cba commit f652595

5 files changed

Lines changed: 135 additions & 206 deletions

File tree

src/lib/api/sourcemaps.ts

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,8 @@ import { open, readFile, stat, unlink } from "node:fs/promises";
2424
import { tmpdir } from "node:os";
2525
import { join } from "node:path";
2626
import { promisify } from "node:util";
27-
import { gzip as gzipCb } from "node:zlib";
27+
// biome-ignore lint/performance/noNamespaceImport: needed for feature-detected zstd access
28+
import * as zlib from "node:zlib";
2829
import pLimit from "p-limit";
2930
import { z } from "zod";
3031
import { ApiError } from "../errors.js";
@@ -34,7 +35,13 @@ import { getSdkConfig } from "../sentry-client.js";
3435
import { type ZipCompression, ZipWriter } from "../sourcemap/zip.js";
3536
import { apiRequestToRegion } from "./infrastructure.js";
3637

37-
const gzipAsync = promisify(gzipCb);
38+
const gzipAsync = promisify(zlib.gzip);
39+
// zstdCompress is available in Node 22.15+. Feature-detect to avoid crashing
40+
// the npm bundle on older Node versions (e.g., CI runners with Node 20).
41+
const zstdCompressAsync =
42+
typeof (zlib as { zstdCompress?: unknown }).zstdCompress === "function"
43+
? promisify((zlib as { zstdCompress: typeof zlib.gzip }).zstdCompress)
44+
: undefined;
3845
const log = logger.withTag("api.sourcemaps");
3946

4047
// ── Schemas ─────────────────────────────────────────────────────────
@@ -195,20 +202,22 @@ export function pickUploadEncoding(
195202
/**
196203
* Compress a chunk buffer with the chosen codec. Exported for testing.
197204
*
198-
* Both codecs run off-thread (Bun's zstd worker and libuv's zlib thread
199-
* pool), so a chunk being compressed doesn't block the event loop --
205+
* Both codecs run off-thread via libuv's thread pool, so a chunk
206+
* being compressed doesn't block the event loop --
200207
* with `concurrency=8`, eight uploads truly compress in parallel.
201208
*/
202209
export async function encodeChunk(
203210
buf: Buffer,
204211
encoding: UploadEncoding | undefined
205212
): Promise<Uint8Array> {
206-
if (encoding === "zstd") {
213+
if (encoding === "zstd" && zstdCompressAsync) {
207214
// L3 is libzstd's default; passed explicitly for self-documenting
208215
// code. L9+ trades ~14% size for 4x compress time and forces the
209216
// server's decoder to allocate 15-30 MiB of window state -- not
210217
// worth it once decode cost is counted.
211-
return await Bun.zstdCompress(buf, { level: 3 });
218+
return await zstdCompressAsync(buf, {
219+
params: { [zlib.constants.ZSTD_c_compressionLevel]: 3 },
220+
});
212221
}
213222
if (encoding === "gzip") {
214223
// zlib default (L6). Counter-intuitively, lower levels (L1/L5)

src/lib/bspatch.ts

Lines changed: 64 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -5,20 +5,10 @@
55
* TRDIFF10 format (produced by zig-bsdiff with `--use-zstd`). Designed for
66
* minimal memory usage during CLI self-upgrades:
77
*
8-
* - Old binary: copy-then-mmap for 0 JS heap (CoW on btrfs/xfs/APFS),
9-
* falling back to `arrayBuffer()` if copy/mmap fails
10-
* - Diff/extra blocks: streamed via `DecompressionStream('zstd')`
11-
* - Output: written incrementally to disk via `Bun.file().writer()`
12-
* - Integrity: SHA-256 computed inline via `Bun.CryptoHasher`
13-
*
14-
* `Bun.mmap()` cannot target the running binary directly because it opens
15-
* with PROT_WRITE/O_RDWR:
16-
* - macOS: AMFI sends uncatchable SIGKILL (writable mapping on signed Mach-O)
17-
* - Linux: ETXTBSY from `open()` (kernel blocks write-open on running ELF)
18-
*
19-
* The copy-then-mmap strategy sidesteps both: the copy is a regular file
20-
* with no running process, so mmap succeeds. On CoW-capable filesystems
21-
* (btrfs, xfs, APFS) the copy is near-instant with zero extra disk I/O.
8+
* - Old binary: copy to temp file, then read via `readFile()` (~100 MB heap)
9+
* - Diff/extra blocks: streamed via zstd `Transform` from `node:zlib`
10+
* - Output: written incrementally to disk via `createWriteStream()`
11+
* - Integrity: SHA-256 computed inline via `node:crypto`
2212
*
2313
* TRDIFF10 format (from zig-bsdiff):
2414
* ```
@@ -30,10 +20,13 @@
3020
* ```
3121
*/
3222

33-
import { constants, copyFileSync } from "node:fs";
23+
import { createHash } from "node:crypto";
24+
import { constants, copyFileSync, createWriteStream } from "node:fs";
3425
import { readFile, unlink } from "node:fs/promises";
3526
import { tmpdir } from "node:os";
3627
import { join } from "node:path";
28+
import { Readable } from "node:stream";
29+
import { createZstdDecompress, zstdDecompressSync } from "node:zlib";
3730

3831
/** TRDIFF10 header magic bytes */
3932
const TRDIFF10_MAGIC = "TRDIFF10";
@@ -123,7 +116,7 @@ export function parsePatchHeader(patch: Uint8Array): PatchHeader {
123116
/**
124117
* Buffered reader over a `ReadableStream` that serves exact byte counts.
125118
*
126-
* Wraps a `DecompressionStream` output reader to provide `read(n)` semantics:
119+
* Wraps a decompression stream output reader to provide `read(n)` semantics:
127120
* pulls chunks from the underlying stream as needed, buffers leftover bytes,
128121
* and returns exactly `n` bytes per call.
129122
*/
@@ -195,34 +188,41 @@ class BufferedStreamReader {
195188
/**
196189
* Create a streaming zstd decompressor from a compressed buffer.
197190
*
198-
* Wraps the compressed data in a ReadableStream, pipes through
199-
* DecompressionStream('zstd'), and returns a BufferedStreamReader
200-
* for on-demand byte consumption.
191+
* Pipes the compressed data through `node:zlib`'s zstd decompressor and
192+
* returns a BufferedStreamReader for on-demand byte consumption.
201193
*
202194
* @param compressed - Zstd-compressed data
203195
* @returns BufferedStreamReader for incremental decompression
204196
*/
205197
function createZstdStreamReader(compressed: Uint8Array): BufferedStreamReader {
206-
const input = new ReadableStream<Uint8Array>({
198+
// Convert the node:zlib Transform stream into a Web ReadableStream
199+
// so BufferedStreamReader can consume it with the same interface.
200+
const nodeStream = Readable.from(Buffer.from(compressed)).pipe(
201+
createZstdDecompress()
202+
);
203+
204+
const webStream = new ReadableStream<Uint8Array>({
207205
start(controller) {
208-
controller.enqueue(compressed);
209-
controller.close();
206+
nodeStream.on("data", (chunk: Buffer) => {
207+
controller.enqueue(new Uint8Array(chunk));
208+
});
209+
nodeStream.on("end", () => {
210+
controller.close();
211+
});
212+
nodeStream.on("error", (err) => {
213+
controller.error(err);
214+
});
210215
},
211216
});
212217

213-
// Bun supports 'zstd' but the standard CompressionFormat type doesn't include it
214-
const decompressed = input.pipeThrough(
215-
new DecompressionStream("zstd" as "deflate")
216-
);
217-
218218
return new BufferedStreamReader(
219-
decompressed.getReader() as ReadableStreamDefaultReader<Uint8Array>
219+
webStream.getReader() as ReadableStreamDefaultReader<Uint8Array>
220220
);
221221
}
222222

223223
/** Result of loading the old binary for patching */
224224
type OldFileHandle = {
225-
/** Memory-mapped or in-memory view of the old binary */
225+
/** In-memory view of the old binary */
226226
data: Uint8Array;
227227
/** Cleanup function to call after patching (removes temp copy, if any) */
228228
cleanup: () => void | Promise<void>;
@@ -231,14 +231,10 @@ type OldFileHandle = {
231231
/**
232232
* Load the old binary for read access during patching.
233233
*
234-
* Strategy: copy to temp file, then try mmap on the copy. The copy is a
235-
* regular file (no running process), so `Bun.mmap()` succeeds on both
236-
* Linux and macOS — ETXTBSY (Linux) and AMFI SIGKILL (macOS) only affect
237-
* the running binary's inode, not a copy. On CoW filesystems (btrfs, xfs,
238-
* APFS) the copy is a metadata-only reflink (near-instant).
239-
*
240-
* Falls back to `Bun.file().arrayBuffer()` (~100 MB heap) if copy or
241-
* mmap fails for any reason.
234+
* Strategy: copy to temp file, then read into memory. The copy avoids
235+
* ETXTBSY (Linux) / AMFI SIGKILL (macOS) issues with reading the running
236+
* binary directly. On CoW filesystems (btrfs, xfs, APFS) the copy is a
237+
* metadata-only reflink (near-instant).
242238
*/
243239
let loadCounter = 0;
244240

@@ -253,18 +249,15 @@ async function loadOldBinary(oldPath: string): Promise<OldFileHandle> {
253249
// silently falls back to regular copy on filesystems that don't support it.
254250
copyFileSync(oldPath, tempCopy, constants.COPYFILE_FICLONE);
255251

256-
// mmap the copy — safe because it's a separate inode, not the running
257-
// binary. MAP_PRIVATE avoids write-back to disk.
258-
const data = Bun.mmap(tempCopy, { shared: false });
259252
return {
260-
data,
253+
data: new Uint8Array(await readFile(tempCopy)),
261254
cleanup: () =>
262255
unlink(tempCopy).catch(() => {
263256
/* Best-effort cleanup — OS will reclaim on reboot */
264257
}),
265258
};
266259
} catch {
267-
// Copy or mmap failed — fall back to reading into JS heap
260+
// Copy failed — read directly into JS heap
268261
await unlink(tempCopy).catch(() => {
269262
/* May not exist if copyFileSync failed */
270263
});
@@ -280,10 +273,9 @@ async function loadOldBinary(oldPath: string): Promise<OldFileHandle> {
280273
/**
281274
* Apply a TRDIFF10 binary patch with streaming I/O for minimal memory usage.
282275
*
283-
* Copies the old file to a temp path and mmaps the copy (0 JS heap), falling
284-
* back to `arrayBuffer()` if mmap fails. Streams diff/extra blocks via
285-
* `DecompressionStream('zstd')`, writes output via `Bun.file().writer()`,
286-
* and computes SHA-256 inline.
276+
* Copies the old file to a temp path and reads it into memory. Streams
277+
* diff/extra blocks via `node:zlib` zstd decompressor, writes output via
278+
* `createWriteStream()`, and computes SHA-256 inline.
287279
*
288280
* @param oldPath - Path to the existing (old) binary file
289281
* @param patchData - Complete TRDIFF10 patch file contents
@@ -304,7 +296,7 @@ export async function applyPatch(
304296
const extraStart = diffStart + diffLen;
305297

306298
// Control block is tiny — decompress fully for random access to tuples
307-
const controlBlock = Bun.zstdDecompressSync(
299+
const controlBlock = zstdDecompressSync(
308300
patchData.subarray(controlStart, diffStart)
309301
);
310302

@@ -314,14 +306,20 @@ export async function applyPatch(
314306
);
315307
const extraReader = createZstdStreamReader(patchData.subarray(extraStart));
316308

317-
// Load old binary via copy-then-mmap (0 JS heap) or arrayBuffer fallback.
318-
// See loadOldBinary() for why direct mmap of the running binary is impossible.
309+
// Load old binary via copy-then-read (or direct read as fallback).
319310
const { data: oldFile, cleanup: cleanupOldFile } =
320311
await loadOldBinary(oldPath);
321312

322-
// Streaming output: write directly to disk, no output buffer in memory
323-
const writer = Bun.file(destPath).writer();
324-
const hasher = new Bun.CryptoHasher("sha256");
313+
// Streaming output: write directly to disk, compute SHA-256 inline
314+
const writer = createWriteStream(destPath);
315+
const hasher = createHash("sha256");
316+
317+
// Capture write errors early — without a listener, Node crashes with
318+
// ERR_UNHANDLED_ERROR if a write fails (ENOSPC, EIO, etc.) during the loop.
319+
let writeError: Error | undefined;
320+
writer.on("error", (err) => {
321+
writeError ??= err;
322+
});
325323

326324
let oldpos = 0;
327325
let newpos = 0;
@@ -333,6 +331,9 @@ export async function applyPatch(
333331
controlPos < controlBlock.byteLength;
334332
controlPos += 24
335333
) {
334+
if (writeError) {
335+
break;
336+
}
336337
const readDiffBy = offtin(controlBlock, controlPos);
337338
const readExtraBy = offtin(controlBlock, controlPos + 8);
338339
const seekBy = offtin(controlBlock, controlPos + 16);
@@ -367,7 +368,16 @@ export async function applyPatch(
367368
}
368369
} finally {
369370
try {
370-
await writer.end();
371+
await new Promise<void>((resolve, reject) => {
372+
writer.end((err?: Error | null) => {
373+
const finalErr = err ?? writeError;
374+
if (finalErr) {
375+
reject(finalErr);
376+
} else {
377+
resolve();
378+
}
379+
});
380+
});
371381
} finally {
372382
await cleanupOldFile();
373383
}
@@ -380,5 +390,5 @@ export async function applyPatch(
380390
);
381391
}
382392

383-
return hasher.digest("hex") as string;
393+
return hasher.digest("hex");
384394
}

src/lib/telemetry/zstd-transport.ts

Lines changed: 17 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,9 @@
88
* SDK's default behavior byte-for-byte so there's no regression.
99
*
1010
* Codec selection is one-shot, performed at factory-construction time.
11-
* No per-request branching: if `Bun.zstdCompress` is available when the
12-
* transport is created, every envelope uses zstd; otherwise every
13-
* envelope uses gzip.
11+
* No per-request branching: if `node:zlib` zstd support is available
12+
* when the transport is created, every envelope uses zstd; otherwise
13+
* every envelope uses gzip.
1414
*
1515
* This mirrors `@sentry/node-core/transports/http.js` `makeNodeTransport`
1616
* — URL parsing, `no_proxy` handling, proxy agent, CA certs, keepAlive,
@@ -32,7 +32,8 @@ import * as http from "node:http";
3232
import * as https from "node:https";
3333
import { Readable } from "node:stream";
3434
import { promisify } from "node:util";
35-
import { gzip as gzipCb } from "node:zlib";
35+
// biome-ignore lint/performance/noNamespaceImport: needed for feature-detected zstd access
36+
import * as zlib from "node:zlib";
3637
import {
3738
createTransport,
3839
suppressTracing,
@@ -77,20 +78,13 @@ const ZSTD_THRESHOLD = 1024;
7778
*/
7879
const GZIP_THRESHOLD = 1024 * 32;
7980

80-
/**
81-
* Shape of the globalThis.Bun subset we rely on. Bun's real types
82-
* declare this, but the transport also runs under Node (via the
83-
* feature-detected polyfill in `script/node-polyfills.ts`) where only
84-
* a subset of Bun APIs are installed.
85-
*/
86-
type BunZstdHost = {
87-
zstdCompress?: (
88-
data: Uint8Array | Buffer | string | ArrayBuffer,
89-
options?: { level?: number }
90-
) => Promise<Buffer>;
91-
};
92-
93-
const gzipAsync = promisify(gzipCb);
81+
const gzipAsync = promisify(zlib.gzip);
82+
// zstdCompress is available in Node 22.15+. Feature-detect to avoid crashing
83+
// the npm bundle on older Node versions (e.g., CI runners with Node 20).
84+
const zstdCompressAsync =
85+
typeof (zlib as { zstdCompress?: unknown }).zstdCompress === "function"
86+
? promisify((zlib as { zstdCompress: typeof zlib.gzip }).zstdCompress)
87+
: undefined;
9488

9589
/**
9690
* Factory for the SDK's `Sentry.init({ transport })` option.
@@ -285,20 +279,10 @@ export async function maybeCompress(
285279
return { payload: buf, encodingApplied: "none" };
286280
}
287281

288-
if (encoding === "zstd") {
289-
// Belt-and-braces — `Bun` may have been swapped out between
290-
// construction and first send. Fall through to gzip if so, but
291-
// re-apply the gzip threshold so mid-sized bodies (1-32 KiB) don't
292-
// get compressed when the SDK default would have shipped them raw.
293-
const bun = (globalThis as { Bun?: BunZstdHost }).Bun;
294-
if (!bun?.zstdCompress) {
295-
if (buf.length <= GZIP_THRESHOLD) {
296-
return { payload: buf, encodingApplied: "none" };
297-
}
298-
const gz = await gzipAsync(buf);
299-
return { payload: gz, encodingApplied: "gzip" };
300-
}
301-
const out = await bun.zstdCompress(buf, { level: ZSTD_LEVEL });
282+
if (encoding === "zstd" && zstdCompressAsync) {
283+
const out = await zstdCompressAsync(buf, {
284+
params: { [zlib.constants.ZSTD_c_compressionLevel]: ZSTD_LEVEL },
285+
});
302286
return {
303287
payload: Buffer.from(out.buffer, out.byteOffset, out.byteLength),
304288
encodingApplied: "zstd",
@@ -311,8 +295,7 @@ export async function maybeCompress(
311295

312296
/** Feature-detect zstd support on the current runtime. */
313297
export function hasZstdSupport(): boolean {
314-
const bun = (globalThis as { Bun?: BunZstdHost }).Bun;
315-
return typeof bun?.zstdCompress === "function";
298+
return zstdCompressAsync !== undefined;
316299
}
317300

318301
/**

0 commit comments

Comments
 (0)