diff --git a/CHANGELOG.md b/CHANGELOG.md index 4bf7f74..2f8b35d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ ## Change log +### Version: 1.5.1 +#### Date: August-05-2026 + - Fix: Resolve `require()` incompatibility that broke bundled and native ESM builds. The keep-alive agent setup used a dynamic `require(moduleName)`, which bundlers (webpack/Turbopack, e.g. Next.js) cannot statically analyze — producing `MODULE_NOT_FOUND` / "expression is too dynamic" build failures. It now uses statically analyzable `require('http')`/`require('https')` calls. + - Fix: Guard keep-alive agent creation for native ESM environments (where `require` is undefined) to prevent a `ReferenceError`. + - Fix: Add a package `browser` field mapping `http`/`https` to `false`, and externalize them in the UMD build, so browser bundles resolve cleanly. + ### Version: 1.5.0 #### Date: August-03-2026 - Fix: Classify request timeouts (`ECONNABORTED`) distinctly instead of a generic `UNKNOWN_ERROR`, preserving the real error code and message diff --git a/config/webpack.config.js b/config/webpack.config.js index 0834619..63683ac 100644 --- a/config/webpack.config.js +++ b/config/webpack.config.js @@ -27,4 +27,13 @@ module.exports = { resolve: { extensions: ['.ts', '.js', '.tsx', '.jsx'], }, + // 'http' and 'https' are Node-only built-ins. Marking them external means: + // - In Node.js (CJS/UMD): the UMD wrapper calls require('http') at runtime → real module + // - In browser: the UMD wrapper looks up window['http'] / window['https'] → undefined, but + // isNodeRuntime() is false there so createHttpAgent()/createHttpsAgent() short-circuit + // before reaching require() + externals: { + http: { commonjs: 'http', commonjs2: 'http', root: 'http' }, + https: { commonjs: 'https', commonjs2: 'https', root: 'https' }, + }, }; diff --git a/package-lock.json b/package-lock.json index 3d441c0..0a92026 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@contentstack/core", - "version": "1.5.0", + "version": "1.5.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@contentstack/core", - "version": "1.5.0", + "version": "1.5.1", "license": "MIT", "dependencies": { "axios": "^1.18.1", diff --git a/package.json b/package.json index e4d282b..4088889 100644 --- a/package.json +++ b/package.json @@ -1,9 +1,13 @@ { "name": "@contentstack/core", - "version": "1.5.0", + "version": "1.5.1", "type": "commonjs", "main": "./dist/cjs/src/index.js", "types": "./dist/cjs/src/index.d.ts", + "browser": { + "http": false, + "https": false + }, "exports": { ".": { "import": { diff --git a/src/lib/contentstack-core.ts b/src/lib/contentstack-core.ts index 8a2aa2d..dc29dcf 100644 --- a/src/lib/contentstack-core.ts +++ b/src/lib/contentstack-core.ts @@ -6,13 +6,24 @@ import { ERROR_MESSAGES } from './error-messages'; const isNodeEnvironment = typeof window === 'undefined'; -// Guarded require: keeps 'http'/'https' out of browser bundles, which have no browser field of their own to redirect this. -function createKeepAliveAgent(moduleName: 'http' | 'https') { - if (!isNodeEnvironment) { - return false as const; - } +// In Node we default to keep-alive agents so TCP connections are reused under concurrent load. +// Guards: `isNodeEnvironment` skips browsers; `typeof require` skips native ESM (where `require` is undefined). +// The requires use literal 'http'/'https' so bundlers (Turbopack/webpack) can statically analyze them, and the +// package.json "browser" field maps those modules to `false` so browser bundles never try to resolve them. +function isNodeRuntime(): boolean { + return isNodeEnvironment && typeof require !== 'undefined'; +} + +function createHttpAgent() { + if (!isNodeRuntime()) return false as const; + + return new (require('http').Agent)({ keepAlive: true }); +} + +function createHttpsAgent() { + if (!isNodeRuntime()) return false as const; - return new (require(moduleName).Agent)({ keepAlive: true }); + return new (require('https').Agent)({ keepAlive: true }); } export function httpClient(options: HttpClientParams): AxiosInstance { @@ -22,8 +33,8 @@ export function httpClient(options: HttpClientParams): AxiosInstance { headers: {} as AxiosRequestHeaders, basePath: '', proxy: false as const, - httpAgent: createKeepAliveAgent('http'), - httpsAgent: createKeepAliveAgent('https'), + httpAgent: createHttpAgent(), + httpsAgent: createHttpsAgent(), timeout: 30000, logHandler: (level: string, data?: any) => { if (level === 'error') { diff --git a/test/contentstack-core.node-agent.spec.ts b/test/contentstack-core.node-agent.spec.ts index 508345b..0228687 100644 --- a/test/contentstack-core.node-agent.spec.ts +++ b/test/contentstack-core.node-agent.spec.ts @@ -3,6 +3,8 @@ */ import http from 'http'; import https from 'https'; +import { execFile } from 'child_process'; +import * as path from 'path'; import { httpClient } from '../src/lib/contentstack-core'; describe('httpClient default connection agents (Node environment)', () => { @@ -19,4 +21,18 @@ describe('httpClient default connection agents (Node environment)', () => { expect(instance.defaults.httpsAgent).toBeInstanceOf(https.Agent); expect((instance.defaults.httpsAgent as any).keepAlive).toBe(true); }); + + it('should not throw and fall back to false agents in native ESM (require is not defined)', (done) => { + // Runs test/esm-compat.mjs as a native ESM child process. + // Regression test for contentstack-js-core#246: ReferenceError: require is not defined. + // Requires `npm run build` to have been run first. + const script = path.join(__dirname, 'esm-compat.mjs'); + execFile(process.execPath, [script], (error, stdout, stderr) => { + if (error) { + done(new Error(`Native ESM test failed:\n${stderr || stdout}`)); + } else { + done(); + } + }); + }); }); diff --git a/test/esm-compat.mjs b/test/esm-compat.mjs new file mode 100644 index 0000000..2d346bb --- /dev/null +++ b/test/esm-compat.mjs @@ -0,0 +1,44 @@ +/** + * Native ESM integration test for @contentstack/core. + * + * Run after `npm run build`: + * node test/esm-compat.mjs + * + * Verifies that importing httpClient via ESM (where require is not defined) + * does not throw a ReferenceError and falls back to false for http/https agents. + * This catches the regression reported in contentstack-js-core#246. + */ +import { httpClient } from '../dist/esm/src/index.js'; + +let failed = false; + +function assert(condition, message) { + if (!condition) { + process.stderr.write(`FAIL: ${message}\n`); + failed = true; + } else { + process.stdout.write(`PASS: ${message}\n`); + } +} + +let instance; +try { + instance = httpClient({}); +} catch (err) { + process.stderr.write(`FAIL: httpClient() threw in native ESM: ${err.message}\n`); + process.exit(1); +} + +// In native ESM, require is not defined, so createHttpAgent()/createHttpsAgent() must fall back to false +assert( + instance.defaults.httpAgent === false, + `httpAgent should be false in native ESM (got ${instance.defaults.httpAgent})` +); + +assert( + instance.defaults.httpsAgent === false, + `httpsAgent should be false in native ESM (got ${instance.defaults.httpsAgent})` +); + +if (failed) process.exit(1); +process.stdout.write('ESM compat: all checks passed\n'); diff --git a/test/esm-exports.spec.ts b/test/esm-exports.spec.ts index da07797..d266abb 100644 --- a/test/esm-exports.spec.ts +++ b/test/esm-exports.spec.ts @@ -106,6 +106,30 @@ describe('ESM Exports Tests', () => { }); }); + describe('ESM Build - require() Safety (Turbopack/webpack compatibility)', () => { + const esmCorePath = path.join(distPath, 'esm', 'src', 'lib', 'contentstack-core.js'); + + it('should have ESM contentstack-core.js built', () => { + expect(fs.existsSync(esmCorePath)).toBe(true); + }); + + it('should not contain dynamic require(variable) expressions that webpack/Turbopack reject', () => { + const content = fs.readFileSync(esmCorePath, 'utf-8'); + // Dynamic require(identifier) — bundlers cannot statically trace these and throw + // "Cannot find module as expression is too dynamic" + expect(content).not.toMatch(/require\([a-zA-Z_$]/); + }); + + it('should use only static string-literal require calls', () => { + const content = fs.readFileSync(esmCorePath, 'utf-8'); + const requireCalls = [...content.matchAll(/require\(([^)]+)\)/g)].map((m) => m[1].trim()); + for (const arg of requireCalls) { + // Every require() arg must be a quoted string literal + expect(arg).toMatch(/^['"`]/); + } + }); + }); + describe('Source Code Imports', () => { it('should be able to import getData as named export from source', async () => { const { getData } = await import('../src');