Skip to content

Commit

Permalink
fix: make serialization work with more difficult objects
Browse files Browse the repository at this point in the history
  • Loading branch information
skoshx committed Dec 19, 2023
1 parent 5765510 commit 36e2e13
Show file tree
Hide file tree
Showing 6 changed files with 143 additions and 54 deletions.
22 changes: 14 additions & 8 deletions src/core/encryption.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,14 @@ import {
CapturedFunction,
DatabaseCapture
} from './types'
import { addLinksToCaptures, extractArgs, extractOutputs, reviveLinks } from './stringify'
import { safeParse, safeStringify } from './stringify'
import {
addLinksToCaptures,
extractArgs,
extractOutputs,
newSafeParse,
newSafeStringify,
reviveLinks
} from './stringify'
import { serializeError } from 'serialize-error'
import { NO_SOURCE } from './constants'
import { createHumanLog } from './errors'
Expand Down Expand Up @@ -314,9 +320,9 @@ export async function encryptCapture(
const serializedError = serializeError(error)

const stringifyResult = Result.all(
safeStringify(args),
safeStringify(outputs),
error !== undefined ? safeStringify(serializedError) : Ok(undefined)
newSafeStringify(args),
newSafeStringify(outputs),
error !== undefined ? newSafeStringify(serializedError) : Ok(undefined)
)

if (stringifyResult.err) {
Expand Down Expand Up @@ -356,13 +362,13 @@ export async function encryptCapture(

export async function decryptCapture(capture: DatabaseCapture, privateKey: string) {
const decryptedAndParsedArgs = (await decrypt(privateKey, capture.args)).andThen(
safeParse<any[][]>
newSafeParse<any[][]>
)
const decryptedAndParsedOutputs = (await decrypt(privateKey, capture.outputs)).andThen(
safeParse<any[]>
newSafeParse<any[]>
)
const decryptedAndParsedError = capture.error
? (await decrypt(privateKey, capture.error)).andThen(safeParse<Error>)
? (await decrypt(privateKey, capture.error)).andThen(newSafeParse<Error>)
: undefined

const parseResults = Result.all(decryptedAndParsedArgs, decryptedAndParsedOutputs)
Expand Down
66 changes: 34 additions & 32 deletions src/core/storage.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Err, Ok } from 'ts-results'
import { Err, Ok, Result } from 'ts-results'
import { getApiBase, getLoadedConfig } from './config'
import { CapturedCall, CapturedFunction, DatabaseCapture, FlytrapConfig } from './types'
import { empty } from './util'
Expand All @@ -7,11 +7,10 @@ import { log } from './logging'
import { getLimitedCaptures } from './captureLimits'
import { shouldIgnoreCapture } from './captureIgnores'
import { formatBytes } from './util'
import { getUserId } from '../index'
import { removeCircularsAndNonPojos, removeUnserializableValues, safeStringify } from './stringify'
import { clearCapturedCalls, clearCapturedFunctions, getUserId } from '../index'
import { newSafeParse, newSafeStringify, removeCirculars } from './stringify'
import { decryptCapture, encryptCapture } from './encryption'
import { request } from './requestUtils'
import { deserialize, serialize, stringify } from 'superjson'

function findWithLatestErrorInvocation<T extends CapturedCall | CapturedFunction>(
capturedFunctions: T[]
Expand Down Expand Up @@ -94,49 +93,49 @@ export async function saveCapture(
)
}

function processCaptures(captures: any[]) {
for (let i = 0; i < captures.length; i++) {
captures[i] = deserialize(serialize(captures[i]))
}
return captures
const processCallsResult = newSafeStringify(calls).andThen(newSafeParse<CapturedCall[]>)
if (processCallsResult.err) {
return processCallsResult
}

const processFunctionsResult = newSafeStringify(functions).andThen(
newSafeParse<CapturedFunction[]>
)
if (processFunctionsResult.err) {
return processFunctionsResult
}

// Remove unserializable values
calls = removeUnserializableValues(calls)
functions = removeUnserializableValues(functions)
calls = processCaptures(calls)
functions = processCaptures(functions)
calls = processCallsResult.val
functions = processFunctionsResult.val

// Remove the circular from `calls` and `functions`
for (let i = 0; i < calls.length; i++) {
for (let j = 0; j < calls[i].invocations.length; j++) {
calls[i].invocations[j].args = calls[i].invocations[j].args.map((a) =>
removeCircularsAndNonPojos(a)
)
calls[i].invocations[j].output = removeCircularsAndNonPojos(calls[i].invocations[j].output)
calls[i].invocations[j].args = calls[i].invocations[j].args.map((a) => removeCirculars(a))
calls[i].invocations[j].output = removeCirculars(calls[i].invocations[j].output)
}
}
for (let i = 0; i < functions.length; i++) {
for (let j = 0; j < functions[i].invocations.length; j++) {
functions[i].invocations[j].args = functions[i].invocations[j].args.map((a) =>
removeCircularsAndNonPojos(a)
)
functions[i].invocations[j].output = removeCircularsAndNonPojos(
functions[i].invocations[j].output
removeCirculars(a)
)
functions[i].invocations[j].output = removeCirculars(functions[i].invocations[j].output)
}
}

// Handle capture amount limits
if (config.captureAmountLimit) {
const limitedCapturesResult = getLimitedCaptures(calls, functions, config.captureAmountLimit)
// Handle capture amount limits (by default limit at 4mb)
const limitedCapturesResult = getLimitedCaptures(
calls,
functions,
config?.captureAmountLimit ?? '4mb'
)

if (limitedCapturesResult.err) {
log.error('error', limitedCapturesResult.val.toString())
} else {
calls = limitedCapturesResult.val.calls
functions = limitedCapturesResult.val.functions
}
if (limitedCapturesResult.err) {
log.error('error', limitedCapturesResult.val.toString())
} else {
calls = limitedCapturesResult.val.calls
functions = limitedCapturesResult.val.functions
}

if (!config.buildId) {
Expand Down Expand Up @@ -172,7 +171,7 @@ export async function saveCapture(
}

// Then payload gets stringified
const stringifiedPayload = safeStringify(encryptedCaptureResult.val)
const stringifiedPayload = newSafeStringify(encryptedCaptureResult.val)
if (stringifiedPayload.err) {
return stringifiedPayload
}
Expand Down Expand Up @@ -200,5 +199,8 @@ export async function saveCapture(
}". Payload Size: ${formatBytes(stringifiedPayload.val.length)}`
)

clearCapturedFunctions()
clearCapturedCalls()

return captureRequestResult
}
79 changes: 76 additions & 3 deletions src/core/stringify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import {
CapturedCall,
CapturedFunction
} from './types'
import { parse, serialize, stringify } from 'superjson'
import SuperJSON, { parse, serialize, stringify } from 'superjson'
import { Err, Ok } from 'ts-results'
import { FLYTRAP_UNSERIALIZABLE_VALUE } from './constants'
import { createHumanLog } from './errors'
Expand Down Expand Up @@ -109,10 +109,10 @@ export function reviveLinks(
}

export function getCaptureSize(capture: CapturedCall | CapturedFunction) {
return safeStringify(capture).map((stringifiedCapture) => stringifiedCapture.length)
return newSafeStringify(capture).map((stringifiedCapture) => stringifiedCapture.length)
}

function removeCirculars(obj: any, parentObjects: Set<any> = new Set()): any {
export function removeCirculars(obj: any, parentObjects: Set<any> = new Set()): any {
if (obj !== null && typeof obj === 'object') {
if (parentObjects.has(obj)) {
return FLYTRAP_UNSERIALIZABLE_VALUE
Expand Down Expand Up @@ -189,6 +189,79 @@ function isSuperJsonSerializable(input: any) {
}
}

function isClassInstance<T>(obj: T): boolean {
return (
obj !== null &&
typeof obj === 'object' &&
!(obj instanceof Array) &&
obj.constructor &&
obj.constructor !== Object
)
}

export function superJsonRegisterCustom(superJsonInstance: typeof SuperJSON) {
// Functions
superJsonInstance.registerCustom<Function | string, string>(
{
isApplicable: (v): v is Function => typeof v === 'function',
serialize: () => FLYTRAP_UNSERIALIZABLE_VALUE,
deserialize: () => FLYTRAP_UNSERIALIZABLE_VALUE
},
'Functions'
)

// Unsupported classes
superJsonInstance.registerCustom<any, string>(
{
isApplicable: (v): v is any => {
const SUPPORTED_CLASSES = [Array, Date, RegExp, Set, Map, Error, URL]

const isSupportedClass = SUPPORTED_CLASSES.some(
(classInstance) => v instanceof classInstance
)
return isClassInstance(v) && !isSupportedClass
},
serialize: () => FLYTRAP_UNSERIALIZABLE_VALUE,
deserialize: () => FLYTRAP_UNSERIALIZABLE_VALUE
},
'Classes'
)
}

export function newSafeStringify<T>(object: T) {
superJsonRegisterCustom(SuperJSON)

try {
return Ok(SuperJSON.stringify(object))
} catch (e) {
return Err(
createHumanLog({
explanations: ['stringify_object_failed'],
params: {
stringifyError: String(e)
}
})
)
}
}

export function newSafeParse<T>(input: string) {
superJsonRegisterCustom(SuperJSON)

try {
return Ok(removeCirculars(SuperJSON.parse(input) as T))
} catch (e) {
return Err(
createHumanLog({
explanations: ['parsing_object_failed'],
params: {
parsingError: String(e)
}
})
)
}
}

export function removeUnserializableValues(captures: (CapturedCall | CapturedFunction)[]) {
for (let i = 0; i < captures.length; i++) {
for (let j = 0; j < captures[i].invocations.length; j++) {
Expand Down
10 changes: 5 additions & 5 deletions src/replay/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { CaptureDecryptedAndRevived } from '../core/types'
import { createHumanLog } from '../core/errors'
import { fetchCapture } from '../core/storage'
import { log } from '../core/logging'
import { safeParse, safeStringify } from '../core/stringify'
import { newSafeParse, newSafeStringify } from '../core/stringify'

function isBrowser(): boolean {
try {
Expand All @@ -32,7 +32,7 @@ const isomorphicStorage: IsomorphicStorage = {
if (savedCaptureString === null) {
return undefined
}
return safeParse<CaptureDecryptedAndRevived>(savedCaptureString).unwrap()
return newSafeParse<CaptureDecryptedAndRevived>(savedCaptureString).unwrap()
} else {
// NodeJS implementation
const { readFileSync } = require('fs')
Expand All @@ -42,7 +42,7 @@ const isomorphicStorage: IsomorphicStorage = {
try {
const captureStringified = readFileSync(join(getCacheDir(), `${captureId}.json`), 'utf-8')
if (captureStringified === null) return undefined
return safeParse<CaptureDecryptedAndRevived>(captureStringified).unwrap()
return newSafeParse<CaptureDecryptedAndRevived>(captureStringified).unwrap()
} catch (e) {
log.error('error', `Replaying error when fetching stored captures: Error: ${String(e)}`)
return undefined
Expand All @@ -52,7 +52,7 @@ const isomorphicStorage: IsomorphicStorage = {
setItem(captureId, capture) {
if (isBrowser()) {
// Implementation for browser
const stringifiedCapture = safeStringify(capture)
const stringifiedCapture = newSafeStringify(capture)
localStorage.setItem(captureId, stringifiedCapture.unwrap())
} else {
// NodeJS implementation
Expand All @@ -63,7 +63,7 @@ const isomorphicStorage: IsomorphicStorage = {
mkdirSync(getCacheDir(), { recursive: true })
return writeFileSync(
join(getCacheDir(), `${captureId}.json`),
safeStringify(capture).unwrap()
newSafeStringify(capture).unwrap()
)
}
}
Expand Down
6 changes: 3 additions & 3 deletions test/serialization.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, it, test } from 'vitest'
import { GlobalRegistrator } from '@happy-dom/global-registrator'
import { safeParse, safeStringify } from '../src/core/stringify'
import { newSafeParse, newSafeStringify } from '../src/core/stringify'
GlobalRegistrator.register()

export interface ErrorWithBatchIndex {
Expand Down Expand Up @@ -162,13 +162,13 @@ for (const [describeName, testObject] of Object.entries(serializationTestFixture
for (const [testName, testFixtures] of Object.entries(testObject)) {
test(testName, async () => {
for (let i = 0; i < testFixtures.length; i++) {
const parsed = safeParse(safeStringify(testFixtures[i].value).unwrap()).unwrap()
const parsed = newSafeParse(newSafeStringify(testFixtures[i].value).unwrap()).unwrap()
if (parsed instanceof Response) {
isMatchingResponse(parsed, testFixtures[i].value)
} else if (parsed instanceof Request) {
isMatchingRequest(parsed, testFixtures[i].value)
} else {
expect(safeParse(safeStringify(testFixtures[i].value).unwrap()).unwrap()).toEqual(
expect(newSafeParse(newSafeStringify(testFixtures[i].value).unwrap()).unwrap()).toEqual(
testFixtures[i].value
)
}
Expand Down
14 changes: 11 additions & 3 deletions test/transform/ecosystem-expanded.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ const targets: Record<string, Target> = {
}
}, */
svelte: {
only: true,
// repo: 'sveltejs/svelte',
repo: '[email protected]:sveltejs/svelte.git',
sourcePaths: ['packages/svelte/src'],
Expand All @@ -125,7 +126,10 @@ const targets: Record<string, Target> = {
projectId: 'test-project',
transformOptions: {},
// disable logging
logging: []
logging: [],
generateBuildId() {
return '00000000-0000-0000-0000-000000000000'
}
}
},
prettier: {
Expand Down Expand Up @@ -155,7 +159,6 @@ const targets: Record<string, Target> = {
}
},
vue: {
only: true,
// repo: 'vuejs/core',
repo: '[email protected]:vuejs/core.git',
sourcePaths: ['packages'],
Expand Down Expand Up @@ -192,7 +195,10 @@ const targets: Record<string, Target> = {
projectId: 'test-project',
transformOptions: {},
// disable logging
logging: []
logging: [],
generateBuildId() {
return '00000000-0000-0000-0000-000000000000'
}
}
}
// @todo:
Expand All @@ -212,6 +218,8 @@ const targets: Record<string, Target> = {
}

async function transformRepositoryUsingFlytrap(targetName: string, target: Target) {
// @ts-expect-error: simulate build start
await unpluginOptions.buildStart()
for await (const filePath of walk(join(reposPath, targetName))) {
const copyToGeneratedFolder = (filePath: string, code: string) => {
writeFileSync(join(generatedPath, getRelativePath(filePath)), code)
Expand Down

0 comments on commit 36e2e13

Please sign in to comment.