From 425012e1da6639884e0c97d46ea7dee9f1ce7c84 Mon Sep 17 00:00:00 2001 From: Evan Kaufman <84145+EvanK@users.noreply.github.com> Date: Mon, 3 Aug 2026 15:25:28 -0700 Subject: [PATCH 1/5] Major refactor - converted existing exports to fully async - named captures for stringified function parsing - implemented deep traversal functions - expanded code comments for jsdoc - removed conditional comment support from comment stripping - added error classes to export --- README.md | 96 ++++++++- src/main.mjs | 319 ++++++++++++++++++++++------ tests/001-general.test.template.cjs | 2 +- tests/002-esm.test.mjs | 2 +- tests/003-browser.test.html | 2 +- tests/general.spec.js | 199 +++++++++-------- 6 files changed, 459 insertions(+), 161 deletions(-) diff --git a/README.md b/README.md index 5735d80..737fb75 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,15 @@ ![npm](https://nodei.co/npm/serialize-function.png) ](https://www.npmjs.com/package/serialize-function) +--- + +- [Quickstart](#quickstart) +- [Deep serialization](#deep-serialization) +- [Hashing](#hashing) +- [Whitespace and comments](#whitespace-and-comments) +- [Function type support](#function-type-support) +- [Changelog](#changelog) + ## Quickstart @@ -30,7 +39,7 @@ Serializes javascript functions to a JSON-encodable object suitable for storage ```js function doTheThing(a,b,c,d,e) { return a + b * c / d % e; } -const obj = serialize(doTheThing); +const obj = await serialize(doTheThing); console.log(obj); // { // params: [ 'a', 'b', 'c', 'd', 'e' ], @@ -42,17 +51,70 @@ console.log(obj); Deserializes back into an invokable function: ```js -const func = deserialize(obj); +const func = await deserialize(obj); console.log( func(1, 2, 3, 4, 5) ); // 2.5 ``` + +## Deep serialization + +You may want to deeply serialize _any_ functions nested at arbitrary levels of your data structures. The provided convenience functions will traverse and selectively clone any containing objects, while serializing any functions found: + +```js +const { deepSerialize, deepDeserialize } = require('serialize-function'); + +const original = { + foo: () => 'something', + bar: [ + function* (seed = 0) { let n = seed; while(true) { n = n * 2; yield n; } } + ], + baz: new Date('2026-01-01') +} + +const clone = await deepSerialize(original); +// { +// foo: { +// params: [], +// body: "return ('something');", +// type: 'ArrowFunction' +// }, +// bar: [ +// { +// params: [ 'seed = 0' ], +// body: 'let n = seed; while(true) { n = n * 2; yield n; }', +// type: 'Generator' +// } +// ], +// baz: 2026-01-01T00:00:00.000Z +// } + +// original container and functions remain unmodified +original.foo(); // 'something' +const gen1 = original.bar[0](3.14); +gen1.next().value; // 6.28 +gen1.next().value; // 12.56 + +const restored = await deepDeserialize(clone); +// { +// foo: [Function: anonymous], +// bar: [ [GeneratorFunction: anonymous] ], +// baz: 2026-01-01T00:00:00.000Z +// } + +// deserialized functions remain invokable +restored.foo(); // 'something' +const gen2 = restored.bar[0](901364); +gen2.next().value; // 1802728 +gen2.next().value; // 3605456 +``` + + ## Hashing Optionally supports SHA256 checksum hashing to prevent MITM tampering: ```js -// note: use of hashing returns a promise const hashedObj = await serialize(doTheThing, { hash: true }); console.log(hashedObj); // { @@ -97,7 +159,7 @@ function thingNumberTwo( return remainder; } -const commentedObj = serialize(thingNumberTwo, { whitespace: true, comments: true }); +const commentedObj = await serialize(thingNumberTwo, { whitespace: true, comments: true }); console.log(commentedObj); // { // params: [ '\n /* marco */\n a', ' b', '\tc', '\n d', 'e/* polo */\n' ], @@ -120,12 +182,13 @@ console.log(commentedObj); // } ``` + ## Function type support Arrow functions, generators, and all async variants are supported (contingent on _browser support_ where relevant): ```js -serialize( +await serialize( (i,j,k) => ({ i, j, k }) ); // { @@ -134,7 +197,7 @@ serialize( // type: 'ArrowFunction' // } -serialize( +await serialize( function* (x,y,z) { yield x; yield y; @@ -147,7 +210,7 @@ serialize( // type: 'Generator' // } -serialize( +await serialize( async (ms) => new Promise( resolve => setTimeout(resolve, ms) ) @@ -158,3 +221,22 @@ serialize( // type: 'AsyncArrowFunction' // } ``` + +> [!NOTE] +> As there is no global `Class` object constructor, there is no way to safely deserialize [ES6 classes](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Classes). +> +> As such, ES6 classes are _not_ currently supported for serialization. +> +> Alternatively, you can rewrite your classes as functions, or transpile them with tools like [Babel](https://babeljs.io/docs/babel-plugin-transform-classes/). + + +## Changelog + +Any potentially breaking changes will be documented here. + +- 1.1.0 - Standardized both node and web builds on SubtleCrypto API +- 1.2.0 - Refactored comment stripping, to address potential regex DOS +- 2.0.0 + - Made all exported functions fully async + - Implemented named captures for format patterns + - Implemented deep de/serialization diff --git a/src/main.mjs b/src/main.mjs index aa22698..781346d 100644 --- a/src/main.mjs +++ b/src/main.mjs @@ -1,29 +1,52 @@ -// an error for each purpose, and a purpose for each error +/** + * Covers failures stringifying serialized function for hashing purposes + * @extends {Error} + */ class JsonError extends Error {}; +/** + * Covers failures generating SHA hash digest + * @extends {Error} + */ class CryptoError extends Error {}; -class SerializeError extends Error {} -class DeserializeError extends Error {} -class ChecksumError extends Error {} -class ConstructError extends Error {} +/** + * Covers general failures during serialization + * @extends {Error} + */ +class SerializeError extends Error {}; +/** + * Covers general failures during deserialization + * @extends {Error} + */ +class DeserializeError extends Error {}; +/** + * Covers failures matching checksum to SHA hash + * @extends {Error} + */ +class ChecksumError extends Error {}; +/** + * Covers failures reconstructing a function during deserialization + * @extends {Error} + */ +class ConstructError extends Error {}; // function constructors (vanilla `Function` already global) const AsyncFunction = async function () {}.constructor; const Generator = function* () {}.constructor; const AsyncGenerator = async function* () {}.constructor; -// would love to simplify w/ named captures, but javascript support is spotty +// matching stringified functions, by respective types, into named capture groups const formatPatterns = { - 'Generator': /^(async\s+)?function\*\s*[^()]*\(([^)]*)\)\s*{([\s\S]*)}$/, - 'Function': /^(async\s+)?function\s*[^()]*\(([^)]*)\)\s*{([\s\S]*)}$/, - // .1 is async - // .2 is param list - // .3 is braced body - 'ArrowFunction': /^(async\s+)?(?:\(([^)]*)\)|([^=\s(]+))\s*=>\s*(?:{([\s\S]*)}|([\s\S]+))$/, - // .1 is "async " | undefined - // .2 is param list | undefined - // .3 is single param | undefined - // .4 is braced body | undefined - // .5 is body expression | undefined + 'Generator': /^(?async\s+)?function\*\s*[^()]*\((?[^)]*)\)\s*{(?[\s\S]*)}$/, + 'Function': /^(?async\s+)?function\s*[^()]*\((?[^)]*)\)\s*{(?[\s\S]*)}$/, + // 1st group is async + // 2nd group is param list + // 3rd group is braced body + 'ArrowFunction': /^(?async\s+)?(?:\((?[^)]*)\)|(?[^=\s(]+))\s*=>\s*(?:{(?[\s\S]*)}|(?[\s\S]+))$/, + // 1st group is async | undefined + // 2nd group is param list | undefined + // 3rd group is single param | undefined + // 4th group is braced body | undefined + // 5th group is body expression | undefined }; // get a sha hash given an object @@ -84,7 +107,6 @@ function removeComments(input) { regex: false, blockComment: false, lineComment: false, - condComp: false }; // work character by character @@ -120,19 +142,10 @@ function removeComments(input) { continue; } - if (mode.condComp) { - if (output[i-2] === '@' && output[i-1] === '*' && output[i] === '/') mode.condComp = false; - continue; - } - mode.doubleQuote = output[i] === '"'; mode.singleQuote = output[i] === '\''; if (output[i] === '/') { - if (output[i+1] === '*' && output[i+2] === '@') { - mode.condComp = true; - continue; - } if (output[i+1] === '*') { output[i] = ''; mode.blockComment = true; @@ -151,7 +164,47 @@ function removeComments(input) { return output.join('').slice(2, -2); } -function serialize(func, opts) { +/** + * Invokable function object + * + * @typedef {Function|Generator|AsyncGenerator} InvokableFunction + */ + +/** + * Object notation for serialized functions + * + * @typedef {object} SerializedFunction + * @property {array} params Function parameters + * @property {string} body Function body + * @property {string} type Function type + * @property {string?} hash Cryptographic hash + */ + +/** + * Options for function serialization + * + * @typedef {object} SerializeOptions + * @property {boolean} [comments=false] Preserves comments + * @property {boolean} [whitespace=false] Preserves whitespace + * @property {boolean} [hash=false] Enables SHA256 hashing of function being serialized + */ + +/** + * Options for function deserialization + * + * @typedef {object} DeserializeOptions + * @property {boolean} [hash=false] Enables SHA256 validating of serialized function's hash + */ + +/** + * Serializes a given function to an object notation + * + * @param {InvokableFunction} func Function to be serialized + * @param {SerializeOptions?} opts Serialization options + * @returns {Promise} + * @throws {SerializeError} + */ +async function serialize(func, opts) { const def = { hash: false, comments: false, whitespace: false }; opts = (typeof opts === 'object' && null !== opts) ? Object.assign({}, def, opts) @@ -190,18 +243,18 @@ function serialize(func, opts) { match = stringified.match(pattern); if (match) { // is async? - let async = match[1] ? 'Async' : ''; + let async = match.groups.isAsync ? 'Async' : ''; // params as string list let params = type === 'ArrowFunction' - ? match[2] ?? match[3] - : match[2] + ? match.groups.params ?? match.groups.singleParam + : match.groups.params ; // normalized into an array params = params.split(',').map((p) => opts.whitespace ? p : p.trim()).filter(Boolean); // body as string let body = type === 'ArrowFunction' - ? match[4] ?? `return (${match[5]});` - : match[3] + ? match.groups.bracedBody ?? `return (${match.groups.bodyExpr});` + : match.groups.body ; // trimmed of extra whitespace if (!opts.whitespace) body = body.trim(); @@ -224,46 +277,47 @@ function serialize(func, opts) { } if (opts.hash) { - return hasher(serialized) - .then(hashed => { - serialized.hash = hashed; - return serialized; - }) - .catch(cause => { - throw new SerializeError('Failure hashing serialized function', { cause }); - }) - ; + try { + const hashed = await hasher(serialized); + serialized.hash = hashed; + } catch (cause) { + throw new SerializeError('Failure hashing serialized function', { cause }); + } } return serialized; } -function deserialize(struct, opts = { hash: false }) { +/** + * Deserializes a given object to an invokable function + * + * @param {SerializedFunction} struct Function to be deserialized + * @param {DeserializeOptions?} opts Deserialization options + * @returns {Promise} + * @throws {DeserializeError|ChecksumError} + */ +async function deserialize(struct, opts = { hash: false }) { if (opts?.hash) { if (struct?.hash === undefined) { throw new DeserializeError('Deserialized function missing hash'); } const test = Object.assign({}, struct); delete test.hash; - return hasher(test) - .then(checksum => { - if (checksum !== struct.hash) { - throw new ChecksumError('Checksum failed', { - cause: { - a: checksum, - b: struct.hash, - } - }); - } - return deserialize(struct, { hash: false }); - }) - .catch(cause => { - if (cause instanceof ChecksumError || cause instanceof DeserializeError || cause instanceof ConstructError) { - throw cause; - } - throw new DeserializeError('Failure generating checksum', { cause }); - }) - ; + + try { + const checksum = await hasher(test); + if (checksum !== struct.hash) { + throw new ChecksumError('Checksum failed', { + cause: { + a: checksum, + b: struct.hash, + } + }); + } + } catch (cause) { + if (cause instanceof ChecksumError) throw cause; + throw new DeserializeError('Failure generating checksum', { cause }); + } } try { @@ -275,7 +329,148 @@ function deserialize(struct, opts = { hash: false }) { } } +/** + * Traversing deep structures, to: + * 1. clone every non-primitive type + * 2. test each value for potential conversion (function to object, vice versa) + * 3. convert each object that passes test + * 4. return cloned and/or converted structure + * + * @param {*} input Value to be deeply traversed + * @param {function} tester Callback to test each value for conversion + * @param {function} converter Callback to convert value + * @returns {Promise<*>} Cloned value with conversions made + * @ignore + */ +async function traverse(input, tester, converter) { + // first step any time through is to test and convert + if (tester(input)) { + return await converter(input); + } + + // return null or primitive types + if (input === null || typeof input !== 'object') { + return input; + } + + // clone and return date objects + if (input instanceof Date) { + return new Date(input); + } + + // iterate arrays + if (input instanceof Array) { + const cloned = []; + for (let i = 0; i < input.length; i++) { + // test and convert element + if (tester(input[i])) cloned[i] = await converter(input[i]); + // or traverse and (maybe) copy it + else cloned[i] = await traverse(input[i], tester, converter); + } + return cloned; + } + + // iterate Sets + if (input instanceof Set) { + const cloned = new Set(); + for (const value of input) { + // test and convert each iterated value + if (tester(value)) cloned.add(await converter(value)); + // or traverse and (maybe) copy it + else cloned.add(await traverse(value, tester, converter)); + } + return cloned; + } + + // iterate Maps and use .get/.set + if (input instanceof Map) { + const cloned = new Map(); + for (const [key, value] of input) { + // test and convert each iterated value + if (tester(value)) cloned.set(key, await converter(value)); + // or traverse and (maybe) copy it + else cloned.set(key, await traverse(value, tester, converter)); + } + return cloned; + } + + // iterate objects + if (input instanceof Object) { + const cloned = Object.create(Object.getPrototypeOf(input)); + for (const key in input) { + // skip inherited props + if (Object.hasOwn(input, key)) { + // test and convert each property + if (tester(input[key])) cloned[key] = await converter(input[key]); + // or traverse and (maybe) copy it + else cloned[key] = await traverse(input[key], tester, converter); + } + } + return cloned; + } + + // return unmodified anything unanticipated + return input; +} + +/** + * Accepts and traverses an input value of arbitrary depth, returning a copy with any + * nested functions serialized in the process + * + * @param {*} value Structure to deeply serialize + * @param {SerializeOptions?} options Serialization options + * @returns {Promise<*>} + * @throws {SerializeError} + */ +async function deepSerialize(value, options) { + try { + return await traverse( + value, + (input) => typeof input === 'function', + (input) => serialize(input, options) + ); + } catch (cause) { + throw new SerializeError(`Failure traversing and serializing`, { cause }); + } +} + +/** + * Accepts and traverses an input value of arbitrary depth, returning a copy with any + * nested serialized functions deserialized in the process + * + * @param {*} value Structure to deeply deserialize + * @param {DeserializeOptions} options Deserialization options + * @returns {Promise<*>} + * @throws {DeserializeError} + */ +async function deepDeserialize(value, options) { + try { + return await traverse( + value, + (input) => typeof input === 'object' && Object.hasOwn(input, 'params') && Object.hasOwn(input, 'body') && Object.hasOwn(input, 'type'), + (input) => deserialize( + input, + Object.assign( + { hash: Object.hasOwn(input, 'hash') }, + options + ) + ) + ); + } catch (cause) { + throw new DeserializeError(`Failure traversing and deserializing`, { cause }); + } +} + export { serialize, deserialize, + deepSerialize, + deepDeserialize, + + JsonError, + CryptoError, + SerializeError, + DeserializeError, + ChecksumError, + ConstructError, }; diff --git a/tests/001-general.test.template.cjs b/tests/001-general.test.template.cjs index 80dcec9..0892c1e 100644 --- a/tests/001-general.test.template.cjs +++ b/tests/001-general.test.template.cjs @@ -7,7 +7,7 @@ chaiConfig.truncateThreshold = 0; // prepare for stubbing/calling through to node:crypto const cryptoStub = sinon.stub(); // proxy the test subjects and use pre-proxied hasher -const { serialize, deserialize } = proxyquire('../dist/main.js', { +const { serialize, deserialize, deepSerialize, deepDeserialize } = proxyquire('../dist/main.js', { 'node:crypto': { createHash: cryptoStub }, diff --git a/tests/002-esm.test.mjs b/tests/002-esm.test.mjs index 7248e9f..ae30b98 100644 --- a/tests/002-esm.test.mjs +++ b/tests/002-esm.test.mjs @@ -4,7 +4,7 @@ chaiConfig.truncateThreshold = 0; describe('002A - esm imports', function () { it('import named', async function () { - const { serialize, deserialize } = await import('../dist/import.mjs'); + const { serialize, deserialize, deepSerialize, deepDeserialize } = await import('../dist/import.mjs'); assert.isFunction(serialize); assert.isFunction(deserialize); diff --git a/tests/003-browser.test.html b/tests/003-browser.test.html index d2186d6..83c9841 100644 --- a/tests/003-browser.test.html +++ b/tests/003-browser.test.html @@ -15,7 +15,7 @@