From 759196a67b9088a7f0603fc465d11a11bed5d1df Mon Sep 17 00:00:00 2001 From: Ujjwal Verma Date: Mon, 5 Oct 2026 21:25:35 +0530 Subject: [PATCH 1/5] feat: export each generator by name The generators are also exported from the package root, next to CodeGen and Common, so a bundler can leave out the ones an application never imports. They are the same objects as the CodeGen and Common members. Signed-off-by: Ujjwal Verma --- index.js | 28 +++++++++++++++++++++++ test/index.js | 59 ++++++++++++++++++++++++++++++++++++++++++++++++ types/index.d.ts | 23 +++++++++++++++++++ 3 files changed, 110 insertions(+) create mode 100644 test/index.js diff --git a/index.js b/index.js index 19e4ad76..37f93a10 100644 --- a/index.js +++ b/index.js @@ -22,3 +22,31 @@ module.exports.CodeGen = require('./lib/codegen/codegen'); module.exports.Common = require('./lib/common/common'); module.exports.version = require('./package.json'); + +// Each generator is also exported by name, so that a bundler can leave out the +// generators an application never imports. They are the same objects as the +// members of CodeGen and Common. Each is assigned on its own line so that +// Node's ES module loader and tsc's declaration output both see the name. +module.exports.AbstractPlugin = require('./lib/codegen/abstractplugin'); +module.exports.GoLangVisitor = require('./lib/codegen/fromcto/golang/golangvisitor'); +module.exports.JSONSchemaVisitor = require('./lib/codegen/fromcto/jsonschema/jsonschemavisitor'); +module.exports.XmlSchemaVisitor = require('./lib/codegen/fromcto/xmlschema/xmlschemavisitor'); +module.exports.PlantUMLVisitor = require('./lib/codegen/fromcto/plantuml/plantumlvisitor'); +module.exports.TypescriptVisitor = require('./lib/codegen/fromcto/typescript/typescriptvisitor'); +module.exports.JavaVisitor = require('./lib/codegen/fromcto/java/javavisitor'); +module.exports.GraphQLVisitor = require('./lib/codegen/fromcto/graphql/graphqlvisitor'); +module.exports.CSharpVisitor = require('./lib/codegen/fromcto/csharp/csharpvisitor'); +module.exports.ODataVisitor = require('./lib/codegen/fromcto/odata/odatavisitor'); +module.exports.MermaidVisitor = require('./lib/codegen/fromcto/mermaid/mermaidvisitor'); +module.exports.MarkdownVisitor = require('./lib/codegen/fromcto/markdown/markdownvisitor'); +module.exports.ProtobufVisitor = require('./lib/codegen/fromcto/protobuf/protobufvisitor'); +module.exports.OpenApiVisitor = require('./lib/codegen/fromcto/openapi/openapivisitor'); +module.exports.AvroVisitor = require('./lib/codegen/fromcto/avro/avrovisitor'); +module.exports.JSONSchemaToConcertoVisitor = require('./lib/codegen/fromJsonSchema/cto/jsonSchemaVisitor'); +module.exports.OpenApiToConcertoVisitor = require('./lib/codegen/fromOpenApi/cto/openApiVisitor'); +module.exports.RustVisitor = require('./lib/codegen/fromcto/rust/rustvisitor'); +module.exports.VocabularyVisitor = require('./lib/codegen/fromcto/vocabulary/vocabularyvisitor'); +module.exports.BenchmarkModelGenerator = require('./lib/common/benchmarkModelGenerator'); +module.exports.DiagramVisitor = require('./lib/common/diagramvisitor'); +module.exports.ConcertoGraphVisitor = require('./lib/common/graph').ConcertoGraphVisitor; +module.exports.DirectedGraph = require('./lib/common/graph').DirectedGraph; diff --git a/test/index.js b/test/index.js new file mode 100644 index 00000000..e916ef0a --- /dev/null +++ b/test/index.js @@ -0,0 +1,59 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +const chai = require('chai'); +chai.should(); + +const codegen = require('../index.js'); +const CodeGen = require('../lib/codegen/codegen.js'); +const Common = require('../lib/common/common.js'); +const packageJson = require('../package.json'); + +describe('index', function () { + it('exports CodeGen, Common and version', function () { + codegen.CodeGen.should.equal(CodeGen); + codegen.Common.should.equal(Common); + codegen.version.should.equal(packageJson); + }); + + // The named exports duplicate the lists in lib/codegen/codegen.js and + // lib/common/common.js; this keeps them in step when a generator is added. + it('exports every CodeGen member except formats by name', function () { + Object.keys(CodeGen).filter(name => name !== 'formats').forEach(name => { + codegen.should.have.property(name).that.equals(CodeGen[name]); + }); + }); + + it('exports every Common member by name', function () { + Object.keys(Common).forEach(name => { + codegen.should.have.property(name).that.equals(Common[name]); + }); + }); + + it('exports nothing else', function () { + const expected = ['CodeGen', 'Common', 'version', + ...Object.keys(CodeGen).filter(name => name !== 'formats'), + ...Object.keys(Common)]; + Object.keys(codegen).sort().should.deep.equal(expected.sort()); + }); + + it('exposes the named exports to ES module importers', async function () { + const esm = await import('../index.js'); + // Node 23+ also exposes the CommonJS object itself as 'module.exports'. + Object.keys(esm).filter(name => name !== 'default' && name !== 'module.exports').sort() + .should.deep.equal(Object.keys(codegen).sort()); + }); +}); diff --git a/types/index.d.ts b/types/index.d.ts index c9693811..b04e8005 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -40,3 +40,26 @@ export const CodeGen: { }; export const Common: typeof import("./lib/common/common"); export const version: any; +export const AbstractPlugin: typeof import("./lib/codegen/abstractplugin"); +export const GoLangVisitor: typeof import("./lib/codegen/fromcto/golang/golangvisitor"); +export const JSONSchemaVisitor: typeof import("./lib/codegen/fromcto/jsonschema/jsonschemavisitor"); +export const XmlSchemaVisitor: typeof import("./lib/codegen/fromcto/xmlschema/xmlschemavisitor"); +export const PlantUMLVisitor: typeof import("./lib/codegen/fromcto/plantuml/plantumlvisitor"); +export const TypescriptVisitor: typeof import("./lib/codegen/fromcto/typescript/typescriptvisitor"); +export const JavaVisitor: typeof import("./lib/codegen/fromcto/java/javavisitor"); +export const GraphQLVisitor: typeof import("./lib/codegen/fromcto/graphql/graphqlvisitor"); +export const CSharpVisitor: typeof import("./lib/codegen/fromcto/csharp/csharpvisitor"); +export const ODataVisitor: typeof import("./lib/codegen/fromcto/odata/odatavisitor"); +export const MermaidVisitor: typeof import("./lib/codegen/fromcto/mermaid/mermaidvisitor"); +export const MarkdownVisitor: typeof import("./lib/codegen/fromcto/markdown/markdownvisitor"); +export const ProtobufVisitor: typeof import("./lib/codegen/fromcto/protobuf/protobufvisitor"); +export const OpenApiVisitor: typeof import("./lib/codegen/fromcto/openapi/openapivisitor"); +export const AvroVisitor: typeof import("./lib/codegen/fromcto/avro/avrovisitor"); +export const JSONSchemaToConcertoVisitor: typeof import("./lib/codegen/fromJsonSchema/cto/jsonSchemaVisitor"); +export const OpenApiToConcertoVisitor: typeof import("./lib/codegen/fromOpenApi/cto/openApiVisitor"); +export const RustVisitor: typeof import("./lib/codegen/fromcto/rust/rustvisitor"); +export const VocabularyVisitor: typeof import("./lib/codegen/fromcto/vocabulary/vocabularyvisitor"); +export const BenchmarkModelGenerator: typeof import("./lib/common/benchmarkModelGenerator"); +export const DiagramVisitor: typeof import("./lib/common/diagramvisitor"); +export const ConcertoGraphVisitor: typeof import("./lib/common/graph").ConcertoGraphVisitor; +export const DirectedGraph: typeof import("./lib/common/graph").DirectedGraph; From 201fe13a9bd65531b025bfab2b224a9ce08fc8ff Mon Sep 17 00:00:00 2001 From: Ujjwal Verma Date: Mon, 5 Oct 2026 21:26:09 +0530 Subject: [PATCH 2/5] feat!: publish a browser ES module build with an exports map dist/esm-browser has one module per lib/ module and imports Concerto from the application instead of bundling it. Node keeps loading index.js. Files inside the package no longer resolve, except lib/codegen/fromjson/cto/inferModel. Signed-off-by: Ujjwal Verma --- .github/workflows/build.yml | 3 + package-lock.json | 2 + package.json | 20 ++- scripts/build-esm.js | 265 ++++++++++++++++++++++++++++++++++++ scripts/smoke-esm.mjs | 253 ++++++++++++++++++++++++++++++++++ 5 files changed, 541 insertions(+), 2 deletions(-) create mode 100644 scripts/build-esm.js create mode 100644 scripts/smoke-esm.mjs diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 4b7261d4..3833551e 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -38,6 +38,9 @@ jobs: - run: npm run build --if-present - run: npm test + - name: Smoke test the ES module build + run: npm run test:esm + - name: Calculate code coverage run: npm run coverage diff --git a/package-lock.json b/package-lock.json index 12321e79..85548857 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,6 +30,7 @@ "chai": "4.3.6", "chai-as-promised": "7.1.1", "chai-things": "0.2.0", + "esbuild": "0.27.1", "eslint": "8.2.0", "expect": "30.2.0", "graphql": "16.14.2", @@ -7322,6 +7323,7 @@ "integrity": "sha512-yY35KZckJJuVVPXpvjgxiCuVEJT67F6zDeVTv4rizyPrfGBUpZQsvmxnN+C371c2esD/hNMjj4tpBhuueLN7aA==", "dev": true, "hasInstallScript": true, + "license": "MIT", "bin": { "esbuild": "bin/esbuild" }, diff --git a/package.json b/package.json index f61a897d..8a18950a 100644 --- a/package.json +++ b/package.json @@ -10,9 +10,22 @@ "main": "index.js", "browser": "umd/concerto-codegen.js", "typings": "types/index.d.ts", + "exports": { + ".": { + "types": "./types/index.d.ts", + "browser": "./dist/esm-browser/index.mjs", + "default": "./index.js" + }, + "./lib/codegen/fromjson/cto/inferModel": { + "browser": "./dist/esm-browser/lib/codegen/fromjson/cto/inferModel.mjs", + "default": "./lib/codegen/fromjson/cto/inferModel.js" + }, + "./package.json": "./package.json" + }, + "sideEffects": false, "scripts": { "coverage": "node ./scripts/coverage.js \"packages/concerto-*\" && nyc report -t coverage --cwd . --report-dir coverage --reporter=lcov && cat ./coverage/lcov.info", - "prepublishOnly": "npm run webpack", + "prepublishOnly": "npm run build:esm && npm run webpack", "pretest": "npm run lint", "lint": "eslint . && npm run lint:lockfile && npm run lint:deps", "postlint": "npm run licchk", @@ -42,10 +55,12 @@ "test:watch": "nyc mocha --watch --recursive -t 10000", "mocha": "mocha --recursive -t 10000", "nyc": "nyc mocha --recursive -t 10000", - "build": "npm run build:types", + "build": "npm run build:types && npm run build:esm", "postbuild": "npm run webpack", "webpack": "webpack --config webpack.config.js --mode production", "build:types": "tsc index.js --declaration --allowJs --emitDeclarationOnly --skipLibCheck --esModuleInterop --outDir types", + "build:esm": "node ./scripts/build-esm.js", + "test:esm": "node ./scripts/smoke-esm.mjs", "lint:deps": "syncpack lint", "lint:deps:fix": "syncpack fix", "lint:lockfile": "lockfile-lint --path package-lock.json --type npm --allowed-hosts npm --validate-https" @@ -77,6 +92,7 @@ "chai": "4.3.6", "chai-as-promised": "7.1.1", "chai-things": "0.2.0", + "esbuild": "0.27.1", "eslint": "8.2.0", "expect": "30.2.0", "graphql": "16.14.2", diff --git a/scripts/build-esm.js b/scripts/build-esm.js new file mode 100644 index 00000000..73238aec --- /dev/null +++ b/scripts/build-esm.js @@ -0,0 +1,265 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +/* + * Emits the ES module build that the `browser` export condition points at: + * + * dist/esm-browser/index.mjs the package entry point + * dist/esm-browser/lib/... one module per CommonJS module under lib/ + * + * Node keeps loading the CommonJS sources (index.js and lib/) for both `import` + * and `require`, so there is a single copy of each class in a Node process. + * + * The layout follows Concerto's scripts/build-esm.js. Run it with + * `npm run build:esm`. + */ + +const fs = require('fs'); +const path = require('path'); +const { builtinModules } = require('module'); +const esbuild = require('esbuild'); + +const packageDir = path.join(__dirname, '..'); +const libDir = path.join(packageDir, 'lib'); +const outdir = path.join(packageDir, 'dist', 'esm-browser'); +const packageJson = require(path.join(packageDir, 'package.json')); +const peerDependencies = Object.keys(packageJson.peerDependencies || {}); + +/** + * Every module under lib/ is an entry point, so each one becomes its own output + * module and a consumer's bundler can drop the generators it never reaches. + * Code shared between them is hoisted into chunks. + * + * @param {string} dir - directory to scan + * @param {string[]} found - accumulator + * @return {string[]} absolute paths of the modules under dir + */ +function collectEntryPoints(dir, found = []) { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const entryPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + collectEntryPoints(entryPath, found); + } else if (entry.name.endsWith('.js')) { + found.push(entryPath); + } + } + return found; +} + +/** + * Lists the names a CommonJS module reads from a peer dependency. lib/ uses two + * forms, `require('peer').Name` and `const { A, B } = require('peer')`; any + * other form fails the build rather than silently importing the whole package. + * + * @param {string} file - the requiring module + * @param {string} peer - the peer dependency + * @return {string[]} the names read from the peer + */ +function namesRequiredFrom(file, peer) { + const source = fs.readFileSync(file, 'utf8'); + const call = `require\\(\\s*['"]${peer.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&')}['"]\\s*\\)`; + const names = new Set(); + let recognised = 0; + for (const match of source.matchAll(new RegExp(`${call}\\.(\\w+)`, 'g'))) { + names.add(match[1]); + recognised++; + } + for (const match of source.matchAll(new RegExp(`\\{([^{}]*)\\}\\s*=\\s*${call}`, 'g'))) { + match[1].split(',').map(name => name.split(':')[0].trim()).filter(Boolean).forEach(name => names.add(name)); + recognised++; + } + const total = [...source.matchAll(new RegExp(call, 'g'))].length; + if (total !== recognised) { + throw new Error(`${path.relative(packageDir, file)}: require('${peer}') must be followed by .Name or destructured`); + } + return [...names].sort(); +} + +// The peer dependencies (concerto-core, -util, -vocabulary) stay external so the +// application keeps a single copy of Concerto. lib/ requires them, and esbuild +// turns a require() of an external package into a __require() call that throws +// in a browser. Instead, each require is routed through a small ES module that +// re-exports only the names that file reads, so the output carries a real +// `import { ModelUtil } from '@accordproject/concerto-core'` that the +// consumer's bundler resolves and can tree-shake. +const peerRequirePlugin = { + name: 'peer-require', + setup(build) { + build.onResolve({ filter: /^@accordproject\// }, args => { + if (!peerDependencies.some(peer => args.path === peer || args.path.startsWith(`${peer}/`))) { + return undefined; + } + if (args.kind === 'require-call' && peerDependencies.includes(args.path)) { + // One shim per requiring file, since each reads different names. + // The path ends up in the output, so it is relative to the package. + const importer = path.relative(packageDir, args.importer).split(path.sep).join('/'); + return { path: `${args.path}|${importer}`, namespace: 'peer-require' }; + } + return { path: args.path, external: true }; + }); + build.onLoad({ filter: /.*/, namespace: 'peer-require' }, args => { + const [peer, importer] = args.path.split('|'); + return { + contents: `export { ${namesRequiredFrom(path.join(packageDir, importer), peer).join(', ')} } from '${peer}';`, + loader: 'js', + resolveDir: packageDir, + }; + }); + }, +}; + +// lib/ uses two Node builtins. They get small browser versions that cover what +// lib/ reads, instead of the util and buffer polyfills the UMD bundle carried. +// Any other builtin becomes an empty module, as in Concerto's browser build. +const builtinShims = { + // Only used to describe a value in an "Unrecognised type" error message. + util: 'module.exports = { inspect: value => { try { return JSON.stringify(value); } catch (err) { return String(value); } } };', + // BenchmarkModelGenerator measures model sizes with Blob, which browsers provide. + buffer: 'module.exports = { Blob: globalThis.Blob };', +}; +const builtinSpecifiers = new Set([...builtinModules, ...builtinModules.map(name => `node:${name}`)]); +const builtinShimPlugin = { + name: 'builtin-shim', + setup(build) { + build.onResolve({ filter: /^(node:)?[a-z_]+$/ }, args => ( + builtinSpecifiers.has(args.path) ? { path: args.path.replace(/^node:/, ''), namespace: 'builtin-shim' } : undefined + )); + build.onLoad({ filter: /.*/, namespace: 'builtin-shim' }, args => ({ + contents: builtinShims[args.path] || 'module.exports = {};', + loader: 'js', + })); + }, +}; + +/** + * Writes the ES module entry points over the per-module outputs, mirroring the + * shape of index.js: the CodeGen and Common namespaces, version, and the named + * generator exports. The shape is read from index.js itself, so a generator + * added to lib/codegen/codegen.js and index.js appears here without edits. + * + * These files are written rather than bundled. Bundling them would hoist every + * generator's initialisation into one shared chunk, which a consumer's bundler + * then has to keep in full. + */ +function writeEntryPoints() { + const index = require(path.join(packageDir, 'index.js')); + + // Map each lib/ module's exports back to the module that defines it. + const modules = new Map(); + for (const [file, cached] of Object.entries(require.cache)) { + if (file.startsWith(libDir + path.sep)) { + modules.set(cached.exports, file); + } + } + const specifierOf = file => `./${path.relative(packageDir, file).split(path.sep).join('/').replace(/\.js$/, '.mjs')}`; + + const namespaces = Object.keys(index).filter(key => modules.has(index[key]) && index[key].constructor === Object); + for (const namespace of namespaces) { + const lines = []; + const local = new Map(); + const members = Object.entries(index[namespace]); + for (const [name, value] of members) { + if (modules.has(value)) { + lines.push(`import ${name} from '${specifierOf(modules.get(value))}';`); + local.set(value, name); + } else if (typeof value === 'function') { + // The defining module, not an aggregator such as common.js. + const owner = [...modules.entries()].find(([exports]) => exports && exports[name] === value && + !namespaces.some(candidate => index[candidate] === exports)); + if (!owner) { + throw new Error(`${namespace}.${name} is not exported by a module under lib/`); + } + const ownerLocal = `${path.basename(owner[1], '.js')}$`; + if (!lines.includes(`import ${ownerLocal} from '${specifierOf(owner[1])}';`)) { + lines.push(`import ${ownerLocal} from '${specifierOf(owner[1])}';`); + } + lines.push(`const ${name} = ${ownerLocal}.${name};`); + local.set(value, name); + } + } + const exported = members.filter(([, value]) => local.has(value)).map(([name]) => name); + lines.push(`export { ${exported.join(', ')} };`); + for (const [name, value] of members.filter(([, value]) => !local.has(value))) { + const entries = Object.entries(value).map(([key, member]) => { + if (!local.has(member)) { + throw new Error(`${namespace}.${name}.${key} is not one of the ${namespace} members`); + } + return `${key}: ${local.get(member)}`; + }); + lines.push(`export const ${name} = { ${entries.join(', ')} };`); + } + fs.writeFileSync(path.join(outdir, `${namespace}.mjs`), `${lines.join('\n')}\n`); + } + + const lines = namespaces.map(namespace => `export * as ${namespace} from './${namespace}.mjs';`); + for (const [name, value] of Object.entries(index)) { + if (namespaces.includes(name)) { + continue; + } + if (value === packageJson) { + lines.push(`export const ${name} = ${JSON.stringify(packageJson)};`); + continue; + } + const namespace = namespaces.find(candidate => index[candidate][name] === value); + if (!namespace) { + throw new Error(`index.js export ${name} is not a member of ${namespaces.join(' or ')}`); + } + lines.push(`export { ${name} } from './${namespace}.mjs';`); + } + fs.writeFileSync(path.join(outdir, 'index.mjs'), `${lines.join('\n')}\n`); +} + +/** + * Builds dist/esm-browser. + */ +async function main() { + // Chunk names are content hashes, so stale chunks from an earlier build + // would otherwise accumulate and be published. + fs.rmSync(outdir, { recursive: true, force: true }); + await esbuild.build({ + entryPoints: collectEntryPoints(libDir), + outdir, + outbase: packageDir, + bundle: true, + splitting: true, + format: 'esm', + platform: 'browser', + target: 'es2020', + sourcemap: true, + logLevel: 'warning', + // Third-party dependencies are bundled, as in Concerto's browser build: + // lib/ requires them, and their CommonJS exports (debug, camelcase, + // pluralize export a function) cannot be re-exported as ES modules. + plugins: [peerRequirePlugin, builtinShimPlugin], + define: { + // lib/ tests `global === undefined` at load time; browsers have no `global`. + global: 'globalThis', + // Bind `process` (read, guarded, by debug) so that downstream bundlers + // do not inject a process polyfill into these modules. + process: 'globalThis.process', + }, + // The package has no "type": "module", so ES modules need the .mjs + // extension. esbuild rewrites the relative specifiers it emits to match. + outExtension: { '.js': '.mjs' }, + }); + writeEntryPoints(); +} + +main().catch(err => { + // eslint-disable-next-line no-console + console.error(err); + process.exit(1); +}); diff --git a/scripts/smoke-esm.mjs b/scripts/smoke-esm.mjs new file mode 100644 index 00000000..29e13b6f --- /dev/null +++ b/scripts/smoke-esm.mjs @@ -0,0 +1,253 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Smoke test for what consumers resolve through the `exports` map, run after + * `npm run build`. + * + * The mocha suites load lib/ directly, so nothing else exercises the browser + * ES module build (dist/esm-browser) that bundlers pick through the `browser` + * condition, or Node's `import` of the package. Each browser check bundles a + * small application with esbuild, the way a consumer's bundler would, and runs + * it in a fresh JavaScript context with none of Node's globals (no `global`, + * `process`, `Buffer` or `require`), so a Node-only reference fails here + * instead of in someone's browser. + * + * Run with `npm run test:esm`. + */ + +import assert from 'assert'; +import fs from 'fs'; +import path from 'path'; +import vm from 'vm'; +import { builtinModules, createRequire } from 'module'; +import { fileURLToPath, pathToFileURL } from 'url'; +import * as esbuild from 'esbuild'; + +const require = createRequire(import.meta.url); +const packageDir = path.join(path.dirname(fileURLToPath(import.meta.url)), '..'); +const browserDir = path.join(packageDir, 'dist', 'esm-browser'); +const PACKAGE = '@accordproject/concerto-codegen'; +const INFER_MODEL = `${PACKAGE}/lib/codegen/fromjson/cto/inferModel`; + +const MODEL = `namespace smoke@1.0.0 +concept Address { + o String street +} +concept Person identified by email { + o String email + o String name optional + o Address address + o DateTime born +}`; + +const checks = []; + +/** + * Register a named check. + * + * @param {string} name - what the check covers + * @param {Function} fn - the check body; throws to fail + */ +function check(name, fn) { + checks.push([name, fn]); +} + +/** + * The export names of a module namespace, without the interop names Node adds + * when ES modules import CommonJS. + * + * @param {object} namespace - a module namespace or exports object + * @return {string[]} the sorted export names + */ +function namesOf(namespace) { + return Object.keys(namespace).filter(name => name !== 'default' && name !== 'module.exports').sort(); +} + +/** + * Bundles an application for the browser, as a consumer's bundler would. + * + * @param {string} contents - the application's entry module + * @param {object} [options] - extra esbuild options + * @return {Promise<{code: string, inputs: string[]}>} the bundle, and the + * files (relative to the package) that contributed code to it + */ +async function bundle(contents, options = {}) { + const result = await esbuild.build({ + stdin: { contents, resolveDir: packageDir, loader: 'js' }, + absWorkingDir: packageDir, + bundle: true, + platform: 'browser', + format: 'iife', + write: false, + metafile: true, + logLevel: 'silent', + ...options, + }); + // metafile.inputs also lists modules that were parsed and then tree-shaken + // away, so read what each input contributed to the output instead. + const [output] = Object.values(result.metafile.outputs); + return { + code: result.outputFiles[0].text, + inputs: Object.entries(output.inputs) + .filter(([, input]) => input.bytesInOutput > 0) + .map(([input]) => input.split('\\').join('/')), + }; +} + +/** + * Runs a bundle in a fresh context that has the standard JavaScript built-ins + * plus the browser APIs the code under test uses, but none of Node's globals. + * + * @param {string} code - the bundle + * @return {object} the value the bundle assigned to globalThis.result + */ +function runInBrowserLikeContext(code) { + const context = vm.createContext({ console, URL, TextEncoder, TextDecoder, Blob, setTimeout, clearTimeout }); + vm.runInContext(code, context); + return context.result; +} + +check('Node resolves the package to the CommonJS entry point', () => { + assert.strictEqual(fileURLToPath(import.meta.resolve(PACKAGE)), path.join(packageDir, 'index.js')); +}); + +check('Node import and require expose the same names', async () => { + assert.deepStrictEqual(namesOf(await import(PACKAGE)), namesOf(require(PACKAGE))); +}); + +check('Node resolves the inferModel subpath', () => { + const inferModel = require(INFER_MODEL); + assert.match(inferModel('smoke@1.0.0', 'Root', { name: 'x' }), /concept Root/); +}); + +check('the browser build exposes the same names as the CommonJS entry point', async () => { + const cjs = require(PACKAGE); + const esm = await import(pathToFileURL(path.join(browserDir, 'index.mjs')).href); + assert.deepStrictEqual(namesOf(esm), namesOf(cjs)); + assert.deepStrictEqual(namesOf(esm.CodeGen), namesOf(cjs.CodeGen)); + assert.deepStrictEqual(Object.keys(esm.CodeGen.formats).sort(), Object.keys(cjs.CodeGen.formats).sort()); + assert.deepStrictEqual(namesOf(esm.Common), namesOf(cjs.Common)); + assert.strictEqual(esm.TypescriptVisitor, esm.CodeGen.TypescriptVisitor); + assert.strictEqual(esm.CodeGen.formats.typescript, esm.TypescriptVisitor); + assert.strictEqual(esm.DirectedGraph, esm.Common.DirectedGraph); + assert.strictEqual(esm.version.version, cjs.version.version); +}); + +check('the browser build has no require calls, Node globals or Node builtin imports', () => { + const builtins = new Set([...builtinModules, ...builtinModules.map(name => `node:${name}`)]); + const offenders = []; + for (const file of fs.readdirSync(browserDir, { recursive: true }).filter(file => file.endsWith('.mjs'))) { + const source = fs.readFileSync(path.join(browserDir, file), 'utf8'); + if (/\b__require\(/.test(source)) { + offenders.push(`${file}: __require() call`); + } + // `global` and `process` are rewritten to globalThis members by + // build-esm.js. Comments and string literals are removed before looking. + const code = esbuild.transformSync(source, { format: 'esm', minifyWhitespace: true, legalComments: 'none' }).code + .replace(/(['"`])(?:\\.|(?!\1)[^\\])*\1/g, '""'); + if (/(^|[^.\w$])(global|process)\b(?!\s*:)/.test(code)) { + offenders.push(`${file}: free global or process`); + } + if (source.includes(packageDir) || source.includes(packageDir.split(path.sep).join('/'))) { + offenders.push(`${file}: contains the local build path`); + } + for (const [, specifier] of source.matchAll(/(?:from|import)\s*['"]([^'"]+)['"]/g)) { + if (builtins.has(specifier)) { + offenders.push(`${file}: imports ${specifier}`); + } + } + } + assert.deepStrictEqual(offenders, []); +}); + +check('a bundled application generates code in a browser-like context', async () => { + const { code, inputs } = await bundle(` + import { ModelManager } from '@accordproject/concerto-core'; + import { InMemoryWriter } from '@accordproject/concerto-util'; + import { CodeGen, Common, BenchmarkModelGenerator } from '${PACKAGE}'; + import inferModel from '${INFER_MODEL}'; + + const modelManager = new ModelManager(); + modelManager.addCTOModel(${JSON.stringify(MODEL)}, 'smoke.cto'); + const generated = {}; + for (const [format, Visitor] of Object.entries(CodeGen.formats)) { + const fileWriter = new InMemoryWriter(); + modelManager.accept(new Visitor(), { fileWriter }); + generated[format] = [...fileWriter.getFilesInMemory().values()].join('\\n').length; + } + const graph = new Common.DirectedGraph(); + modelManager.accept(new Common.ConcertoGraphVisitor(), { graph, createDependencyGraph: true }); + const fromSchema = new CodeGen.JSONSchemaToConcertoVisitor().visit( + CodeGen.JSONSchemaToConcertoVisitor.parse({ type: 'object', properties: { id: { type: 'string' } } }), + { metaModelNamespace: 'concerto.metamodel@1.0.0', namespace: 'smoke@1.0.0' }); + globalThis.result = { + generated, + typescript: (() => { + const fileWriter = new InMemoryWriter(); + modelManager.accept(new CodeGen.TypescriptVisitor(), { fileWriter }); + return [...fileWriter.getFilesInMemory().values()].join(''); + })(), + graphHasPerson: graph.hasVertex('smoke@1.0.0.Person'), + fromSchemaModels: fromSchema.models.length, + inferred: inferModel('smoke@1.0.0', 'Root', { name: 'x' }), + jsonSize: new BenchmarkModelGenerator().jsonSize({ a: 1 }), + }; + `); + const ownModules = inputs.filter(input => !input.startsWith('node_modules/') && input !== ''); + assert.ok(ownModules.length > 0, 'nothing bundled from this package'); + assert.deepStrictEqual(ownModules.filter(input => !input.startsWith('dist/esm-browser/')), [], 'bundled from outside dist/esm-browser'); + + const result = runInBrowserLikeContext(code); + const empty = Object.entries(result.generated).filter(([, length]) => length === 0).map(([format]) => format); + assert.deepStrictEqual(empty, [], 'formats that generated nothing'); + assert.match(result.typescript, /interface IPerson/); + assert.ok(result.graphHasPerson, 'graph has no Person vertex'); + assert.strictEqual(result.fromSchemaModels, 1); + assert.match(result.inferred, /concept Root/); + assert.strictEqual(result.jsonSize, 7); +}); + +// Importing one generator must not pull in the others. The markers are string +// literals the minifier keeps: one from the generator that is imported, and one +// from each of three that are not (ajv is only reached by JSONSchemaToConcertoVisitor). +check('a bundle that imports one generator leaves the others out', async () => { + const markers = { + typescript: '/* eslint-disable @typescript-eslint/no-empty-interface */', + rust: 'use serde::{ Deserialize, Serialize };', + java: 'import java.util.HashMap;', + ajv: 'http://json-schema.org/draft-07/schema#', + }; + const { code, inputs } = await bundle(`import { TypescriptVisitor } from '${PACKAGE}'; console.log(TypescriptVisitor);`, { minify: true }); + const found = Object.keys(markers).filter(name => code.includes(markers[name])); + assert.deepStrictEqual(found, ['typescript'], `bundled ${found.join(', ')}`); + assert.ok(!inputs.some(input => input.includes('concerto-vocabulary')), 'concerto-vocabulary was bundled'); +}); + +let failures = 0; +for (const [name, fn] of checks) { + try { + await fn(); + console.log(`ok ${name}`); + } catch (err) { + failures++; + console.error(`FAIL ${name}\n ${err.message}`); + } +} + +if (failures > 0) { + console.error(`\n${failures} of ${checks.length} ESM smoke checks failed`); + process.exit(1); +} +console.log(`\n${checks.length} ESM smoke checks passed`); From 4a7a33fa72e508aedac07afc9fcf1d0c6721a727 Mon Sep 17 00:00:00 2001 From: Ujjwal Verma Date: Sat, 10 Oct 2026 23:10:53 +0530 Subject: [PATCH 3/5] feat!: drop the UMD browser bundle Bundlers take dist/esm-browser through the exports map, so the UMD bundle, the browser field, webpack and its babel and polyfill dev dependencies are removed. MIGRATION.md describes what changes for consumers. Signed-off-by: Ujjwal Verma --- .babelrc | 3 - MIGRATION.md | 95 ++ package-lock.json | 4092 ++------------------------------------------- package.json | 12 +- webpack.config.js | 82 - 5 files changed, 232 insertions(+), 4052 deletions(-) delete mode 100644 .babelrc create mode 100644 MIGRATION.md delete mode 100644 webpack.config.js diff --git a/.babelrc b/.babelrc deleted file mode 100644 index 8aa924d7..00000000 --- a/.babelrc +++ /dev/null @@ -1,3 +0,0 @@ -{ - "presets": ["@babel/preset-env"] -} \ No newline at end of file diff --git a/MIGRATION.md b/MIGRATION.md new file mode 100644 index 00000000..a807faf9 --- /dev/null +++ b/MIGRATION.md @@ -0,0 +1,95 @@ +# Migrating to concerto-codegen 7.0.0 + +7.0.0 publishes an ES module build for browsers, selects it through an `exports` map +in `package.json`, and removes the webpack UMD bundle. This was done under +[template-engine#185](https://github.com/accordproject/template-engine/issues/185) +so that browser applications ship only the generators they use, and share one copy +of Concerto with the rest of the application. The layout follows Concerto 5.0.0 +([concerto#1306](https://github.com/accordproject/concerto/pull/1306)). + +**Who is affected:** almost nobody. If your code imports the package by its name, +`require('@accordproject/concerto-codegen')` or +`import { CodeGen } from '@accordproject/concerto-codegen'`, nothing changes. You are +only affected if you import a file inside the package, or load the UMD bundle with a +`