From ed9178b2115d553b9149d9e67465a51966baefe4 Mon Sep 17 00:00:00 2001 From: Jaideep Pyne Date: Mon, 17 Aug 2026 06:52:23 +0530 Subject: [PATCH] feat: emit ESM syntax in standalone mode via ajv.code.esm Standalone mode hardcoded CommonJS require/module.exports, forcing ESM/TypeScript consumers to add interop shims. Mirror Ajv's standalone code.esm option: when `{ mode: 'standalone', ajv: { code: { esm: true } } }` is set, emit import/export default (with explicit .js extensions that ESM resolution requires) instead of require/module.exports. CommonJS output is unchanged by default. Closes #679 Signed-off-by: Jaideep Pyne --- README.md | 20 +++++++++++++ lib/standalone.js | 21 ++++++++++++-- test/standalone-mode.test.js | 56 ++++++++++++++++++++++++++++++++++++ 3 files changed, 94 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 63f8a4ed..c9868f01 100644 --- a/README.md +++ b/README.md @@ -735,6 +735,26 @@ const stringify = require('stringify.js') console.log(stringify({ firstName: 'Foo', surname: 'bar' })) // '{"firstName":"Foo"}' ``` +By default the generated code uses CommonJS (`require`/`module.exports`). To emit ES modules +(`import`/`export default`) instead, enable the Ajv `code.esm` option, mirroring +[Ajv's standalone ESM output](https://ajv.js.org/standalone.html): + +```js +const code = fastJson({ + title: 'default string', + type: 'object', + properties: { + firstName: { + type: 'string' + } + } +}, { mode: 'standalone', ajv: { code: { esm: true } } }) + +fs.writeFileSync('stringify.mjs', code) +const { default: stringify } = await import('./stringify.mjs') +console.log(stringify({ firstName: 'Foo', surname: 'bar' })) // '{"firstName":"Foo"}' +``` + ## Acknowledgments diff --git a/lib/standalone.js b/lib/standalone.js index 0ba3ac3f..3e9481b5 100644 --- a/lib/standalone.js +++ b/lib/standalone.js @@ -1,9 +1,18 @@ 'use strict' function buildStandaloneCode (contextFunc, context, serializer, validator) { + // Mirror AJV's standalone `code.esm` option so ESM consumers can import the generated + // serializer directly instead of wrapping the CJS output in an interop layer. + const esm = context.options?.ajv?.code?.esm === true + + // ESM resolution requires the explicit file extension, whereas CommonJS `require` does not. + const requireOrImport = (name, path) => esm + ? `import ${name} from '${path}.js'\n` + : `const ${name} = require('${path}')\n` + let ajvDependencyCode = '' if (context.validatorSchemasIds.size > 0) { - ajvDependencyCode += 'const Validator = require(\'fast-json-stringify/lib/validator\')\n' + ajvDependencyCode += requireOrImport('Validator', 'fast-json-stringify/lib/validator') ajvDependencyCode += `const validatorState = ${JSON.stringify(validator.getState())}\n` ajvDependencyCode += 'const validator = Validator.restoreFromState(validatorState)\n' } else { @@ -14,16 +23,22 @@ function buildStandaloneCode (contextFunc, context, serializer, validator) { // validatorState will hold external schemas if it needs them const { schema, ...serializerState } = serializer.getState() + // `export default fn(...)` parses `fn` as a function declaration, so the immediate invocation + // is lost. Wrap it in parentheses to keep it an expression, unlike the `module.exports =` form. + const exportStatement = esm + ? `export default (${contextFunc.toString()})(validator, serializer)` + : `module.exports = ${contextFunc.toString()}(validator, serializer)` + return ` 'use strict' - const Serializer = require('fast-json-stringify/lib/serializer') + ${requireOrImport('Serializer', 'fast-json-stringify/lib/serializer')} const serializerState = ${JSON.stringify(serializerState)} const serializer = Serializer.restoreFromState(serializerState) ${ajvDependencyCode} - module.exports = ${contextFunc.toString()}(validator, serializer)` + ${exportStatement}` } module.exports = buildStandaloneCode diff --git a/test/standalone-mode.test.js b/test/standalone-mode.test.js index 528d05fa..a45d2159 100644 --- a/test/standalone-mode.test.js +++ b/test/standalone-mode.test.js @@ -4,6 +4,7 @@ const { test, after } = require('node:test') const fjs = require('..') const fs = require('fs') const path = require('path') +const url = require('node:url') function build (opts, schema) { return fjs(schema || { @@ -122,6 +123,61 @@ test('test ajv schema', async (t) => { })) }) +test('standalone mode emits ESM syntax when ajv.code.esm is enabled', async (t) => { + t.plan(6) + + after(async () => { + await fs.promises.rm(destination, { force: true }) + }) + + const code = build({ mode: 'standalone', ajv: { code: { esm: true } } }) + t.assert.ok(typeof code === 'string') + t.assert.ok(code.includes("import Serializer from 'fast-json-stringify/lib/serializer.js'")) + t.assert.ok(code.includes('export default')) + t.assert.equal(code.includes('require('), false, 'no CJS require') + t.assert.equal(code.includes('module.exports'), false, 'no CJS module.exports') + + const destination = path.resolve(tmpDir, 'standalone-esm.mjs') + await fs.promises.writeFile(destination, code) + const { default: stringify } = await import(url.pathToFileURL(destination).href) + t.assert.equal(stringify({ firstName: 'Foo', surname: 'bar' }), + JSON.stringify({ firstName: 'Foo' }), 'surname evicted') +}) + +test('standalone ESM output imports the ajv validator', async (t) => { + t.plan(4) + + after(async () => { + await fs.promises.rm(destination, { force: true }) + }) + + const code = build({ mode: 'standalone', ajv: { code: { esm: true } } }, { + type: 'object', + if: { + type: 'object', + properties: { kind: { type: 'string', const: 'foo' } } + }, + then: { + type: 'object', + properties: { kind: { type: 'string' }, foo: { type: 'string' } } + }, + else: { + type: 'object', + properties: { kind: { type: 'string' }, bar: { type: 'string' } } + } + }) + t.assert.ok(code.includes("import Validator from 'fast-json-stringify/lib/validator.js'")) + t.assert.equal(code.includes('require('), false, 'no CJS require even with a validator') + + const destination = path.resolve(tmpDir, 'standalone-esm-ajv.mjs') + await fs.promises.writeFile(destination, code) + const { default: stringify } = await import(url.pathToFileURL(destination).href) + t.assert.equal(stringify({ kind: 'foo', foo: 'FOO', bar: 'BAR' }), + JSON.stringify({ kind: 'foo', foo: 'FOO' }), 'then branch serialized') + t.assert.equal(stringify({ kind: 'other', foo: 'FOO', bar: 'BAR' }), + JSON.stringify({ kind: 'other', bar: 'BAR' }), 'else branch serialized') +}) + test('no need to keep external schemas once compiled', async (t) => { t.plan(1)