Skip to content

Commit 907799d

Browse files
JPeer264BYK
andauthored
feat: Add ESM export (#1288)
This adds support for ESM exports. The issue is that `zlib` does not have any ESM support below Node v22. For that reason we need to wrap Test error Nuxt app (ESM sourcemap upload): https://sentry-sdks.sentry.io/issues/7630622367/ Test error Next app (CJS sourcemap upload): https://sentry-sdks.sentry.io/issues/7629023314/ --------- Co-authored-by: Burak Yigit Kaya <byk@sentry.io>
1 parent 3ddfb22 commit 907799d

3 files changed

Lines changed: 202 additions & 31 deletions

File tree

package.json

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -66,9 +66,14 @@
6666
},
6767
"exports": {
6868
".": {
69-
"types": "./dist/index.d.cts",
70-
"require": "./dist/index.cjs",
71-
"default": "./dist/index.cjs"
69+
"import": {
70+
"types": "./dist/index.d.mts",
71+
"default": "./dist/index.mjs"
72+
},
73+
"require": {
74+
"types": "./dist/index.d.cts",
75+
"default": "./dist/index.cjs"
76+
}
7277
}
7378
},
7479
"bin": {
@@ -87,7 +92,9 @@
8792
"files": [
8893
"dist/bin.cjs",
8994
"dist/index.cjs",
95+
"dist/index.mjs",
9096
"dist/index.d.cts",
97+
"dist/index.d.mts",
9198
"dist/ink-app.js",
9299
"dist/node-sqlite3-wasm.wasm",
93100
"dist/vendor/symbolic_bg.wasm"

script/bundle.ts

Lines changed: 139 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,10 @@ const sentrySourcemapPlugin: Plugin = {
114114
pluginBuild.onEnd(async (buildResult) => {
115115
const outputs = Object.keys(buildResult.metafile?.outputs ?? {});
116116
const jsFiles = outputs.filter(
117-
(p) => p.endsWith(".cjs") || (p.endsWith(".js") && !p.endsWith(".map"))
117+
(p) =>
118+
p.endsWith(".cjs") ||
119+
p.endsWith(".mjs") ||
120+
(p.endsWith(".js") && !p.endsWith(".map"))
118121
);
119122

120123
if (jsFiles.length === 0) {
@@ -164,6 +167,9 @@ const sentrySourcemapPlugin: Plugin = {
164167
const REQUIRE_ALIAS_FILTER =
165168
/(?:db[\\/](?:index|schema|sqlite)|list-command|telemetry)\.ts$/;
166169
const REQUIRE_ALIAS_RE = /\b_require\(/g;
170+
const IDENTIFIER_FILTER_RE = /^[A-Za-z_$][\w$]*$/;
171+
const NODE_ZLIB_RE = /^node:zlib$/;
172+
const ANY_RE = /.*/;
167173

168174
/** Transform _require() → require() so esbuild resolves lazy relative requires. */
169175
const requireAliasPlugin: Plugin = {
@@ -179,6 +185,66 @@ const requireAliasPlugin: Plugin = {
179185
},
180186
};
181187

188+
/**
189+
* ESM-only: make `node:zlib` imports resolve through a namespace shim so that
190+
* named imports of version-gated exports (the `zstd*` family, added in Node
191+
* 22.15) become dynamic property reads instead of static named imports.
192+
*
193+
* In the CJS build these are `require("node:zlib").zstdCompress` — a lazy
194+
* property that is simply `undefined` on older Node, which the code
195+
* feature-detects and falls back to gzip. In ESM, esbuild hoists them to
196+
* `import { zstdCompress } from "node:zlib"`, which Node validates at link
197+
* time; on Node < 22.15 the missing export makes the WHOLE module fail to
198+
* load, breaking the package's advertised Node 18+ support. Routing through
199+
* `import * as zlib` keeps every access dynamic and link-safe.
200+
*/
201+
const zlibNamespaceShimPlugin: Plugin = {
202+
name: "zlib-namespace-shim",
203+
setup(b) {
204+
const NS = "sentry:node-zlib-shim";
205+
206+
// Re-export every name node:zlib exposes on the (newest) build runtime plus
207+
// the zstd family, each as a dynamic property read. The build node is a
208+
// superset of older runtimes, so this covers every name any consumer imports
209+
// (createGzip, brotli*, zstd*, …); names absent at runtime read as undefined,
210+
// which the code feature-detects. `\bimport`/valid-identifier filtering keeps
211+
// the generated module syntactically valid.
212+
const zlibExportNames = Object.keys(require("node:zlib"));
213+
const names = new Set(
214+
[
215+
...zlibExportNames,
216+
"zstdCompress",
217+
"zstdCompressSync",
218+
"zstdDecompress",
219+
"zstdDecompressSync",
220+
"createZstdCompress",
221+
"createZstdDecompress",
222+
].filter((n) => IDENTIFIER_FILTER_RE.test(n) && n !== "default")
223+
);
224+
225+
// Redirect node:zlib to the shim — EXCEPT the shim's own re-export below,
226+
// which must reach the real builtin (otherwise it resolves to itself and
227+
// every member reads back as undefined). esbuild marks the real builtin
228+
// external so it stays a runtime lookup.
229+
b.onResolve({ filter: NODE_ZLIB_RE }, (args) =>
230+
args.namespace === NS
231+
? { path: "node:zlib", external: true }
232+
: { path: NS, namespace: NS }
233+
);
234+
235+
b.onLoad({ filter: ANY_RE, namespace: NS }, () => ({
236+
contents: [
237+
`import * as zlib from "node:zlib";`,
238+
"export default zlib;",
239+
...[...names].map(
240+
(n) => `export const ${n} = zlib[${JSON.stringify(n)}];`
241+
),
242+
].join("\n"),
243+
loader: "js",
244+
}));
245+
},
246+
};
247+
182248
const plugins: Plugin[] = [
183249
sentrySourcemapPlugin,
184250
textImportPlugin,
@@ -193,40 +259,29 @@ if (process.env.SENTRY_AUTH_TOKEN) {
193259
);
194260
}
195261

196-
const result = await build({
262+
// Options shared by both the CJS and ESM library builds. Only the module
263+
// format, output path, and format-specific interop shims differ between them.
264+
const commonBuildOptions = {
197265
entryPoints: ["./src/index.ts"],
198266
bundle: true,
199267
minify: true,
200268
treeShaking: true,
201-
// No banner — warning suppression moved to dist/bin.cjs (CLI-only).
202-
// The library bundle must not suppress the host application's warnings.
269+
// No banner (beyond format shims) — warning suppression moved to dist/bin.cjs
270+
// (CLI-only). The library bundle must not suppress the host app's warnings.
203271
sourcemap: true,
204272
platform: "node",
205273
// Target Node.js 18 — the published package's floor (engines.node).
206274
// Older Node.js uses the bundled WASM SQLite driver (node-sqlite3-wasm);
207275
// 22.15+ uses the native node:sqlite. Downlevels newer syntax accordingly.
208276
target: "node18",
209-
format: "cjs",
210-
outfile: "./dist/index.cjs",
211-
// Inject Bun polyfills and import.meta.url shim for CJS compatibility
212-
inject: ["./script/node-polyfills.ts", "./script/import-meta-url.js"],
213-
define: {
214-
SENTRY_CLI_VERSION: JSON.stringify(VERSION),
215-
SENTRY_CLIENT_ID_BUILD: JSON.stringify(SENTRY_CLIENT_ID),
216-
"process.env.NODE_ENV": JSON.stringify("production"),
217-
__SENTRY_DEBUG_ID__: JSON.stringify(PLACEHOLDER_DEBUG_ID),
218-
// Replace import.meta.url with the injected shim variable for CJS
219-
"import.meta.url": "import_meta_url",
220-
},
221277
// Externalize Node.js built-ins, plus Ink + React + companions.
222-
// These packages are NOT bundled into the main CJS output because
223-
// they use top-level await (esbuild can't emit that in CJS).
224-
// Instead, the Ink UI lives in a separate self-contained ESM
225-
// sidecar (`dist/ink-app.js`) that the text-import-plugin
226-
// pre-bundles with all deps inlined. The main bundle references
227-
// the sidecar via a path string and loads it lazily via dynamic
228-
// `import()` at runtime. The external list here prevents esbuild
229-
// from trying to resolve these packages in the main bundle graph.
278+
// These packages are NOT bundled into the main output because they use
279+
// top-level await (esbuild can't emit that in CJS). Instead, the Ink UI
280+
// lives in a separate self-contained ESM sidecar (`dist/ink-app.js`) that
281+
// the text-import-plugin pre-bundles with all deps inlined. The main bundle
282+
// references the sidecar via a path string and loads it lazily via dynamic
283+
// `import()` at runtime. The external list here prevents esbuild from trying
284+
// to resolve these packages in the main bundle graph.
230285
external: [
231286
"node:*",
232287
// The DIF loader resolves this .wasm at runtime (dev only); never bundle it.
@@ -242,6 +297,54 @@ const result = await build({
242297
],
243298
metafile: true,
244299
plugins,
300+
} satisfies Parameters<typeof build>[0];
301+
302+
// Defines shared by both formats. The CJS build additionally rewrites
303+
// `import.meta.url` to a shim; the ESM build uses it natively.
304+
const commonDefine: Record<string, string> = {
305+
SENTRY_CLI_VERSION: JSON.stringify(VERSION),
306+
SENTRY_CLIENT_ID_BUILD: JSON.stringify(SENTRY_CLIENT_ID),
307+
"process.env.NODE_ENV": JSON.stringify("production"),
308+
__SENTRY_DEBUG_ID__: JSON.stringify(PLACEHOLDER_DEBUG_ID),
309+
};
310+
311+
// In an ESM bundle, `require`, `__dirname`, and `__filename` don't exist, but
312+
// the inlined `node-sqlite3-wasm` Emscripten glue (and other lazily-required
313+
// modules) still reference them. Recreate them from `import.meta.url` so the
314+
// WASM SQLite fallback resolves `dist/node-sqlite3-wasm.wasm` correctly.
315+
const ESM_INTEROP_BANNER = [
316+
`import { createRequire as __sentryCreateRequire } from "node:module";`,
317+
`import { fileURLToPath as __sentryFileURLToPath } from "node:url";`,
318+
`import { dirname as __sentryDirname } from "node:path";`,
319+
"const require = __sentryCreateRequire(import.meta.url);",
320+
"const __filename = __sentryFileURLToPath(import.meta.url);",
321+
"const __dirname = __sentryDirname(__filename);",
322+
].join("\n");
323+
324+
const result = await build({
325+
...commonBuildOptions,
326+
format: "cjs",
327+
outfile: "./dist/index.cjs",
328+
// Inject Bun polyfills and import.meta.url shim for CJS compatibility
329+
inject: ["./script/node-polyfills.ts", "./script/import-meta-url.js"],
330+
define: {
331+
...commonDefine,
332+
// Replace import.meta.url with the injected shim variable for CJS
333+
"import.meta.url": "import_meta_url",
334+
},
335+
});
336+
337+
const esmResult = await build({
338+
...commonBuildOptions,
339+
format: "esm",
340+
outfile: "./dist/index.mjs",
341+
// ESM has native import.meta.url — only the Bun polyfills need injecting.
342+
inject: ["./script/node-polyfills.ts"],
343+
define: { ...commonDefine },
344+
banner: { js: ESM_INTEROP_BANNER },
345+
// ESM-only: keep node:zlib access dynamic so version-gated zstd exports don't
346+
// become link-time-fatal named imports on Node < 22.15 (see plugin comment).
347+
plugins: [...commonBuildOptions.plugins, zlibNamespaceShimPlugin],
245348
});
246349

247350
// Write the CLI bin wrapper (tiny — shebang + version check + dispatch).
@@ -296,10 +399,14 @@ export default createSentrySDK;
296399
const sdkTypes = await readFile("./src/sdk.generated.d.cts", "utf-8");
297400

298401
const TYPE_DECLARATIONS = `${CORE_DECLARATIONS}\n${sdkTypes}\n`;
402+
// The declarations are self-contained (no relative imports), so the same content
403+
// serves both module systems: `.d.cts` for the `require` condition and `.d.mts`
404+
// for the `import` condition.
299405
await writeFile("./dist/index.d.cts", TYPE_DECLARATIONS);
406+
await writeFile("./dist/index.d.mts", TYPE_DECLARATIONS);
300407

301408
console.log(" -> dist/bin.cjs (CLI wrapper)");
302-
console.log(" -> dist/index.d.cts (type declarations)");
409+
console.log(" -> dist/index.d.cts + dist/index.d.mts (type declarations)");
303410

304411
// The `ink-app.js` sidecar (pre-bundled by text-import-plugin) ships
305412
// with the npm package so `npx sentry@latest init` can load the
@@ -330,10 +437,14 @@ await copyFile(
330437
console.log(" -> dist/node-sqlite3-wasm.wasm (WASM SQLite fallback)");
331438

332439
// Calculate bundle size (only the main bundle, not source maps)
333-
const bundleOutput = result.metafile?.outputs["dist/index.cjs"];
334-
const bundleSize = bundleOutput?.bytes ?? 0;
335-
const bundleSizeKB = (bundleSize / 1024).toFixed(1);
440+
const bundleSizeKB = (
441+
(result.metafile?.outputs["dist/index.cjs"]?.bytes ?? 0) / 1024
442+
).toFixed(1);
443+
const esmBundleSizeKB = (
444+
(esmResult.metafile?.outputs["dist/index.mjs"]?.bytes ?? 0) / 1024
445+
).toFixed(1);
336446

337447
console.log(`\n -> dist/index.cjs (${bundleSizeKB} KB)`);
448+
console.log(` -> dist/index.mjs (${esmBundleSizeKB} KB)`);
338449
console.log(`\n${"=".repeat(40)}`);
339450
console.log("Bundle complete!");
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { existsSync } from "node:fs";
2+
import { fileURLToPath } from "node:url";
3+
import { describe, expect, test } from "vitest";
4+
import pkg from "../../package.json";
5+
6+
/**
7+
* Guards the dual-package (ESM + CJS) contract of the published `sentry`
8+
* library. The bundler-plugin SDK and other ESM consumers import the default
9+
* export; a regression to a CJS-only `exports` map silently breaks them
10+
* (`createSentrySDK is not a function`) even though unit tests still pass.
11+
*/
12+
describe("package.json exports (dual ESM/CJS)", () => {
13+
const root = new URL("../../", import.meta.url);
14+
const resolve = (rel: string) => fileURLToPath(new URL(rel, root));
15+
16+
test("declares both import and require conditions with matching types", () => {
17+
const dot = pkg.exports["."] as {
18+
import?: { types?: string; default?: string };
19+
require?: { types?: string; default?: string };
20+
};
21+
22+
expect(dot.import?.default).toBe("./dist/index.mjs");
23+
expect(dot.import?.types).toBe("./dist/index.d.mts");
24+
expect(dot.require?.default).toBe("./dist/index.cjs");
25+
expect(dot.require?.types).toBe("./dist/index.d.cts");
26+
});
27+
28+
test("ships both bundles and their type declarations in files", () => {
29+
for (const f of [
30+
"dist/index.mjs",
31+
"dist/index.cjs",
32+
"dist/index.d.mts",
33+
"dist/index.d.cts",
34+
]) {
35+
expect(pkg.files).toContain(f);
36+
}
37+
});
38+
39+
// Only meaningful after a build; skipped in a clean checkout where dist/ is absent.
40+
const built = existsSync(resolve("dist/index.mjs"));
41+
test.runIf(built)("built ESM entry exposes createSentrySDK", async () => {
42+
const mod = await import(resolve("dist/index.mjs"));
43+
expect(typeof mod.default).toBe("function");
44+
expect(typeof mod.createSentrySDK).toBe("function");
45+
});
46+
47+
test.runIf(built)("built CJS entry exposes createSentrySDK", () => {
48+
// eslint-disable-next-line @typescript-eslint/no-require-imports
49+
const mod = require(resolve("dist/index.cjs"));
50+
expect(typeof mod.createSentrySDK).toBe("function");
51+
expect(typeof mod.default).toBe("function");
52+
});
53+
});

0 commit comments

Comments
 (0)