From 1090d3ea4e69e5942903b91a8494d957b41c27e1 Mon Sep 17 00:00:00 2001 From: KaKa Date: Sat, 11 Jun 2022 04:31:39 +0800 Subject: [PATCH 1/2] feat: standalone mode --- README.md | 34 +++++- ajv.js | 37 ++++++ index.js | 218 ++++------------------------------- serializer.js | 168 +++++++++++++++++++++++++++ test/fixtures/.keep | 0 test/standalone-mode.test.js | 38 ++++++ 6 files changed, 295 insertions(+), 200 deletions(-) create mode 100644 ajv.js create mode 100644 serializer.js create mode 100644 test/fixtures/.keep create mode 100644 test/standalone-mode.test.js diff --git a/README.md b/README.md index 75760513..595c1268 100644 --- a/README.md +++ b/README.md @@ -663,13 +663,37 @@ const debugCompiled = fastJson({ type: 'string' } } -}, { debugMode: true }) +}, { mode: 'debug' }) -console.log(debugCompiled) // it is an array of functions that can create your `stringify` function -console.log(debugCompiled.toString()) // print a "ready to read" string function, you can save it to a file +console.log(debugCompiled) // it is a object contain code, ajv instance +const rawString = debugCompiled.code // it is the generated code +console.log(rawString) -const rawString = debugCompiled.toString() -const stringify = fastJson.restore(rawString) // use the generated string to get back the `stringify` function +const stringify = fastJson.restore(debugCompiled) // use the generated string to get back the `stringify` function +console.log(stringify({ firstName: 'Foo', surname: 'bar' })) // '{"firstName":"Foo"}' +``` + + +### Standalone Mode + +The standalone mode is used to compile the code that can be directly run by `node` +itself. You need to install `fast-json-stringify`, `ajv`, `fast-uri` and `ajv-formats` +in order to let the standalone code works. + +```js +const fs = require('fs') +const code = fastJson({ + title: 'default string', + type: 'object', + properties: { + firstName: { + type: 'string' + } + } +}, { mode: 'standalone' }) + +fs.writeFileSync('stringify.js', code) +const stringify = require('stringify.js') console.log(stringify({ firstName: 'Foo', surname: 'bar' })) // '{"firstName":"Foo"}' ``` diff --git a/ajv.js b/ajv.js new file mode 100644 index 00000000..80194fea --- /dev/null +++ b/ajv.js @@ -0,0 +1,37 @@ +'use strict' + +const Ajv = require('ajv') +const fastUri = require('fast-uri') +const ajvFormats = require('ajv-formats') + +module.exports = buildAjv + +function buildAjv (options) { + const ajvInstance = new Ajv({ ...options, strictSchema: false, uriResolver: fastUri }) + ajvFormats(ajvInstance) + + const validateDateTimeFormat = ajvFormats.get('date-time').validate + const validateDateFormat = ajvFormats.get('date').validate + const validateTimeFormat = ajvFormats.get('time').validate + + ajvInstance.addKeyword({ + keyword: 'fjs_date_type', + validate: (schema, date) => { + if (date instanceof Date) { + return true + } + if (schema === 'date-time') { + return validateDateTimeFormat(date) + } + if (schema === 'date') { + return validateDateFormat(date) + } + if (schema === 'time') { + return validateTimeFormat(date) + } + return false + } + }) + + return ajvInstance +} diff --git a/index.js b/index.js index 4a699c33..1632d0ab 100644 --- a/index.js +++ b/index.js @@ -2,15 +2,14 @@ /* eslint no-prototype-builtins: 0 */ -const Ajv = require('ajv') -const fastUri = require('fast-uri') -const ajvFormats = require('ajv-formats') const merge = require('deepmerge') const clone = require('rfdc')({ proto: true }) const fjsCloned = Symbol('fast-json-stringify.cloned') const { randomUUID } = require('crypto') const validate = require('./schema-validator') +const Serializer = require('./serializer') +const buildAjv = require('./ajv') let largeArraySize = 2e4 let stringSimilarity = null @@ -57,173 +56,6 @@ const schemaReferenceMap = new Map() let ajvInstance = null let contextFunctions = null -class Serializer { - constructor (options = {}) { - switch (options.rounding) { - case 'floor': - this.parseInteger = Math.floor - break - case 'ceil': - this.parseInteger = Math.ceil - break - case 'round': - this.parseInteger = Math.round - break - default: - this.parseInteger = Math.trunc - break - } - } - - asAny (i) { - return JSON.stringify(i) - } - - asNull () { - return 'null' - } - - asInteger (i) { - if (typeof i === 'bigint') { - return i.toString() - } else if (Number.isInteger(i)) { - return '' + i - } else { - /* eslint no-undef: "off" */ - const integer = this.parseInteger(i) - if (Number.isNaN(integer)) { - throw new Error(`The value "${i}" cannot be converted to an integer.`) - } else { - return '' + integer - } - } - } - - asIntegerNullable (i) { - return i === null ? 'null' : this.asInteger(i) - } - - asNumber (i) { - const num = Number(i) - if (Number.isNaN(num)) { - throw new Error(`The value "${i}" cannot be converted to a number.`) - } else { - return '' + num - } - } - - asNumberNullable (i) { - return i === null ? 'null' : this.asNumber(i) - } - - asBoolean (bool) { - return bool && 'true' || 'false' // eslint-disable-line - } - - asBooleanNullable (bool) { - return bool === null ? 'null' : this.asBoolean(bool) - } - - asDatetime (date, skipQuotes) { - const quotes = skipQuotes === true ? '' : '"' - if (date instanceof Date) { - return quotes + date.toISOString() + quotes - } - return this.asString(date, skipQuotes) - } - - asDatetimeNullable (date, skipQuotes) { - return date === null ? 'null' : this.asDatetime(date, skipQuotes) - } - - asDate (date, skipQuotes) { - const quotes = skipQuotes === true ? '' : '"' - if (date instanceof Date) { - return quotes + new Date(date.getTime() - (date.getTimezoneOffset() * 60000)).toISOString().slice(0, 10) + quotes - } - return this.asString(date, skipQuotes) - } - - asDateNullable (date, skipQuotes) { - return date === null ? 'null' : this.asDate(date, skipQuotes) - } - - asTime (date, skipQuotes) { - const quotes = skipQuotes === true ? '' : '"' - if (date instanceof Date) { - return quotes + new Date(date.getTime() - (date.getTimezoneOffset() * 60000)).toISOString().slice(11, 19) + quotes - } - return this.asString(date, skipQuotes) - } - - asTimeNullable (date, skipQuotes) { - return date === null ? 'null' : this.asTime(date, skipQuotes) - } - - asString (str, skipQuotes) { - const quotes = skipQuotes === true ? '' : '"' - if (str instanceof Date) { - return quotes + str.toISOString() + quotes - } else if (str === null) { - return quotes + quotes - } else if (str instanceof RegExp) { - str = str.source - } else if (typeof str !== 'string') { - str = str.toString() - } - // If we skipQuotes it means that we are using it as test - // no need to test the string length for the render - if (skipQuotes) { - return str - } - - if (str.length < 42) { - return this.asStringSmall(str) - } else { - return JSON.stringify(str) - } - } - - asStringNullable (str) { - return str === null ? 'null' : this.asString(str) - } - - // magically escape strings for json - // relying on their charCodeAt - // everything below 32 needs JSON.stringify() - // every string that contain surrogate needs JSON.stringify() - // 34 and 92 happens all the time, so we - // have a fast case for them - asStringSmall (str) { - const l = str.length - let result = '' - let last = 0 - let found = false - let surrogateFound = false - let point = 255 - // eslint-disable-next-line - for (var i = 0; i < l && point >= 32; i++) { - point = str.charCodeAt(i) - if (point >= 0xD800 && point <= 0xDFFF) { - // The current character is a surrogate. - surrogateFound = true - } - if (point === 34 || point === 92) { - result += str.slice(last, i) + '\\' - last = i - found = true - } - } - - if (!found) { - result = str - } else { - result += str.slice(last) - } - return ((point < 32) || (surrogateFound === true)) ? JSON.stringify(str) : '"' + result + '"' - } -} - function build (schema, options) { arrayItemsReferenceSerializersMap.clear() objectReferenceSerializersMap.clear() @@ -232,31 +64,7 @@ function build (schema, options) { contextFunctions = [] options = options || {} - ajvInstance = new Ajv({ ...options.ajv, strictSchema: false, uriResolver: fastUri }) - ajvFormats(ajvInstance) - - const validateDateTimeFormat = ajvFormats.get('date-time').validate - const validateDateFormat = ajvFormats.get('date').validate - const validateTimeFormat = ajvFormats.get('time').validate - - ajvInstance.addKeyword({ - keyword: 'fjs_date_type', - validate: (schema, date) => { - if (date instanceof Date) { - return true - } - if (schema === 'date-time') { - return validateDateTimeFormat(date) - } - if (schema === 'date') { - return validateDateFormat(date) - } - if (schema === 'time') { - return validateTimeFormat(date) - } - return false - } - }) + ajvInstance = buildAjv(options.ajv) isValidSchema(schema) if (options.schema) { @@ -320,9 +128,29 @@ function build (schema, options) { const dependenciesName = ['ajv', 'serializer', contextFunctionCode] if (options.debugMode) { + options.mode = 'debug' + } + + if (options.mode === 'debug') { return { code: dependenciesName.join('\n'), ajv: ajvInstance } } + if (options.mode === 'standalone') { + return ` +'use strict' + +const Serializer = require('fast-json-stringify/serializer') +const buildAjv = require('fast-json-stringify/ajv') + +const serializer = new Serializer(${JSON.stringify(options || {})}) +const ajv = buildAjv(${JSON.stringify(options.ajv || {})}) + +${contextFunctionCode.replace('return main', '')} + +module.exports = main + ` + } + /* eslint no-new-func: "off" */ const contextFunc = new Function('ajv', 'serializer', contextFunctionCode) const stringifyFunc = contextFunc(ajvInstance, serializer) diff --git a/serializer.js b/serializer.js new file mode 100644 index 00000000..81563bd5 --- /dev/null +++ b/serializer.js @@ -0,0 +1,168 @@ +'use strict' + +module.exports = class Serializer { + constructor (options = {}) { + switch (options.rounding) { + case 'floor': + this.parseInteger = Math.floor + break + case 'ceil': + this.parseInteger = Math.ceil + break + case 'round': + this.parseInteger = Math.round + break + default: + this.parseInteger = Math.trunc + break + } + } + + asAny (i) { + return JSON.stringify(i) + } + + asNull () { + return 'null' + } + + asInteger (i) { + if (typeof i === 'bigint') { + return i.toString() + } else if (Number.isInteger(i)) { + return '' + i + } else { + /* eslint no-undef: "off" */ + const integer = this.parseInteger(i) + if (Number.isNaN(integer)) { + throw new Error(`The value "${i}" cannot be converted to an integer.`) + } else { + return '' + integer + } + } + } + + asIntegerNullable (i) { + return i === null ? 'null' : this.asInteger(i) + } + + asNumber (i) { + const num = Number(i) + if (Number.isNaN(num)) { + throw new Error(`The value "${i}" cannot be converted to a number.`) + } else { + return '' + num + } + } + + asNumberNullable (i) { + return i === null ? 'null' : this.asNumber(i) + } + + asBoolean (bool) { + return bool && 'true' || 'false' // eslint-disable-line + } + + asBooleanNullable (bool) { + return bool === null ? 'null' : this.asBoolean(bool) + } + + asDatetime (date, skipQuotes) { + const quotes = skipQuotes === true ? '' : '"' + if (date instanceof Date) { + return quotes + date.toISOString() + quotes + } + return this.asString(date, skipQuotes) + } + + asDatetimeNullable (date, skipQuotes) { + return date === null ? 'null' : this.asDatetime(date, skipQuotes) + } + + asDate (date, skipQuotes) { + const quotes = skipQuotes === true ? '' : '"' + if (date instanceof Date) { + return quotes + new Date(date.getTime() - (date.getTimezoneOffset() * 60000)).toISOString().slice(0, 10) + quotes + } + return this.asString(date, skipQuotes) + } + + asDateNullable (date, skipQuotes) { + return date === null ? 'null' : this.asDate(date, skipQuotes) + } + + asTime (date, skipQuotes) { + const quotes = skipQuotes === true ? '' : '"' + if (date instanceof Date) { + return quotes + new Date(date.getTime() - (date.getTimezoneOffset() * 60000)).toISOString().slice(11, 19) + quotes + } + return this.asString(date, skipQuotes) + } + + asTimeNullable (date, skipQuotes) { + return date === null ? 'null' : this.asTime(date, skipQuotes) + } + + asString (str, skipQuotes) { + const quotes = skipQuotes === true ? '' : '"' + if (str instanceof Date) { + return quotes + str.toISOString() + quotes + } else if (str === null) { + return quotes + quotes + } else if (str instanceof RegExp) { + str = str.source + } else if (typeof str !== 'string') { + str = str.toString() + } + // If we skipQuotes it means that we are using it as test + // no need to test the string length for the render + if (skipQuotes) { + return str + } + + if (str.length < 42) { + return this.asStringSmall(str) + } else { + return JSON.stringify(str) + } + } + + asStringNullable (str) { + return str === null ? 'null' : this.asString(str) + } + + // magically escape strings for json + // relying on their charCodeAt + // everything below 32 needs JSON.stringify() + // every string that contain surrogate needs JSON.stringify() + // 34 and 92 happens all the time, so we + // have a fast case for them + asStringSmall (str) { + const l = str.length + let result = '' + let last = 0 + let found = false + let surrogateFound = false + let point = 255 + // eslint-disable-next-line + for (var i = 0; i < l && point >= 32; i++) { + point = str.charCodeAt(i) + if (point >= 0xD800 && point <= 0xDFFF) { + // The current character is a surrogate. + surrogateFound = true + } + if (point === 34 || point === 92) { + result += str.slice(last, i) + '\\' + last = i + found = true + } + } + + if (!found) { + result = str + } else { + result += str.slice(last) + } + return ((point < 32) || (surrogateFound === true)) ? JSON.stringify(str) : '"' + result + '"' + } +} diff --git a/test/fixtures/.keep b/test/fixtures/.keep new file mode 100644 index 00000000..e69de29b diff --git a/test/standalone-mode.test.js b/test/standalone-mode.test.js new file mode 100644 index 00000000..d5378608 --- /dev/null +++ b/test/standalone-mode.test.js @@ -0,0 +1,38 @@ +'use strict' + +const test = require('tap').test +const fjs = require('..') +const fs = require('fs') +const path = require('path') + +function build (opts) { + return fjs({ + title: 'default string', + type: 'object', + properties: { + firstName: { + type: 'string' + } + }, + required: ['firstName'] + }, opts) +} + +const tmpDir = 'test/fixtures' + +test('activate standalone mode', async (t) => { + t.plan(2) + let code = build({ mode: 'standalone' }) + t.type(code, 'string') + code = code.replace(/fast-json-stringify/g, '../..') + + const destionation = path.resolve(tmpDir, 'standalone.js') + + t.teardown(async () => { + await fs.promises.rm(destionation, { force: true }) + }) + + await fs.promises.writeFile(destionation, code) + const standalone = require(destionation) + t.same(standalone({ firstName: 'Foo', surname: 'bar' }), JSON.stringify({ firstName: 'Foo' }), 'surname evicted') +}) From ce0e754cbecbc54f85634750ce9e711403ed82e2 Mon Sep 17 00:00:00 2001 From: KaKa Date: Sat, 11 Jun 2022 04:34:54 +0800 Subject: [PATCH 2/2] docs: update toc --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 595c1268..38157685 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,8 @@ compile-json-stringify date format x 1,086,187 ops/sec ±0.16% (99 runs sampled) - `Nullable` - `Large Arrays` - `Security Notice` +- `Debug Mode` +- `Standalone Mode` - `Acknowledgements` - `License`