From 48cfc073ddc8ad3f31c09f986cefb6f71e391ccf Mon Sep 17 00:00:00 2001 From: PabloSzx Date: Tue, 9 Nov 2021 23:11:16 -0300 Subject: [PATCH] npm esm tag --- .babelrc-npm.json | 8 ++ .eslintignore | 1 + .eslintrc.yml | 4 + .gitignore | 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 +- package.json | 2 +- resources/build-npm.js | 108 +++++++++++++++--- resources/gen-version.js | 27 +++-- 21 files changed, 308 insertions(+), 30 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..0719e69474f 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,4 @@ /denoDist /npm /deno +/npmEsmDist 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..65b234d1d6e --- /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, 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..f4451a166ef --- /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..5669e71b113 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.1-rc" } } 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/package.json b/package.json index 0dba6f33d19..bea13222554 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,7 @@ "prettier:check": "prettier --check .", "check:spelling": "cspell --cache --no-progress '**/*'", "check:integrations": "npm run build:npm && npm run build:deno && mocha --full-trace integrationTests/*-test.js", - "build:npm": "node resources/build-npm.js", + "build:npm": "node resources/build-npm.js && node resources/build-npm.js --esm-only", "build:deno": "node resources/build-deno.js", "gitpublish:npm": "bash ./resources/gitpublish.sh npm npmDist", "gitpublish:deno": "bash ./resources/gitpublish.sh deno denoDist" diff --git a/resources/build-npm.js b/resources/build-npm.js index a54cfaa9834..12df47588f4 100644 --- a/resources/build-npm.js +++ b/resources/build-npm.js @@ -8,30 +8,52 @@ const ts = require('typescript'); const babel = require('@babel/core'); const prettier = require('prettier'); +const { getVersionFileBody } = require('./gen-version.js'); const { readdirRecursive, showDirStats } = require('./utils.js'); const prettierConfig = JSON.parse( fs.readFileSync(require.resolve('../.prettierrc'), 'utf-8'), ); +const isESMOnly = process.argv.includes('--esm-only'); + if (require.main === module) { - fs.rmSync('./npmDist', { recursive: true, force: true }); - fs.mkdirSync('./npmDist'); + buildNPM(); +} + +exports.buildNPM = buildNPM; - const packageJSON = buildPackageJSON(); +function buildNPM() { + const distDirectory = isESMOnly ? './npmEsmDist' : './npmDist'; + + fs.rmSync(distDirectory, { recursive: true, force: true }); + fs.mkdirSync(distDirectory); const srcFiles = readdirRecursive('./src', { ignoreDir: /^__.*__$/ }); + + const packageJSON = buildPackageJSON(srcFiles); + 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 = + filepath === 'version.ts' + ? babelTransform(getVersionFileBody(packageJSON.version), { + envName: 'esm', + }) + : 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 +69,7 @@ if (require.main === module) { ...tsConfig.compilerOptions, noEmit: false, declaration: true, - declarationDir: './npmDist', + declarationDir: distDirectory, emitDeclarationOnly: true, }; @@ -64,6 +86,7 @@ if (require.main === module) { ); assert(packageJSON.types === undefined, 'Unexpected "types" in package.json'); + const supportedTSVersions = Object.keys(packageJSON.typesVersions); assert( supportedTSVersions.length === 1, @@ -72,7 +95,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 +104,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 +130,24 @@ function babelBuild(srcPath, options) { return code + '\n'; } -function buildPackageJSON() { +function babelTransform(sourceCode, options) { + const { code } = babel.transformSync(sourceCode, { + babelrc: false, + configFile: './.babelrc-npm.json', + ...options, + }); + return code + '\n'; +} + +function buildPackageJSON( + /** + * @type {string[]} + */ + srcFiles, +) { + /** + * @type {Record} + */ const packageJSON = JSON.parse( fs.readFileSync(require.resolve('../package.json'), 'utf-8'), ); @@ -113,6 +156,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 +182,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 +191,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; } diff --git a/resources/gen-version.js b/resources/gen-version.js index 944b51401d8..24a339c68ad 100644 --- a/resources/gen-version.js +++ b/resources/gen-version.js @@ -4,21 +4,31 @@ const fs = require('fs'); const { version } = require('../package.json'); -const versionMatch = /^(\d+)\.(\d+)\.(\d+)-?(.*)?$/.exec(version); -if (!versionMatch) { - throw new Error('Version does not match semver spec: ' + version); +if (require.main === module) { + fs.writeFileSync('./src/version.ts', getVersionFileBody(version)); } -const [, major, minor, patch, preReleaseTag] = versionMatch; +function getVersionFileBody( + /** + * @type {string} + */ + versionArg, +) { + const versionMatch = /^(\d+)\.(\d+)\.(\d+)-?(.*)?$/.exec(versionArg); + if (!versionMatch) { + throw new Error('Version does not match semver spec: ' + versionArg); + } + + const [, major, minor, patch, preReleaseTag] = versionMatch; -const body = ` + const body = ` // Note: This file is autogenerated using "resources/gen-version.js" script and // automatically updated by "npm version" command. /** * A string containing the version of the GraphQL.js library */ -export const version = '${version}' as string; +export const version = '${versionArg}' as string; /** * An object containing the components of the GraphQL.js version string @@ -33,6 +43,7 @@ export const versionInfo = Object.freeze({ }); `; -if (require.main === module) { - fs.writeFileSync('./src/version.ts', body.trim() + '\n'); + return body.trim() + '\n'; } + +exports.getVersionFileBody = getVersionFileBody;