diff --git a/package.json b/package.json index 1bf6821..78c2f39 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "obj-serialize", - "version": "2.0.0", + "version": "2.1.0", "description": "Simple utility to serialize objects to be passed around to another context. Useful in Next.js Pages Router projects.", "author": { "name": "Igor Klepacki", @@ -52,7 +52,6 @@ "devDependencies": { "@jest/globals": "^29.7.0", "@jest/types": "^29.6.3", - "@types/flat": "^5.0.2", "@types/node": "^18.7.18", "jest": "^29.7.0", "next": "^14.0.4", @@ -60,10 +59,7 @@ "rimraf": "^3.0.2", "ts-jest": "^29.1.1", "tsup": "^8.0.1", + "type-fest": "^4.10.2", "typescript": "^4.8.3" - }, - "dependencies": { - "flattie": "^1.1.0", - "nestie": "^1.0.3" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 114349c..3b046cd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,14 +4,6 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false -dependencies: - flattie: - specifier: ^1.1.0 - version: 1.1.0 - nestie: - specifier: ^1.0.3 - version: 1.0.3 - devDependencies: '@jest/globals': specifier: ^29.7.0 @@ -19,9 +11,6 @@ devDependencies: '@jest/types': specifier: ^29.6.3 version: 29.6.3 - '@types/flat': - specifier: ^5.0.2 - version: 5.0.2 '@types/node': specifier: ^18.7.18 version: 18.7.18 @@ -43,6 +32,9 @@ devDependencies: tsup: specifier: ^8.0.1 version: 8.0.1(typescript@4.8.3) + type-fest: + specifier: ^4.10.2 + version: 4.10.2 typescript: specifier: ^4.8.3 version: 4.8.3 @@ -1097,10 +1089,6 @@ packages: '@babel/types': 7.23.6 dev: true - /@types/flat@5.0.2: - resolution: {integrity: sha512-3zsplnP2djeps5P9OyarTxwRpMLoe5Ash8aL9iprw0JxB+FAHjY+ifn4yZUuW4/9hqtnmor6uvjSRzJhiVbrEQ==} - dev: true - /@types/graceful-fs@4.1.9: resolution: {integrity: sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==} dependencies: @@ -1675,11 +1663,6 @@ packages: path-exists: 4.0.0 dev: true - /flattie@1.1.0: - resolution: {integrity: sha512-xU99gDEnciIwJdGcBmNHnzTJ/w5AT+VFJOu6sTB6WM8diOYNA3Sa+K1DiEBQ7XH4QikQq3iFW1U+jRVcotQnBw==} - engines: {node: '>=8'} - dev: false - /fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} dev: true @@ -2507,11 +2490,6 @@ packages: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} dev: true - /nestie@1.0.3: - resolution: {integrity: sha512-971uBUL8p6TuISghZ4Qxx35DKgRKVuOLNBl9Q6CJNIcwh79zAB07d3bkrN3Sh3xbOhyzdUS9V7t0KV6/gyOl+g==} - engines: {node: '>=8'} - dev: false - /next@14.0.4(@babel/core@7.23.6)(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-qbwypnM7327SadwFtxXnQdGiKpkuhaRLE2uq62/nRul9cj9KhQ5LhHmlziTNqUidZotw/Q1I9OjirBROdUJNgA==} engines: {node: '>=18.17.0'} @@ -3152,6 +3130,11 @@ packages: engines: {node: '>=10'} dev: true + /type-fest@4.10.2: + resolution: {integrity: sha512-anpAG63wSpdEbLwOqH8L84urkL6PiVIov3EMmgIhhThevh9aiMQov+6Btx0wldNcvm4wV+e2/Rt1QdDwKHFbHw==} + engines: {node: '>=16'} + dev: true + /typescript@4.8.3: resolution: {integrity: sha512-goMHfm00nWPa8UvR/CPSvykqf6dVV8x/dp0c5mFTMTIu0u0FlGWRioyy7Nn0PGAdHxpJZnuO/ut+PpQ8UiHAig==} engines: {node: '>=4.2.0'} diff --git a/src/serialize.ts b/src/serialize.ts index f789821..bb50830 100644 --- a/src/serialize.ts +++ b/src/serialize.ts @@ -1,6 +1,3 @@ -import { flattie } from 'flattie' -import { nestie } from 'nestie' - export const SkipSerialization = Symbol( 'ba1894562a5b3e00de68679b5e01fed0de0a0aac6da459729c2f4d665d928ccc', ) @@ -9,47 +6,65 @@ type SerializationRules = ( unserializedValue: T, ) => string | number | boolean | null | undefined | typeof SkipSerialization -export function serialize(data: Record, rules: SerializationRules) { - if (hasCircularReferences(data)) { - throw new Error('Neither Next.js nor obj-serialize supports circular references.') - } +function detectCircularReferences( + object: unknown, + seenObjects = new Set(), +): boolean { + if (object !== null && typeof object === 'object') { + if (seenObjects.has(object)) return true - return nestie( - Object.entries(flattie(data) as Record).reduce( - (newData, [key, value]) => { - let oldValue = value - const serialized = rules(value) - - if (typeof serialized === typeof SkipSerialization) { - newData[key] = oldValue - } else if (serialized !== oldValue) { - newData[key] = serialized - } - - return newData - }, - {} as Record, - ), - ) as Record + seenObjects.add(object) + for (const key of Object.keys(object as object)) { + if ( + detectCircularReferences((object as { [key: string]: unknown })[key], seenObjects) + ) + return true + } + + seenObjects.delete(object) + } + return false } function hasCircularReferences(object: unknown): boolean { - const seenObjects = new Set() + return detectCircularReferences(object) +} - const detect = (object: unknown): boolean => { - if (object !== null && typeof object === 'object') { - if (seenObjects.has(object)) return true +function isPlainObject(obj: unknown): obj is Record { + return Object.prototype.toString.call(obj) === '[object Object]' +} - seenObjects.add(object) +function processValue(value: unknown, rules: SerializationRules): unknown { + if (Array.isArray(value)) { + return value + .map((item) => processValue(item, rules)) + .filter((item) => item !== SkipSerialization) + } else if (typeof value === 'object' && value !== null && isPlainObject(value)) { + const processedObject = Object.fromEntries( + Object.entries(value) + .map(([key, val]) => [key, processValue(val, rules)]) + .filter(([, val]) => val !== SkipSerialization), + ) - for (const key of Object.keys(object as object)) - if (detect((object as { [key: string]: unknown })[key])) return true + if (Object.keys(processedObject).length === 0 && !rules(value)) + return SkipSerialization - seenObjects.delete(object) - } + return processedObject + } else { + const serialized = rules(value) + if (serialized === SkipSerialization) return value - return false + return serialized + } +} + +export function serialize(data: unknown, rules: SerializationRules) { + if (hasCircularReferences(data)) { + throw new Error('Neither Next.js nor obj-serialize supports circular references.') } - return detect(object) + const result = processValue(data, rules) + return result === SkipSerialization + ? ({} as Record) + : (result as Record) } diff --git a/tests/__stubs__/unserializable-data.ts b/tests/__stubs__/unserializable-data.ts index 309c1e0..5f2b2a1 100644 --- a/tests/__stubs__/unserializable-data.ts +++ b/tests/__stubs__/unserializable-data.ts @@ -1,75 +1,296 @@ -export const oneLevelDeepUnserializableData = { - a: 'bar', - b: 123, - c: new Date(), - d: undefined, +import type { JsonValue } from 'type-fest' + +type TestData = { + jsonUnserializable: Record + serialized: JsonValue } -export function createCircularReferenceUnserializableData() { - const a: Record = { - ...oneLevelDeepUnserializableData, - } - const b: Record = { - ...oneLevelDeepUnserializableData, - } - a.circularProperty = b - b.circularProperty = a - return a +export const oneLevelDeep: TestData = { + jsonUnserializable: { + a: 'bar', + b: 123, + c: new Date('2024-02-08T04:17:54.902Z'), + d: undefined, + }, + serialized: { + a: 'bar', + b: 123, + c: '2024-02-08T04:17:54.902Z', + d: null, + }, } -export const tenLevelDeepUnserializableData = { - a: { - j: 'bar', - k: 123, - l: new Date(), - m: undefined, - b: { - j: 'bar', - k: 123, - l: new Date(), - m: undefined, - c: { - j: 'bar', - k: 123, - l: new Date(), - m: undefined, - d: { - j: 'bar', - k: 123, - l: new Date(), - m: undefined, +export const fiveLevelsDeep: TestData = { + jsonUnserializable: { + a: 'bar', + b: 123, + c: new Date('2024-02-08T04:17:54.902Z'), + d: undefined, + e: { + a: 'bar', + b: 123, + c: new Date('2024-02-08T04:17:54.902Z'), + d: undefined, + e: { + a: 'bar', + b: 123, + c: new Date('2024-02-08T04:17:54.902Z'), + d: undefined, + e: { + a: 'bar', + b: 123, + c: new Date('2024-02-08T04:17:54.902Z'), + d: undefined, e: { - j: 'bar', - k: 123, - l: new Date(), - m: undefined, - f: { - j: 'bar', - k: 123, - l: new Date(), - m: undefined, - g: { - j: 'bar', - k: 123, - l: new Date(), - m: undefined, - h: { - j: 'bar', - k: 123, - l: new Date(), - m: undefined, - i: { - j: 'bar', - k: 123, - l: new Date(), - m: undefined, - }, - }, - }, - }, + a: 'bar', + b: 123, + c: new Date('2024-02-08T04:17:54.902Z'), + d: undefined, + }, + }, + }, + }, + }, + serialized: { + a: 'bar', + b: 123, + c: '2024-02-08T04:17:54.902Z', + d: null, + e: { + a: 'bar', + b: 123, + c: '2024-02-08T04:17:54.902Z', + d: null, + e: { + a: 'bar', + b: 123, + c: '2024-02-08T04:17:54.902Z', + d: null, + e: { + a: 'bar', + b: 123, + c: '2024-02-08T04:17:54.902Z', + d: null, + e: { + a: 'bar', + b: 123, + c: '2024-02-08T04:17:54.902Z', + d: null, + }, + }, + }, + }, + }, +} + +export const oneLevelDeepWithArrays: TestData = { + jsonUnserializable: { + a: 'bar', + b: 123, + c: new Date('2024-02-08T04:17:54.902Z'), + d: undefined, + e: [1, 2, 3], + f: ['foo', 'bar', 'baz'], + }, + serialized: { + a: 'bar', + b: 123, + c: '2024-02-08T04:17:54.902Z', + d: null, + e: [1, 2, 3], + f: ['foo', 'bar', 'baz'], + }, +} + +export const fiveLevelsDeepWithArrays: TestData = { + jsonUnserializable: { + a: 'bar', + b: 123, + c: new Date('2024-02-08T04:17:54.902Z'), + d: undefined, + e: [1, 2, 3], + f: ['foo', 'bar', 'baz'], + g: { + a: 'bar', + b: 123, + c: new Date('2024-02-08T04:17:54.902Z'), + d: undefined, + e: [1, 2, 3], + f: ['foo', 'bar', 'baz'], + g: { + a: 'bar', + b: 123, + c: new Date('2024-02-08T04:17:54.902Z'), + d: undefined, + e: [1, 2, 3], + f: ['foo', 'bar', 'baz'], + g: { + a: 'bar', + b: 123, + c: new Date('2024-02-08T04:17:54.902Z'), + d: undefined, + e: [1, 2, 3], + f: ['foo', 'bar', 'baz'], + g: { + a: 'bar', + b: 123, + c: new Date('2024-02-08T04:17:54.902Z'), + d: undefined, + e: [1, 2, 3], + f: ['foo', 'bar', 'baz'], }, }, }, }, }, + serialized: { + a: 'bar', + b: 123, + c: '2024-02-08T04:17:54.902Z', + d: null, + e: [1, 2, 3], + f: ['foo', 'bar', 'baz'], + g: { + a: 'bar', + b: 123, + c: '2024-02-08T04:17:54.902Z', + d: null, + e: [1, 2, 3], + f: ['foo', 'bar', 'baz'], + g: { + a: 'bar', + b: 123, + c: '2024-02-08T04:17:54.902Z', + d: null, + e: [1, 2, 3], + f: ['foo', 'bar', 'baz'], + g: { + a: 'bar', + b: 123, + c: '2024-02-08T04:17:54.902Z', + d: null, + e: [1, 2, 3], + f: ['foo', 'bar', 'baz'], + g: { + a: 'bar', + b: 123, + c: '2024-02-08T04:17:54.902Z', + d: null, + e: [1, 2, 3], + f: ['foo', 'bar', 'baz'], + }, + }, + }, + }, + }, +} + +export const oneLevelDeepWithMixedArrays: TestData = { + jsonUnserializable: { + a: [ + 'bar', + 123, + new Date('2024-02-08T04:17:54.902Z'), + undefined, + { + b: ['bar', 123, new Date('2024-02-08T04:17:54.902Z'), undefined], + }, + ], + }, + serialized: { + a: [ + 'bar', + 123, + '2024-02-08T04:17:54.902Z', + null, + { + b: ['bar', 123, '2024-02-08T04:17:54.902Z', null], + }, + ], + }, +} + +export const fiveLevelsDeepWithMixedArrays: TestData = { + jsonUnserializable: { + a: [ + 'bar', + 123, + new Date('2024-02-08T04:17:54.902Z'), + undefined, + { + b: [ + 'bar', + 123, + new Date('2024-02-08T04:17:54.902Z'), + undefined, + { + c: [ + 'bar', + 123, + new Date('2024-02-08T04:17:54.902Z'), + undefined, + { + d: [ + 'bar', + 123, + new Date('2024-02-08T04:17:54.902Z'), + undefined, + { + e: ['bar', 123, new Date('2024-02-08T04:17:54.902Z'), undefined], + }, + ], + }, + ], + }, + ], + }, + ], + }, + serialized: { + a: [ + 'bar', + 123, + '2024-02-08T04:17:54.902Z', + null, + { + b: [ + 'bar', + 123, + '2024-02-08T04:17:54.902Z', + null, + { + c: [ + 'bar', + 123, + '2024-02-08T04:17:54.902Z', + null, + { + d: [ + 'bar', + 123, + '2024-02-08T04:17:54.902Z', + null, + { + e: ['bar', 123, '2024-02-08T04:17:54.902Z', null], + }, + ], + }, + ], + }, + ], + }, + ], + }, +} + +export function createCircularReferenceUnserializableData() { + const a: Record = { + ...oneLevelDeep.jsonUnserializable, + } + const b: Record = { + ...oneLevelDeep.jsonUnserializable, + } + a.circularProperty = b + b.circularProperty = a + return a } diff --git a/tests/predefined.test.ts b/tests/predefined.test.ts index 221374a..64dab65 100644 --- a/tests/predefined.test.ts +++ b/tests/predefined.test.ts @@ -4,7 +4,12 @@ import { isSerializableProps as nextPageIsSerializableProps } from 'next/dist/li import { nextServerSideSerialize } from '../src/predefined' import { createCircularReferenceUnserializableData, - oneLevelDeepUnserializableData, + oneLevelDeep, + oneLevelDeepWithArrays, + oneLevelDeepWithMixedArrays, + fiveLevelsDeep, + fiveLevelsDeepWithArrays, + fiveLevelsDeepWithMixedArrays, } from './__stubs__/unserializable-data' function emulatedIsSerializableProps(props: Record) { @@ -21,16 +26,54 @@ function emulatedIsSerializableProps(props: Record) { } describe('nextServerSideSerialize', () => { - it('should serialize not JSON serializable one level deep object', () => { - const serializedData = nextServerSideSerialize(oneLevelDeepUnserializableData) + it('should serialize not JSON serializable 1 level deep object', () => { + const serializedData = nextServerSideSerialize(oneLevelDeep.jsonUnserializable) expect(emulatedIsSerializableProps(serializedData)).toBe(true) + expect(serializedData).toStrictEqual(oneLevelDeep.serialized) }) - it('should serialize not JSON serializable ten levels deep object', () => { - const serializedData = nextServerSideSerialize(oneLevelDeepUnserializableData) + it('should serialize not JSON serializable 1 level deep object with arrays', () => { + const serializedData = nextServerSideSerialize( + oneLevelDeepWithArrays.jsonUnserializable, + ) + + expect(emulatedIsSerializableProps(serializedData)).toBe(true) + expect(serializedData).toStrictEqual(oneLevelDeepWithArrays.serialized) + }) + + it('should serialize not JSON serializable 1 level deep object with arrays and objects mixed', () => { + const serializedData = nextServerSideSerialize( + oneLevelDeepWithMixedArrays.jsonUnserializable, + ) + + expect(emulatedIsSerializableProps(serializedData)).toBe(true) + expect(serializedData).toStrictEqual(oneLevelDeepWithMixedArrays.serialized) + }) + + it('should serialize not JSON serializable 5 levels deep object', () => { + const serializedData = nextServerSideSerialize(fiveLevelsDeep.jsonUnserializable) + + expect(emulatedIsSerializableProps(serializedData)).toBe(true) + expect(serializedData).toStrictEqual(fiveLevelsDeep.serialized) + }) + + it('should serialize not JSON serializable 5 levels deep object with arrays', () => { + const serializedData = nextServerSideSerialize( + fiveLevelsDeepWithArrays.jsonUnserializable, + ) + + expect(emulatedIsSerializableProps(serializedData)).toBe(true) + expect(serializedData).toStrictEqual(fiveLevelsDeepWithArrays.serialized) + }) + + it('should serialize not JSON serializable 5 levels deep object with arrays and objects mixed', () => { + const serializedData = nextServerSideSerialize( + fiveLevelsDeepWithMixedArrays.jsonUnserializable, + ) expect(emulatedIsSerializableProps(serializedData)).toBe(true) + expect(serializedData).toStrictEqual(fiveLevelsDeepWithMixedArrays.serialized) }) it('should not serialize object with circular references', () => {