diff --git a/src/core/encryption.ts b/src/core/encryption.ts index 188ca70..ffac305 100644 --- a/src/core/encryption.ts +++ b/src/core/encryption.ts @@ -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' @@ -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) { @@ -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 + newSafeParse ) const decryptedAndParsedOutputs = (await decrypt(privateKey, capture.outputs)).andThen( - safeParse + newSafeParse ) const decryptedAndParsedError = capture.error - ? (await decrypt(privateKey, capture.error)).andThen(safeParse) + ? (await decrypt(privateKey, capture.error)).andThen(newSafeParse) : undefined const parseResults = Result.all(decryptedAndParsedArgs, decryptedAndParsedOutputs) diff --git a/src/core/storage.ts b/src/core/storage.ts index b232705..97ebc66 100644 --- a/src/core/storage.ts +++ b/src/core/storage.ts @@ -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' @@ -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( capturedFunctions: T[] @@ -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) + if (processCallsResult.err) { + return processCallsResult + } + + const processFunctionsResult = newSafeStringify(functions).andThen( + newSafeParse + ) + 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) { @@ -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 } @@ -200,5 +199,8 @@ export async function saveCapture( }". Payload Size: ${formatBytes(stringifiedPayload.val.length)}` ) + clearCapturedFunctions() + clearCapturedCalls() + return captureRequestResult } diff --git a/src/core/stringify.ts b/src/core/stringify.ts index 4f0694b..c7a1198 100644 --- a/src/core/stringify.ts +++ b/src/core/stringify.ts @@ -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' @@ -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 = new Set()): any { +export function removeCirculars(obj: any, parentObjects: Set = new Set()): any { if (obj !== null && typeof obj === 'object') { if (parentObjects.has(obj)) { return FLYTRAP_UNSERIALIZABLE_VALUE @@ -189,6 +189,79 @@ function isSuperJsonSerializable(input: any) { } } +function isClassInstance(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( + { + isApplicable: (v): v is Function => typeof v === 'function', + serialize: () => FLYTRAP_UNSERIALIZABLE_VALUE, + deserialize: () => FLYTRAP_UNSERIALIZABLE_VALUE + }, + 'Functions' + ) + + // Unsupported classes + superJsonInstance.registerCustom( + { + 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(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(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++) { diff --git a/src/replay/index.ts b/src/replay/index.ts index 170d21d..bb01a6a 100644 --- a/src/replay/index.ts +++ b/src/replay/index.ts @@ -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 { @@ -32,7 +32,7 @@ const isomorphicStorage: IsomorphicStorage = { if (savedCaptureString === null) { return undefined } - return safeParse(savedCaptureString).unwrap() + return newSafeParse(savedCaptureString).unwrap() } else { // NodeJS implementation const { readFileSync } = require('fs') @@ -42,7 +42,7 @@ const isomorphicStorage: IsomorphicStorage = { try { const captureStringified = readFileSync(join(getCacheDir(), `${captureId}.json`), 'utf-8') if (captureStringified === null) return undefined - return safeParse(captureStringified).unwrap() + return newSafeParse(captureStringified).unwrap() } catch (e) { log.error('error', `Replaying error when fetching stored captures: Error: ${String(e)}`) return undefined @@ -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 @@ -63,7 +63,7 @@ const isomorphicStorage: IsomorphicStorage = { mkdirSync(getCacheDir(), { recursive: true }) return writeFileSync( join(getCacheDir(), `${captureId}.json`), - safeStringify(capture).unwrap() + newSafeStringify(capture).unwrap() ) } } diff --git a/test/serialization.test.ts b/test/serialization.test.ts index a2c9909..28ccccf 100644 --- a/test/serialization.test.ts +++ b/test/serialization.test.ts @@ -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 { @@ -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 ) } diff --git a/test/transform/ecosystem-expanded.ts b/test/transform/ecosystem-expanded.ts index f48634a..9053e65 100644 --- a/test/transform/ecosystem-expanded.ts +++ b/test/transform/ecosystem-expanded.ts @@ -107,6 +107,7 @@ const targets: Record = { } }, */ svelte: { + only: true, // repo: 'sveltejs/svelte', repo: 'git@github.com:sveltejs/svelte.git', sourcePaths: ['packages/svelte/src'], @@ -125,7 +126,10 @@ const targets: Record = { projectId: 'test-project', transformOptions: {}, // disable logging - logging: [] + logging: [], + generateBuildId() { + return '00000000-0000-0000-0000-000000000000' + } } }, prettier: { @@ -155,7 +159,6 @@ const targets: Record = { } }, vue: { - only: true, // repo: 'vuejs/core', repo: 'git@github.com:vuejs/core.git', sourcePaths: ['packages'], @@ -192,7 +195,10 @@ const targets: Record = { projectId: 'test-project', transformOptions: {}, // disable logging - logging: [] + logging: [], + generateBuildId() { + return '00000000-0000-0000-0000-000000000000' + } } } // @todo: @@ -212,6 +218,8 @@ const targets: Record = { } 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)