From a52327ff024f30d2c2a04709728cad83f7304ba9 Mon Sep 17 00:00:00 2001 From: PabloSzx Date: Wed, 24 Nov 2021 15:56:17 -0300 Subject: [PATCH] npm esm tag --- .babelrc-npm.json | 8 ++ .eslintignore | 1 + .eslintrc.yml | 4 + .gitignore | 1 + .prettierignore | 1 + integrationTests/integration-test.js | 11 +++ integrationTests/node-esm/index.js | 36 ++++++++ integrationTests/node-esm/package.json | 15 ++++ integrationTests/node-esm/schema/package.json | 11 +++ integrationTests/node-esm/schema/schema.mjs | 3 + integrationTests/node-esm/test.js | 22 +++++ .../node-esm/version/package.json | 8 ++ integrationTests/node-esm/version/version.js | 5 ++ integrationTests/ts/esm.ts | 38 ++++++++ integrationTests/ts/package.json | 4 +- integrationTests/webpack/entry-esm.mjs | 13 +++ integrationTests/webpack/package.json | 1 + integrationTests/webpack/test.js | 15 +++- integrationTests/webpack/webpack.config.json | 5 +- resources/build-npm.js | 88 +++++++++++++++---- 20 files changed, 269 insertions(+), 21 deletions(-) create mode 100644 integrationTests/node-esm/index.js create mode 100644 integrationTests/node-esm/package.json create mode 100644 integrationTests/node-esm/schema/package.json create mode 100644 integrationTests/node-esm/schema/schema.mjs create mode 100644 integrationTests/node-esm/test.js create mode 100644 integrationTests/node-esm/version/package.json create mode 100644 integrationTests/node-esm/version/version.js create mode 100644 integrationTests/ts/esm.ts create mode 100644 integrationTests/webpack/entry-esm.mjs diff --git a/.babelrc-npm.json b/.babelrc-npm.json index 357c91dbc06..8fe16a74796 100644 --- a/.babelrc-npm.json +++ b/.babelrc-npm.json @@ -22,6 +22,14 @@ "plugins": [ ["./resources/add-extension-to-import-paths", { "extension": "mjs" }] ] + }, + "esm": { + "presets": [ + ["@babel/preset-env", { "modules": false, "targets": { "node": "12" } }] + ], + "plugins": [ + ["./resources/add-extension-to-import-paths", { "extension": "js" }] + ] } } } diff --git a/.eslintignore b/.eslintignore index ec6a952fa78..5bbf6e50d98 100644 --- a/.eslintignore +++ b/.eslintignore @@ -3,6 +3,7 @@ /node_modules /coverage /npmDist +/npmEsmDist /denoDist /npm /deno diff --git a/.eslintrc.yml b/.eslintrc.yml index e13d9ee2e1d..b0a97dd7f87 100644 --- a/.eslintrc.yml +++ b/.eslintrc.yml @@ -442,6 +442,10 @@ rules: yield-star-spacing: off overrides: + - files: + - 'integrationTests/node-esm/**/*.js' + parserOptions: + sourceType: module - files: '**/*.ts' parser: '@typescript-eslint/parser' parserOptions: diff --git a/.gitignore b/.gitignore index 0687d549b7d..f86aca9ee92 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ /node_modules /coverage /npmDist +/npmEsmDist /denoDist /npm /deno diff --git a/.prettierignore b/.prettierignore index 384c2585fbd..5c7535774ce 100644 --- a/.prettierignore +++ b/.prettierignore @@ -4,6 +4,7 @@ /node_modules /coverage /npmDist +/npmEsmDist /denoDist /npm /deno diff --git a/integrationTests/integration-test.js b/integrationTests/integration-test.js index aa5eac2f2e7..3edab8cd2fd 100644 --- a/integrationTests/integration-test.js +++ b/integrationTests/integration-test.js @@ -27,6 +27,16 @@ describe('Integration Tests', () => { path.join(tmpDir, 'graphql.tgz'), ); + const esmDistDir = path.resolve('./npmEsmDist'); + const esmArchiveName = exec(`npm --quiet pack ${esmDistDir}`, { + cwd: tmpDir, + }); + + fs.renameSync( + path.join(tmpDir, esmArchiveName), + path.join(tmpDir, 'graphql-esm.tgz'), + ); + function testOnNodeProject(projectName) { const projectPath = path.join(__dirname, projectName); @@ -44,5 +54,6 @@ describe('Integration Tests', () => { testOnNodeProject('ts'); testOnNodeProject('node'); + testOnNodeProject('node-esm'); testOnNodeProject('webpack'); }); diff --git a/integrationTests/node-esm/index.js b/integrationTests/node-esm/index.js new file mode 100644 index 00000000000..f0a26e51b5c --- /dev/null +++ b/integrationTests/node-esm/index.js @@ -0,0 +1,36 @@ +/* eslint-disable node/no-missing-import, import/no-unresolved, node/no-unsupported-features/es-syntax */ + +import { deepStrictEqual, strictEqual } from 'assert'; + +import { version } from 'version'; +import { schema } from 'schema'; + +import { graphqlSync } from 'graphql'; + +// Import without explicit extension +import { isPromise } from 'graphql/jsutils/isPromise'; + +// Import package.json +import pkg from 'graphql/package.json'; + +deepStrictEqual(`${version}-esm`, pkg.version); + +const result = graphqlSync({ + schema, + source: '{ hello }', + rootValue: { hello: 'world' }, +}); + +deepStrictEqual(result, { + data: { + __proto__: null, + hello: 'world', + }, +}); + +strictEqual(isPromise(Promise.resolve()), true); + +// The possible promise rejection is handled by "--unhandled-rejections=strict" +import('graphql/jsutils/isPromise').then((isPromisePkg) => { + strictEqual(isPromisePkg.isPromise(Promise.resolve()), true); +}); diff --git a/integrationTests/node-esm/package.json b/integrationTests/node-esm/package.json new file mode 100644 index 00000000000..a8f08b44648 --- /dev/null +++ b/integrationTests/node-esm/package.json @@ -0,0 +1,15 @@ +{ + "type": "module", + "description": "graphql-js ESM should work on all supported node versions", + "scripts": { + "test": "node test.js" + }, + "dependencies": { + "graphql": "file:../graphql-esm.tgz", + "node-12": "npm:node@12.x.x", + "node-14": "npm:node@14.x.x", + "node-16": "npm:node@16.x.x", + "schema": "file:./schema", + "version": "file:./version" + } +} diff --git a/integrationTests/node-esm/schema/package.json b/integrationTests/node-esm/schema/package.json new file mode 100644 index 00000000000..411883da200 --- /dev/null +++ b/integrationTests/node-esm/schema/package.json @@ -0,0 +1,11 @@ +{ + "name": "schema", + "exports": { + ".": { + "import": "./schema.mjs" + } + }, + "peerDependencies": { + "graphql": "*" + } +} diff --git a/integrationTests/node-esm/schema/schema.mjs b/integrationTests/node-esm/schema/schema.mjs new file mode 100644 index 00000000000..0249166b676 --- /dev/null +++ b/integrationTests/node-esm/schema/schema.mjs @@ -0,0 +1,3 @@ +import { buildSchema } from 'graphql/utilities'; + +export const schema = buildSchema('type Query { hello: String }'); diff --git a/integrationTests/node-esm/test.js b/integrationTests/node-esm/test.js new file mode 100644 index 00000000000..f40d724d2f9 --- /dev/null +++ b/integrationTests/node-esm/test.js @@ -0,0 +1,22 @@ +import { execSync } from 'child_process'; +import { readFileSync } from 'fs'; +import { resolve } from 'path'; + +const { dependencies } = JSON.parse( + readFileSync(resolve('package.json'), 'utf-8'), +); + +const nodeVersions = Object.keys(dependencies) + .filter((pkg) => pkg.startsWith('node-')) + .sort((a, b) => b.localeCompare(a)); + +for (const version of nodeVersions) { + console.log(`Testing on ${version} ...`); + + const nodePath = resolve('node_modules', version, 'bin/node'); + execSync( + nodePath + + ' --experimental-json-modules --unhandled-rejections=strict index.js', + { stdio: 'inherit' }, + ); +} diff --git a/integrationTests/node-esm/version/package.json b/integrationTests/node-esm/version/package.json new file mode 100644 index 00000000000..a163f20d9ef --- /dev/null +++ b/integrationTests/node-esm/version/package.json @@ -0,0 +1,8 @@ +{ + "name": "bar", + "type": "module", + "main": "./version.js", + "peerDependencies": { + "graphql": "*" + } +} diff --git a/integrationTests/node-esm/version/version.js b/integrationTests/node-esm/version/version.js new file mode 100644 index 00000000000..2f1ad8eae28 --- /dev/null +++ b/integrationTests/node-esm/version/version.js @@ -0,0 +1,5 @@ +/* eslint-disable import/no-unresolved, node/no-missing-import */ + +import { version } from 'graphql'; + +export { version }; diff --git a/integrationTests/ts/esm.ts b/integrationTests/ts/esm.ts new file mode 100644 index 00000000000..4554d1efecd --- /dev/null +++ b/integrationTests/ts/esm.ts @@ -0,0 +1,38 @@ +import type { ExecutionResult } from 'graphql-esm/execution'; + +import { graphqlSync } from 'graphql-esm'; +import { + GraphQLString, + GraphQLSchema, + GraphQLObjectType, +} from 'graphql-esm/type'; + +const queryType: GraphQLObjectType = new GraphQLObjectType({ + name: 'Query', + fields: () => ({ + sayHi: { + type: GraphQLString, + args: { + who: { + type: GraphQLString, + defaultValue: 'World', + }, + }, + resolve(_root, args: { who: string }) { + return 'Hello ' + args.who; + }, + }, + }), +}); + +const schema: GraphQLSchema = new GraphQLSchema({ query: queryType }); + +const result: ExecutionResult = graphqlSync({ + schema, + source: ` + query helloWho($who: String){ + test(who: $who) + } + `, + variableValues: { who: 'Dolly' }, +}); diff --git a/integrationTests/ts/package.json b/integrationTests/ts/package.json index 751644900aa..8920a886ca6 100644 --- a/integrationTests/ts/package.json +++ b/integrationTests/ts/package.json @@ -6,9 +6,11 @@ }, "dependencies": { "graphql": "file:../graphql.tgz", + "graphql-esm": "file:../graphql-esm.tgz", "typescript-4.1": "npm:typescript@4.1.x", "typescript-4.2": "npm:typescript@4.2.x", "typescript-4.3": "npm:typescript@4.3.x", - "typescript-4.4": "npm:typescript@4.4.x" + "typescript-4.4": "npm:typescript@4.4.x", + "typescript-4.5": "npm:typescript@4.5.x" } } diff --git a/integrationTests/webpack/entry-esm.mjs b/integrationTests/webpack/entry-esm.mjs new file mode 100644 index 00000000000..1dec59e0436 --- /dev/null +++ b/integrationTests/webpack/entry-esm.mjs @@ -0,0 +1,13 @@ +// eslint-disable-next-line node/no-missing-import, import/no-unresolved +import { graphqlSync } from 'graphql-esm'; + +// eslint-disable-next-line node/no-missing-import, import/no-unresolved +import { buildSchema } from 'graphql-esm/utilities/buildASTSchema'; + +const schema = buildSchema('type Query { hello: String }'); + +export const result = graphqlSync({ + schema, + source: '{ hello }', + rootValue: { hello: 'world' }, +}); diff --git a/integrationTests/webpack/package.json b/integrationTests/webpack/package.json index aec7a21afb4..83001bb4b71 100644 --- a/integrationTests/webpack/package.json +++ b/integrationTests/webpack/package.json @@ -6,6 +6,7 @@ }, "dependencies": { "graphql": "file:../graphql.tgz", + "graphql-esm": "file:../graphql-esm.tgz", "webpack": "5.x.x", "webpack-cli": "4.x.x" } diff --git a/integrationTests/webpack/test.js b/integrationTests/webpack/test.js index 40c22233d4f..6bd4e0c04c7 100644 --- a/integrationTests/webpack/test.js +++ b/integrationTests/webpack/test.js @@ -3,12 +3,23 @@ const assert = require('assert'); // eslint-disable-next-line node/no-missing-require -const { result } = require('./dist/main.js'); +const { result: cjs } = require('./dist/cjs.js'); -assert.deepStrictEqual(result, { +assert.deepStrictEqual(cjs, { data: { __proto__: null, hello: 'world', }, }); + +// eslint-disable-next-line node/no-missing-require +const { result: esm } = require('./dist/esm.js'); + +assert.deepStrictEqual(esm, { + data: { + __proto__: null, + hello: 'world', + }, +}); + console.log('Test script: Got correct result from Webpack bundle!'); diff --git a/integrationTests/webpack/webpack.config.json b/integrationTests/webpack/webpack.config.json index 830b2bd52dc..c74ab3e4472 100644 --- a/integrationTests/webpack/webpack.config.json +++ b/integrationTests/webpack/webpack.config.json @@ -1,6 +1,9 @@ { "mode": "production", - "entry": "./entry.js", + "entry": { + "cjs": "./entry.js", + "esm": "./entry-esm.mjs" + }, "output": { "libraryTarget": "commonjs2" } diff --git a/resources/build-npm.js b/resources/build-npm.js index a54cfaa9834..8ef96c8a83b 100644 --- a/resources/build-npm.js +++ b/resources/build-npm.js @@ -15,23 +15,40 @@ const prettierConfig = JSON.parse( ); if (require.main === module) { - fs.rmSync('./npmDist', { recursive: true, force: true }); - fs.mkdirSync('./npmDist'); + buildNPM(); + buildNPM({ + isESMOnly: true, + }); +} + +exports.buildNPM = buildNPM; + +function buildNPM({ isESMOnly = false } = {}) { + const distDirectory = isESMOnly ? './npmEsmDist' : './npmDist'; - const packageJSON = buildPackageJSON(); + fs.rmSync(distDirectory, { recursive: true, force: true }); + fs.mkdirSync(distDirectory); const srcFiles = readdirRecursive('./src', { ignoreDir: /^__.*__$/ }); + + const packageJSON = buildPackageJSON({ srcFiles, isESMOnly }); + for (const filepath of srcFiles) { const srcPath = path.join('./src', filepath); - const destPath = path.join('./npmDist', filepath); + const destPath = path.join(distDirectory, filepath); fs.mkdirSync(path.dirname(destPath), { recursive: true }); if (filepath.endsWith('.ts')) { - const cjs = babelBuild(srcPath, { envName: 'cjs' }); - writeGeneratedFile(destPath.replace(/\.ts$/, '.js'), cjs); - - const mjs = babelBuild(srcPath, { envName: 'mjs' }); - writeGeneratedFile(destPath.replace(/\.ts$/, '.mjs'), mjs); + if (isESMOnly) { + const js = babelBuild(srcPath, { envName: 'esm' }); + writeGeneratedFile(destPath.replace(/\.ts$/, '.js'), js); + } else { + const cjs = babelBuild(srcPath, { envName: 'cjs' }); + writeGeneratedFile(destPath.replace(/\.ts$/, '.js'), cjs); + + const mjs = babelBuild(srcPath, { envName: 'mjs' }); + writeGeneratedFile(destPath.replace(/\.ts$/, '.mjs'), mjs); + } } } @@ -47,7 +64,7 @@ if (require.main === module) { ...tsConfig.compilerOptions, noEmit: false, declaration: true, - declarationDir: './npmDist', + declarationDir: distDirectory, emitDeclarationOnly: true, }; @@ -72,7 +89,7 @@ if (require.main === module) { // TODO: revisit once TS implements https://github.com/microsoft/TypeScript/issues/32166 const notSupportedTSVersionFile = 'NotSupportedTSVersion.d.ts'; fs.writeFileSync( - path.join('./npmDist', notSupportedTSVersionFile), + path.join(distDirectory, notSupportedTSVersionFile), // Provoke syntax error to show this message `"Package 'graphql' support only TS versions that are ${supportedTSVersions[0]}".`, ); @@ -81,13 +98,16 @@ if (require.main === module) { '*': { '*': [notSupportedTSVersionFile] }, }; - fs.copyFileSync('./LICENSE', './npmDist/LICENSE'); - fs.copyFileSync('./README.md', './npmDist/README.md'); + fs.copyFileSync('./LICENSE', distDirectory + '/LICENSE'); + fs.copyFileSync('./README.md', distDirectory + '/README.md'); // Should be done as the last step so only valid packages can be published - writeGeneratedFile('./npmDist/package.json', JSON.stringify(packageJSON)); + writeGeneratedFile( + distDirectory + '/package.json', + JSON.stringify(packageJSON), + ); - showDirStats('./npmDist'); + showDirStats(distDirectory); } function writeGeneratedFile(filepath, body) { @@ -104,7 +124,10 @@ function babelBuild(srcPath, options) { return code + '\n'; } -function buildPackageJSON() { +function buildPackageJSON({ srcFiles, isESMOnly = false }) { + /** + * @type {Record} + */ const packageJSON = JSON.parse( fs.readFileSync(require.resolve('../package.json'), 'utf-8'), ); @@ -113,6 +136,19 @@ function buildPackageJSON() { delete packageJSON.scripts; delete packageJSON.devDependencies; + if (isESMOnly) { + delete packageJSON.module; + packageJSON.version = `${packageJSON.version}-esm`; + + packageJSON.type = 'module'; + + packageJSON.exports = { + '.': './index.js', + './*': './*', + './package.json': './package.json', + }; + } + const { version } = packageJSON; const versionMatch = /^\d+\.\d+\.\d+-?(?.*)?$/.exec(version); if (!versionMatch) { @@ -126,7 +162,7 @@ function buildPackageJSON() { // Note: `experimental-*` take precedence over `alpha`, `beta` or `rc`. const publishTag = splittedTag[2] ?? splittedTag[0]; assert( - ['alpha', 'beta', 'rc'].includes(publishTag) || + ['alpha', 'beta', 'rc', 'esm'].includes(publishTag) || publishTag.startsWith('experimental-'), `"${publishTag}" tag is not supported.`, ); @@ -135,5 +171,23 @@ function buildPackageJSON() { packageJSON.publishConfig = { tag: publishTag }; } + if (isESMOnly) { + /** + * This allows imports without explicit extensions and index imports + * Like `import("graphql/language/parser")` and `import("graphql/utilities")` + */ + for (const srcFile of srcFiles.map((v) => v.replace(/\\/g, '/'))) { + if (srcFile.endsWith('.ts')) { + const srcFilePath = srcFile.slice(0, srcFile.length - 3); + packageJSON.exports[`./${srcFilePath}`] = `./${srcFilePath}.js`; + + const indexMatch = /^(.+)\/index\.ts$/.exec(srcFile); + if (indexMatch && indexMatch[1]) { + packageJSON.exports['./' + indexMatch[1]] = `./${srcFilePath}.js`; + } + } + } + } + return packageJSON; }