From 03d4440df9eed7705c1326e102fe63bd6a66ea63 Mon Sep 17 00:00:00 2001 From: Yaacov Rydzinski Date: Tue, 1 Jul 2025 21:16:21 +0300 Subject: [PATCH] add support for conditional exports this commits adds support for conditional exports bun, module, module-sync => ESM deno, node and require => CJS default => ESM. deno and node (in versions that do not support module-sync) point to CJS even when using import so as to avoid the dual-package hazard. the require condition points to CJS just because our default is otherwise now ESM. --- cspell.yml | 1 + integrationTests/README.md | 4 + integrationTests/conditions/check.mjs | 21 +++++ integrationTests/conditions/cjs-importer.cjs | 11 +++ integrationTests/conditions/package.json | 10 +++ integrationTests/conditions/test.js | 31 ++++++++ resources/build-npm.ts | 83 ++++++++++++++++---- resources/integration-test.ts | 3 + resources/utils.ts | 15 +++- 9 files changed, 161 insertions(+), 18 deletions(-) create mode 100644 integrationTests/conditions/check.mjs create mode 100644 integrationTests/conditions/cjs-importer.cjs create mode 100644 integrationTests/conditions/package.json create mode 100644 integrationTests/conditions/test.js diff --git a/cspell.yml b/cspell.yml index fe9583e043..9a713bca1d 100644 --- a/cspell.yml +++ b/cspell.yml @@ -66,6 +66,7 @@ words: # TODO: contribute upstream - deno + - denoland - hashbang - Rspack - Rollup diff --git a/integrationTests/README.md b/integrationTests/README.md index bcfe425cfd..c510a5f619 100644 --- a/integrationTests/README.md +++ b/integrationTests/README.md @@ -14,6 +14,10 @@ Each subdirectory represents a different environment/bundler: - `ts` - tests for supported Typescript versions - `webpack` - tests for Webpack +### Verifying Conditional Exports + +The `conditions` subdirectory contains tests that verify the conditional exports of GraphQL.js. These tests ensure that the correct files are imported based on the environment being used. + ### Verifying Development Mode Tests Each subdirectory represents a different environment/bundler demonstrating enabling development mode by setting the environment variable `NODE_ENV` to `development`. diff --git a/integrationTests/conditions/check.mjs b/integrationTests/conditions/check.mjs new file mode 100644 index 0000000000..c5086d3a82 --- /dev/null +++ b/integrationTests/conditions/check.mjs @@ -0,0 +1,21 @@ +import assert from 'node:assert'; + +import { GraphQLObjectType as ESMGraphQLObjectType } from 'graphql'; + +import { CJSGraphQLObjectType, cjsPath } from './cjs-importer.cjs'; + +const moduleSync = process.env.MODULE_SYNC === 'true'; +const expectedExtension = moduleSync ? '.mjs' : '.js'; +assert.ok( + cjsPath.endsWith(expectedExtension), + `require('graphql') should resolve to a file with extension "${expectedExtension}", but got "${cjsPath}".`, +); + +const isSameModule = ESMGraphQLObjectType === CJSGraphQLObjectType; +assert.strictEqual( + isSameModule, + true, + 'ESM and CJS imports should be the same module instances.', +); + +console.log('Module identity and path checks passed.'); diff --git a/integrationTests/conditions/cjs-importer.cjs b/integrationTests/conditions/cjs-importer.cjs new file mode 100644 index 0000000000..fbb7520c12 --- /dev/null +++ b/integrationTests/conditions/cjs-importer.cjs @@ -0,0 +1,11 @@ +'use strict'; + +const { GraphQLObjectType } = require('graphql'); + +const cjsPath = require.resolve('graphql'); + +// eslint-disable-next-line import/no-commonjs +module.exports = { + CJSGraphQLObjectType: GraphQLObjectType, + cjsPath, +}; diff --git a/integrationTests/conditions/package.json b/integrationTests/conditions/package.json new file mode 100644 index 0000000000..deb2effe5c --- /dev/null +++ b/integrationTests/conditions/package.json @@ -0,0 +1,10 @@ +{ + "description": "graphql-js should be loaded correctly on different versions of Node.js, Deno and Bun", + "private": true, + "scripts": { + "test": "node test.js" + }, + "dependencies": { + "graphql": "file:../graphql.tgz" + } +} diff --git a/integrationTests/conditions/test.js b/integrationTests/conditions/test.js new file mode 100644 index 0000000000..eea21164f3 --- /dev/null +++ b/integrationTests/conditions/test.js @@ -0,0 +1,31 @@ +import childProcess from 'node:child_process'; + +const nodeTests = [ + // Old node versions, require => CJS + { version: '20.18.0', moduleSync: false }, + { version: '22.11.0', moduleSync: false }, + // New node versions, module-sync => ESM + { version: '20.19.0', moduleSync: true }, + { version: '22.12.0', moduleSync: true }, + { version: '24.0.0', moduleSync: true }, +]; + +for (const { version, moduleSync } of nodeTests) { + console.log(`Testing on node@${version} (moduleSync: ${moduleSync}) ...`); + childProcess.execSync( + `docker run --rm --volume "$PWD":/usr/src/app -w /usr/src/app --env MODULE_SYNC=${moduleSync} node:${version}-slim node ./check.mjs`, + { stdio: 'inherit' }, + ); +} + +console.log('Testing on bun (moduleSync: true) ...'); +childProcess.execSync( + `docker run --rm --volume "$PWD":/usr/src/app -w /usr/src/app --env MODULE_SYNC=true oven/bun:alpine bun ./check.mjs`, + { stdio: 'inherit' }, +); + +console.log('Testing on deno (moduleSync: false) ...'); +childProcess.execSync( + `docker run --rm --volume "$PWD":/usr/src/app -w /usr/src/app --env MODULE_SYNC=false denoland/deno:2.4.0 deno run --allow-read --allow-env ./check.mjs`, + { stdio: 'inherit' }, +); diff --git a/resources/build-npm.ts b/resources/build-npm.ts index c29747efb8..b56a8bab09 100644 --- a/resources/build-npm.ts +++ b/resources/build-npm.ts @@ -6,6 +6,7 @@ import ts from 'typescript'; import { changeExtensionInImportPaths } from './change-extension-in-import-paths.js'; import { inlineInvariant } from './inline-invariant.js'; +import type { ConditionalExports } from './utils.js'; import { prettify, readPackageJSON, @@ -98,7 +99,6 @@ async function buildPackage(outDir: string, isESMOnly: boolean): Promise { } } - // Temporary workaround to allow "internal" imports, no grantees provided packageJSON.exports['./*.js'] = './*.js'; packageJSON.exports['./*'] = './*.js'; @@ -106,10 +106,28 @@ async function buildPackage(outDir: string, isESMOnly: boolean): Promise { packageJSON.version += '+esm'; } else { delete packageJSON.type; - packageJSON.main = 'index'; + packageJSON.main = 'index.js'; packageJSON.module = 'index.mjs'; - emitTSFiles({ outDir, module: 'commonjs', extension: '.js' }); + packageJSON.types = 'index.d.ts'; + + const { emittedTSFiles } = emitTSFiles({ + outDir, + module: 'commonjs', + extension: '.js', + }); emitTSFiles({ outDir, module: 'es2020', extension: '.mjs' }); + + packageJSON.exports = {}; + for (const filepath of emittedTSFiles) { + if (path.basename(filepath) === 'index.js') { + const relativePath = './' + path.relative('./npmDist', filepath); + packageJSON.exports[path.dirname(relativePath)] = + buildExports(relativePath); + } + } + + packageJSON.exports['./*.js'] = buildExports('./*.js'); + packageJSON.exports['./*'] = buildExports('./*.js'); } const packageJsonPath = `./${outDir}/package.json`; @@ -141,21 +159,31 @@ function emitTSFiles(options: { const tsHost = ts.createCompilerHost(tsOptions); tsHost.writeFile = (filepath, body) => { - if (filepath.match(/.js$/) && extension === '.mjs') { - let bodyToWrite = body; - bodyToWrite = bodyToWrite.replace( - '//# sourceMappingURL=graphql.js.map', - '//# sourceMappingURL=graphql.mjs.map', - ); - writeGeneratedFile(filepath.replace(/.js$/, extension), bodyToWrite); - } else if (filepath.match(/.js.map$/) && extension === '.mjs') { - writeGeneratedFile( - filepath.replace(/.js.map$/, extension + '.map'), - body, - ); - } else { - writeGeneratedFile(filepath, body); + if (extension === '.mjs') { + if (filepath.match(/.js$/)) { + let bodyToWrite = body; + bodyToWrite = bodyToWrite.replace( + '//# sourceMappingURL=graphql.js.map', + '//# sourceMappingURL=graphql.mjs.map', + ); + writeGeneratedFile(filepath.replace(/.js$/, extension), bodyToWrite); + return; + } + + if (filepath.match(/.js.map$/)) { + writeGeneratedFile( + filepath.replace(/.js.map$/, extension + '.map'), + body, + ); + return; + } + + if (filepath.match(/.d.ts$/)) { + writeGeneratedFile(filepath.replace(/.d.ts$/, '.d.mts'), body); + return; + } } + writeGeneratedFile(filepath, body); }; const tsProgram = ts.createProgram(['src/index.ts'], tsOptions, tsHost); @@ -172,3 +200,24 @@ function emitTSFiles(options: { emittedTSFiles: tsResult.emittedFiles.sort((a, b) => a.localeCompare(b)), }; } + +function buildExports(filepath: string): ConditionalExports { + const { dir, name } = path.parse(filepath); + const base = `./${path.join(dir, name)}`; + return { + types: { + module: `${base}.d.mts`, + 'module-sync': `${base}.d.mts`, + bun: `${base}.d.mts`, + node: `${base}.d.ts`, + require: `${base}.d.ts`, + default: `${base}.d.mts`, + }, + module: `${base}.mjs`, + bun: `${base}.mjs`, + 'module-sync': `${base}.mjs`, + node: `${base}.js`, + require: `${base}.js`, + default: `${base}.mjs`, + }; +} diff --git a/resources/integration-test.ts b/resources/integration-test.ts index afc391011a..02f2bc8dab 100644 --- a/resources/integration-test.ts +++ b/resources/integration-test.ts @@ -39,6 +39,9 @@ describe('Integration Tests', () => { testOnNodeProject('node'); testOnNodeProject('webpack'); + // Conditional export tests + testOnNodeProject('conditions'); + // Development mode tests testOnNodeProject('dev-node'); testOnNodeProject('dev-deno'); diff --git a/resources/utils.ts b/resources/utils.ts index 4291ddc20a..94b7ed0155 100644 --- a/resources/utils.ts +++ b/resources/utils.ts @@ -234,7 +234,7 @@ interface PackageJSON { repository?: { url?: string }; scripts?: { [name: string]: string }; type?: string; - exports: { [path: string]: string }; + exports: { [path: string]: string | ConditionalExports }; types?: string; typesVersions: { [ranges: string]: { [path: string]: Array } }; devDependencies?: { [name: string]: string }; @@ -245,6 +245,19 @@ interface PackageJSON { module?: string; } +export interface ConditionalExports extends BaseExports { + types: BaseExports; +} + +interface BaseExports { + module: string; + bun: string; + 'module-sync': string; + node: string; + require: string; + default: string; +} + export function readPackageJSON( dirPath: string = localRepoPath(), ): PackageJSON {