diff --git a/packages/twenty-front-component-renderer/jest.config.mjs b/packages/twenty-front-component-renderer/jest.config.mjs index d9045ebb41..42dc18dd87 100644 --- a/packages/twenty-front-component-renderer/jest.config.mjs +++ b/packages/twenty-front-component-renderer/jest.config.mjs @@ -13,9 +13,10 @@ const jestConfig = { displayName: 'twenty-front-component-renderer', preset: '../../jest.preset.js', testEnvironment: 'jsdom', - transformIgnorePatterns: ['../../node_modules/'], + setupFiles: ['/jest.setup.mjs'], + transformIgnorePatterns: ['node_modules/(?!@quilted/)'], transform: { - '^.+\\.[tj]sx?$': [ + '^.+\\.(mjs|[tj]sx?)$': [ '@swc/jest', { jsc: { @@ -29,8 +30,12 @@ const jestConfig = { ...pathsToModuleNameMapper(tsConfig.compilerOptions.paths, { prefix: '/', }), + '^@quilted/threads$': + '/../../node_modules/@quilted/threads/build/esm/index.mjs', + '^@quilted/events$': + '/../../node_modules/@quilted/events/build/esm/index.mjs', }, - moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'], + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'mjs'], extensionsToTreatAsEsm: ['.ts', '.tsx'], coverageDirectory: './coverage', }; diff --git a/packages/twenty-front-component-renderer/jest.setup.mjs b/packages/twenty-front-component-renderer/jest.setup.mjs new file mode 100644 index 0000000000..1c93e8e30d --- /dev/null +++ b/packages/twenty-front-component-renderer/jest.setup.mjs @@ -0,0 +1,5 @@ +import { deserialize, serialize } from 'node:v8'; + +if (typeof globalThis.structuredClone !== 'function') { + globalThis.structuredClone = (value) => deserialize(serialize(value)); +} diff --git a/packages/twenty-front-component-renderer/src/constants/FrontComponentThreadErrorMarker.ts b/packages/twenty-front-component-renderer/src/constants/FrontComponentThreadErrorMarker.ts new file mode 100644 index 0000000000..65a2235c5b --- /dev/null +++ b/packages/twenty-front-component-renderer/src/constants/FrontComponentThreadErrorMarker.ts @@ -0,0 +1,2 @@ +export const FRONT_COMPONENT_THREAD_ERROR_MARKER = + '__frontComponentThreadError'; diff --git a/packages/twenty-front-component-renderer/src/host/utils/__tests__/frontComponentCacheStorageService.test.ts b/packages/twenty-front-component-renderer/src/host/utils/__tests__/frontComponentCacheStorageService.test.ts new file mode 100644 index 0000000000..95ee91fd22 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/host/utils/__tests__/frontComponentCacheStorageService.test.ts @@ -0,0 +1,43 @@ +import { frontComponentCacheStorageService } from '../frontComponentCacheStorageService'; + +describe('frontComponentCacheStorageService', () => { + describe('open', () => { + afterEach(() => { + Reflect.deleteProperty(globalThis, 'caches'); + }); + + it('should return undefined when accessing caches throws, as in Firefox opaque-origin contexts', async () => { + Object.defineProperty(globalThis, 'caches', { + configurable: true, + get() { + throw new Error('NS_ERROR_FAILURE'); + }, + }); + + await expect(frontComponentCacheStorageService.open()).resolves.toBe( + undefined, + ); + }); + + it('should return undefined when caches is not available', async () => { + await expect(frontComponentCacheStorageService.open()).resolves.toBe( + undefined, + ); + }); + + it('should open the front component cache when caches is available', async () => { + const fakeCache = { match: jest.fn() }; + const open = jest.fn().mockResolvedValue(fakeCache); + + Object.defineProperty(globalThis, 'caches', { + configurable: true, + value: { open }, + }); + + await expect(frontComponentCacheStorageService.open()).resolves.toBe( + fakeCache, + ); + expect(open).toHaveBeenCalledWith('front-component-source-v1'); + }); + }); +}); diff --git a/packages/twenty-front-component-renderer/src/host/utils/createFrontComponentHostThread.ts b/packages/twenty-front-component-renderer/src/host/utils/createFrontComponentHostThread.ts index 3a1d82041c..ef70ea9016 100644 --- a/packages/twenty-front-component-renderer/src/host/utils/createFrontComponentHostThread.ts +++ b/packages/twenty-front-component-renderer/src/host/utils/createFrontComponentHostThread.ts @@ -5,6 +5,7 @@ import { type FrontComponentHostThreadExports } from '@/types/FrontComponentHost import { type FrontComponentThread } from '@/types/FrontComponentThread'; import { type HostFetchFunction } from '@/types/HostFetchFunction'; import { type WorkerExports } from '@/types/WorkerExports'; +import { createClonableErrorThreadSerialization } from '@/utils/createClonableErrorThreadSerialization'; export const createFrontComponentHostThread = ( hostMessagePort: MessagePort, @@ -18,6 +19,7 @@ export const createFrontComponentHostThread = ( ...FRONT_COMPONENT_HOST_COMMUNICATION_API_NOOP, hostFetch, }, + serialization: createClonableErrorThreadSerialization(), }); hostMessagePort.start(); diff --git a/packages/twenty-front-component-renderer/src/host/utils/frontComponentCacheStorageService.ts b/packages/twenty-front-component-renderer/src/host/utils/frontComponentCacheStorageService.ts index cf83e4d158..4f5e7674b5 100644 --- a/packages/twenty-front-component-renderer/src/host/utils/frontComponentCacheStorageService.ts +++ b/packages/twenty-front-component-renderer/src/host/utils/frontComponentCacheStorageService.ts @@ -8,11 +8,13 @@ export const frontComponentCacheStorageService = { }: { source: string; }): Promise => { - if (typeof crypto === 'undefined' || !isDefined(crypto.subtle)) { - return undefined; - } - + // Guards stay inside the try block: in Firefox, accessing `caches` or + // `crypto` in an opaque-origin context throws instead of being undefined. try { + if (typeof crypto === 'undefined' || !isDefined(crypto.subtle)) { + return undefined; + } + const digest = await crypto.subtle.digest( 'SHA-256', new TextEncoder().encode(source), @@ -27,11 +29,11 @@ export const frontComponentCacheStorageService = { }, open: async (): Promise => { - if (typeof caches === 'undefined') { - return undefined; - } - try { + if (typeof caches === 'undefined') { + return undefined; + } + return await caches.open(FRONT_COMPONENT_SOURCE_CACHE_NAME); } catch { return undefined; diff --git a/packages/twenty-front-component-renderer/src/remote/sandbox/utils/__tests__/createWorkerSpawnErrorSandboxMessage.test.ts b/packages/twenty-front-component-renderer/src/remote/sandbox/utils/__tests__/createWorkerSpawnErrorSandboxMessage.test.ts index 9fef050b55..8b6111af87 100644 --- a/packages/twenty-front-component-renderer/src/remote/sandbox/utils/__tests__/createWorkerSpawnErrorSandboxMessage.test.ts +++ b/packages/twenty-front-component-renderer/src/remote/sandbox/utils/__tests__/createWorkerSpawnErrorSandboxMessage.test.ts @@ -24,4 +24,24 @@ describe('createWorkerSpawnErrorSandboxMessage', () => { message: 'Failed to spawn the front component worker', }); }); + + it('should stringify foreign exception objects that carry no message', () => { + class FakeGeckoException { + name = 'NS_ERROR_FAILURE'; + message = ''; + stack = '@blob:null/uuid:5:15'; + + toString(): string { + return '[Exception... "Failure" nsresult: "0x80004005 (NS_ERROR_FAILURE)"]'; + } + } + + expect( + createWorkerSpawnErrorSandboxMessage(new FakeGeckoException()), + ).toEqual({ + type: FRONT_COMPONENT_SANDBOX_MESSAGE_TYPE.ERROR, + message: + '[Exception... "Failure" nsresult: "0x80004005 (NS_ERROR_FAILURE)"]', + }); + }); }); diff --git a/packages/twenty-front-component-renderer/src/remote/sandbox/utils/createWorkerSpawnErrorSandboxMessage.ts b/packages/twenty-front-component-renderer/src/remote/sandbox/utils/createWorkerSpawnErrorSandboxMessage.ts index 2cbe5789e5..300650517c 100644 --- a/packages/twenty-front-component-renderer/src/remote/sandbox/utils/createWorkerSpawnErrorSandboxMessage.ts +++ b/packages/twenty-front-component-renderer/src/remote/sandbox/utils/createWorkerSpawnErrorSandboxMessage.ts @@ -2,16 +2,24 @@ import { isNonEmptyString } from '@sniptt/guards'; import { FRONT_COMPONENT_SANDBOX_MESSAGE_TYPE } from '@/remote/sandbox/constants/FrontComponentSandboxMessageType'; import { type FrontComponentSandboxMessage } from '@/remote/sandbox/types/FrontComponentSandboxMessage'; +import { buildClonableErrorPayload } from '@/utils/buildClonableErrorPayload'; +import { isErrorLikeValue } from '@/utils/isErrorLikeValue'; const WORKER_SPAWN_FAILURE_MESSAGE = 'Failed to spawn the front component worker'; export const createWorkerSpawnErrorSandboxMessage = ( error: unknown, -): FrontComponentSandboxMessage => ({ - type: FRONT_COMPONENT_SANDBOX_MESSAGE_TYPE.ERROR, - message: - error instanceof Error && isNonEmptyString(error.message) - ? error.message - : WORKER_SPAWN_FAILURE_MESSAGE, -}); +): FrontComponentSandboxMessage => { + const { name, message } = buildClonableErrorPayload(error); + + const isInformativeMessage = isNonEmptyString(message) && message !== name; + + return { + type: FRONT_COMPONENT_SANDBOX_MESSAGE_TYPE.ERROR, + message: + isErrorLikeValue(error) && isInformativeMessage + ? message + : WORKER_SPAWN_FAILURE_MESSAGE, + }; +}; diff --git a/packages/twenty-front-component-renderer/src/remote/worker/remote-worker.ts b/packages/twenty-front-component-renderer/src/remote/worker/remote-worker.ts index cb557badd2..86a27d3b0d 100644 --- a/packages/twenty-front-component-renderer/src/remote/worker/remote-worker.ts +++ b/packages/twenty-front-component-renderer/src/remote/worker/remote-worker.ts @@ -20,6 +20,7 @@ import { setFrontComponentExecutionContext } from '@/remote/worker/utils/setFron import { type FrontComponentHostThread } from '@/types/FrontComponentHostThread'; import { type FrontComponentHostThreadExports } from '@/types/FrontComponentHostThreadExports'; import { type WorkerExports } from '@/types/WorkerExports'; +import { createClonableErrorThreadSerialization } from '@/utils/createClonableErrorThreadSerialization'; installStylePropertyOnRemoteElements(); patchRemoteElementAttributes(); @@ -71,6 +72,7 @@ self.addEventListener('message', (event) => { WorkerExports >(transferredPort, { exports: workerExports, + serialization: createClonableErrorThreadSerialization(), }); transferredPort.start(); diff --git a/packages/twenty-front-component-renderer/src/types/ClonableErrorPayload.ts b/packages/twenty-front-component-renderer/src/types/ClonableErrorPayload.ts new file mode 100644 index 0000000000..c738d70a7b --- /dev/null +++ b/packages/twenty-front-component-renderer/src/types/ClonableErrorPayload.ts @@ -0,0 +1,6 @@ +export type ClonableErrorPayload = { + name: string; + message: string; + stack?: string; + code?: string; +}; diff --git a/packages/twenty-front-component-renderer/src/types/ErrorLikeValue.ts b/packages/twenty-front-component-renderer/src/types/ErrorLikeValue.ts new file mode 100644 index 0000000000..a8b8d20e4c --- /dev/null +++ b/packages/twenty-front-component-renderer/src/types/ErrorLikeValue.ts @@ -0,0 +1,8 @@ +// `stack` is required: a string stack is the discriminator that tells a real +// thrown value (Error, DOMException, Firefox Gecko Exception) apart from a plain +// data object that merely happens to carry `name` and `message` fields. +export type ErrorLikeValue = { + name: string; + message: string; + stack: string; +}; diff --git a/packages/twenty-front-component-renderer/src/utils/__tests__/buildClonableErrorPayload.test.ts b/packages/twenty-front-component-renderer/src/utils/__tests__/buildClonableErrorPayload.test.ts new file mode 100644 index 0000000000..4b57406597 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/utils/__tests__/buildClonableErrorPayload.test.ts @@ -0,0 +1,58 @@ +import { CustomError } from 'twenty-shared/utils'; + +import { buildClonableErrorPayload } from '../buildClonableErrorPayload'; + +class FakeGeckoException { + name = 'NS_ERROR_FAILURE'; + message = ''; + stack = '@blob:null/uuid:5:15'; + + toString(): string { + return '[Exception... "Failure" nsresult: "0x80004005 (NS_ERROR_FAILURE)"]'; + } +} + +describe('buildClonableErrorPayload', () => { + it('should flatten an Error to name, message and stack', () => { + const error = new Error('boom'); + + expect(buildClonableErrorPayload(error)).toEqual({ + name: 'Error', + message: 'boom', + stack: error.stack, + code: undefined, + }); + }); + + it('should carry the code of a CustomError', () => { + const error = new CustomError('fetch bridge unavailable', 'FETCH_BRIDGE'); + + expect(buildClonableErrorPayload(error)).toMatchObject({ + message: 'fetch bridge unavailable', + code: 'FETCH_BRIDGE', + }); + }); + + it('should fall back to the stringified value when the message is empty', () => { + expect(buildClonableErrorPayload(new FakeGeckoException())).toEqual({ + name: 'NS_ERROR_FAILURE', + message: + '[Exception... "Failure" nsresult: "0x80004005 (NS_ERROR_FAILURE)"]', + stack: '@blob:null/uuid:5:15', + code: undefined, + }); + }); + + it('should stringify values that are not error-like', () => { + expect(buildClonableErrorPayload('exploded')).toEqual({ + name: 'Error', + message: 'exploded', + }); + }); + + it('should produce a structured-cloneable payload', () => { + expect(() => + structuredClone(buildClonableErrorPayload(new FakeGeckoException())), + ).not.toThrow(); + }); +}); diff --git a/packages/twenty-front-component-renderer/src/utils/__tests__/createClonableErrorThreadSerialization.test.ts b/packages/twenty-front-component-renderer/src/utils/__tests__/createClonableErrorThreadSerialization.test.ts new file mode 100644 index 0000000000..86cbec583f --- /dev/null +++ b/packages/twenty-front-component-renderer/src/utils/__tests__/createClonableErrorThreadSerialization.test.ts @@ -0,0 +1,185 @@ +import { Thread } from '@quilted/threads'; +import { CustomError } from 'twenty-shared/utils'; + +import { FRONT_COMPONENT_THREAD_ERROR_MARKER } from '@/constants/FrontComponentThreadErrorMarker'; + +import { createClonableErrorThreadSerialization } from '../createClonableErrorThreadSerialization'; + +class FakeGeckoException { + name = 'NS_ERROR_FAILURE'; + message = ''; + stack = '@blob:null/uuid:5:15'; + + toString(): string { + return '[Exception... "Failure" nsresult: "0x80004005 (NS_ERROR_FAILURE)"]'; + } +} + +type TestThreadExports = { + explode(): Promise; +}; + +type ThreadMessageListener = (data: unknown) => void; + +const createDetachedThread = () => + new Thread({ send: () => undefined, listen: () => undefined }); + +const createExplodingThreadPair = () => { + const listeners: { + caller?: ThreadMessageListener; + responder?: ThreadMessageListener; + } = {}; + + const callingThread = new Thread( + { + send: (message) => + queueMicrotask(() => listeners.responder?.(structuredClone(message))), + listen: (listener) => { + listeners.caller = listener; + }, + }, + { serialization: createClonableErrorThreadSerialization() }, + ); + + new Thread, TestThreadExports>( + { + send: (message) => + queueMicrotask(() => listeners.caller?.(structuredClone(message))), + listen: (listener) => { + listeners.responder = listener; + }, + }, + { + exports: { + explode: async () => { + throw new FakeGeckoException(); + }, + }, + serialization: createClonableErrorThreadSerialization(), + }, + ); + + return callingThread; +}; + +describe('createClonableErrorThreadSerialization', () => { + it('should serialize an Error to a marked structured-cloneable payload', () => { + const serialization = createClonableErrorThreadSerialization(); + + const serialized = serialization.serialize( + new Error('boom'), + createDetachedThread(), + ); + + expect(serialized).toMatchObject({ + [FRONT_COMPONENT_THREAD_ERROR_MARKER]: { + name: 'Error', + message: 'boom', + }, + }); + expect(() => structuredClone(serialized)).not.toThrow(); + }); + + it('should serialize a foreign exception object to its stringified diagnostic', () => { + const serialization = createClonableErrorThreadSerialization(); + + const serialized = serialization.serialize( + new FakeGeckoException(), + createDetachedThread(), + ); + + expect(serialized).toEqual({ + [FRONT_COMPONENT_THREAD_ERROR_MARKER]: { + name: 'NS_ERROR_FAILURE', + message: + '[Exception... "Failure" nsresult: "0x80004005 (NS_ERROR_FAILURE)"]', + stack: '@blob:null/uuid:5:15', + code: undefined, + }, + }); + expect(() => structuredClone(serialized)).not.toThrow(); + }); + + it('should serialize errors nested inside regular payloads', () => { + const serialization = createClonableErrorThreadSerialization(); + + const serialized = serialization.serialize( + { results: [new Error('nested boom')] }, + createDetachedThread(), + ); + + expect(serialized).toMatchObject({ + results: [ + { + [FRONT_COMPONENT_THREAD_ERROR_MARKER]: { message: 'nested boom' }, + }, + ], + }); + }); + + it('should leave regular payloads untouched', () => { + const serialization = createClonableErrorThreadSerialization(); + + const payload = { + url: 'https://example.com', + headers: { authorization: 'Bearer token' }, + }; + + expect(serialization.serialize(payload, createDetachedThread())).toEqual( + payload, + ); + }); + + it('should deserialize a marked payload into an Error', () => { + const serialization = createClonableErrorThreadSerialization(); + + const deserialized = serialization.deserialize( + { + [FRONT_COMPONENT_THREAD_ERROR_MARKER]: { + name: 'NS_ERROR_FAILURE', + message: '[Exception...]', + stack: '@blob:null/uuid:5:15', + }, + }, + createDetachedThread(), + ); + + expect(deserialized).toBeInstanceOf(Error); + expect(deserialized).toMatchObject({ + name: 'NS_ERROR_FAILURE', + message: '[Exception...]', + stack: '@blob:null/uuid:5:15', + }); + }); + + it('should round-trip a CustomError with its code', () => { + const serialization = createClonableErrorThreadSerialization(); + const thread = createDetachedThread(); + + const roundTripped = serialization.deserialize( + structuredClone( + serialization.serialize( + new CustomError('fetch bridge unavailable', 'FETCH_BRIDGE'), + thread, + ), + ), + thread, + ); + + expect(roundTripped).toBeInstanceOf(CustomError); + expect(roundTripped).toMatchObject({ + message: 'fetch bridge unavailable', + code: 'FETCH_BRIDGE', + }); + }); + + it('should reject thread calls with the real error when the export throws an uncloneable exception', async () => { + const callingThread = createExplodingThreadPair(); + + await expect(callingThread.imports.explode()).rejects.toMatchObject({ + name: 'NS_ERROR_FAILURE', + message: + '[Exception... "Failure" nsresult: "0x80004005 (NS_ERROR_FAILURE)"]', + }); + }); +}); diff --git a/packages/twenty-front-component-renderer/src/utils/__tests__/extractClonableErrorPayload.test.ts b/packages/twenty-front-component-renderer/src/utils/__tests__/extractClonableErrorPayload.test.ts new file mode 100644 index 0000000000..4aecb13753 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/utils/__tests__/extractClonableErrorPayload.test.ts @@ -0,0 +1,61 @@ +import { FRONT_COMPONENT_THREAD_ERROR_MARKER } from '@/constants/FrontComponentThreadErrorMarker'; + +import { extractClonableErrorPayload } from '../extractClonableErrorPayload'; + +describe('extractClonableErrorPayload', () => { + it('should extract a marked payload', () => { + expect( + extractClonableErrorPayload({ + [FRONT_COMPONENT_THREAD_ERROR_MARKER]: { + name: 'NS_ERROR_FAILURE', + message: '[Exception...]', + stack: '@blob:null/uuid:5:15', + code: 'SOME_CODE', + }, + }), + ).toEqual({ + name: 'NS_ERROR_FAILURE', + message: '[Exception...]', + stack: '@blob:null/uuid:5:15', + code: 'SOME_CODE', + }); + }); + + it('should drop non-string stack and code fields', () => { + expect( + extractClonableErrorPayload({ + [FRONT_COMPONENT_THREAD_ERROR_MARKER]: { + name: 'Error', + message: 'boom', + stack: 42, + code: { nested: true }, + }, + }), + ).toEqual({ + name: 'Error', + message: 'boom', + stack: undefined, + code: undefined, + }); + }); + + it('should return null for unmarked objects', () => { + expect( + extractClonableErrorPayload({ name: 'Error', message: 'boom' }), + ).toBe(null); + }); + + it('should return null when the marked payload is malformed', () => { + expect( + extractClonableErrorPayload({ + [FRONT_COMPONENT_THREAD_ERROR_MARKER]: { message: 42 }, + }), + ).toBe(null); + + expect( + extractClonableErrorPayload({ + [FRONT_COMPONENT_THREAD_ERROR_MARKER]: 'boom', + }), + ).toBe(null); + }); +}); diff --git a/packages/twenty-front-component-renderer/src/utils/__tests__/isErrorLikeValue.test.ts b/packages/twenty-front-component-renderer/src/utils/__tests__/isErrorLikeValue.test.ts new file mode 100644 index 0000000000..41004fbeb7 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/utils/__tests__/isErrorLikeValue.test.ts @@ -0,0 +1,54 @@ +import { CustomError } from 'twenty-shared/utils'; + +import { isErrorLikeValue } from '../isErrorLikeValue'; + +class FakeGeckoException { + name = 'NS_ERROR_FAILURE'; + message = ''; + stack = '@blob:null/uuid:5:15'; + + toString(): string { + return '[Exception... "Failure" nsresult: "0x80004005 (NS_ERROR_FAILURE)"]'; + } +} + +describe('isErrorLikeValue', () => { + it('should match Error instances', () => { + expect(isErrorLikeValue(new Error('boom'))).toBe(true); + }); + + it('should match Error subclasses', () => { + expect(isErrorLikeValue(new CustomError('boom', 'BOOM_CODE'))).toBe(true); + }); + + it('should match DOMException instances', () => { + expect(isErrorLikeValue(new DOMException('denied', 'SecurityError'))).toBe( + true, + ); + }); + + it('should match foreign exception objects exposing name, message and stack strings', () => { + expect(isErrorLikeValue(new FakeGeckoException())).toBe(true); + }); + + it('should not match plain objects missing a stack', () => { + expect(isErrorLikeValue({ name: 'Error', message: 'boom' })).toBe(false); + }); + + it('should not match user data that happens to expose name and message', () => { + expect(isErrorLikeValue({ name: 'Acme', message: 'In stock' })).toBe(false); + }); + + it('should not match plain data objects', () => { + expect( + isErrorLikeValue({ url: 'https://example.com', method: 'GET' }), + ).toBe(false); + }); + + it('should not match primitives', () => { + expect(isErrorLikeValue('boom')).toBe(false); + expect(isErrorLikeValue(42)).toBe(false); + expect(isErrorLikeValue(null)).toBe(false); + expect(isErrorLikeValue(undefined)).toBe(false); + }); +}); diff --git a/packages/twenty-front-component-renderer/src/utils/__tests__/rehydrateClonableError.test.ts b/packages/twenty-front-component-renderer/src/utils/__tests__/rehydrateClonableError.test.ts new file mode 100644 index 0000000000..aae3d5f35e --- /dev/null +++ b/packages/twenty-front-component-renderer/src/utils/__tests__/rehydrateClonableError.test.ts @@ -0,0 +1,36 @@ +import { CustomError } from 'twenty-shared/utils'; + +import { rehydrateClonableError } from '../rehydrateClonableError'; + +describe('rehydrateClonableError', () => { + it('should rebuild an Error with name, message and stack', () => { + const error = rehydrateClonableError({ + name: 'NS_ERROR_FAILURE', + message: '[Exception...]', + stack: '@blob:null/uuid:5:15', + }); + + expect(error).toBeInstanceOf(Error); + expect(error.name).toBe('NS_ERROR_FAILURE'); + expect(error.message).toBe('[Exception...]'); + expect(error.stack).toBe('@blob:null/uuid:5:15'); + }); + + it('should rebuild a CustomError when a code is present', () => { + const error = rehydrateClonableError({ + name: 'CustomError', + message: 'fetch bridge unavailable', + code: 'FETCH_BRIDGE', + }); + + expect(error).toBeInstanceOf(CustomError); + expect(error.message).toBe('fetch bridge unavailable'); + expect(error).toMatchObject({ code: 'FETCH_BRIDGE' }); + }); + + it('should keep the generated stack when the payload has none', () => { + const error = rehydrateClonableError({ name: 'Error', message: 'boom' }); + + expect(error.stack).toEqual(expect.any(String)); + }); +}); diff --git a/packages/twenty-front-component-renderer/src/utils/buildClonableErrorPayload.ts b/packages/twenty-front-component-renderer/src/utils/buildClonableErrorPayload.ts new file mode 100644 index 0000000000..4acf1b8df9 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/utils/buildClonableErrorPayload.ts @@ -0,0 +1,33 @@ +import { isNonEmptyString, isString } from '@sniptt/guards'; + +import { type ClonableErrorPayload } from '@/types/ClonableErrorPayload'; +import { isErrorLikeValue } from '@/utils/isErrorLikeValue'; + +const FALLBACK_ERROR_NAME = 'Error'; + +const stringifyErrorValue = (value: unknown): string => { + try { + return String(value); + } catch { + return FALLBACK_ERROR_NAME; + } +}; + +export const buildClonableErrorPayload = ( + value: unknown, +): ClonableErrorPayload => { + if (!isErrorLikeValue(value)) { + return { name: FALLBACK_ERROR_NAME, message: stringifyErrorValue(value) }; + } + + const { code } = value as { code?: unknown }; + + return { + name: isNonEmptyString(value.name) ? value.name : FALLBACK_ERROR_NAME, + message: isNonEmptyString(value.message) + ? value.message + : stringifyErrorValue(value), + stack: isString(value.stack) ? value.stack : undefined, + code: isString(code) ? code : undefined, + }; +}; diff --git a/packages/twenty-front-component-renderer/src/utils/createClonableErrorThreadSerialization.ts b/packages/twenty-front-component-renderer/src/utils/createClonableErrorThreadSerialization.ts new file mode 100644 index 0000000000..fe31d61632 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/utils/createClonableErrorThreadSerialization.ts @@ -0,0 +1,25 @@ +import { ThreadSerializationStructuredClone } from '@quilted/threads'; +import { isDefined } from 'twenty-shared/utils'; + +import { FRONT_COMPONENT_THREAD_ERROR_MARKER } from '@/constants/FrontComponentThreadErrorMarker'; +import { buildClonableErrorPayload } from '@/utils/buildClonableErrorPayload'; +import { extractClonableErrorPayload } from '@/utils/extractClonableErrorPayload'; +import { isErrorLikeValue } from '@/utils/isErrorLikeValue'; +import { rehydrateClonableError } from '@/utils/rehydrateClonableError'; + +export const createClonableErrorThreadSerialization = + (): ThreadSerializationStructuredClone => + new ThreadSerializationStructuredClone({ + serialize: (value) => + isErrorLikeValue(value) + ? { + [FRONT_COMPONENT_THREAD_ERROR_MARKER]: + buildClonableErrorPayload(value), + } + : undefined, + deserialize: (value) => { + const payload = extractClonableErrorPayload(value); + + return isDefined(payload) ? rehydrateClonableError(payload) : undefined; + }, + }); diff --git a/packages/twenty-front-component-renderer/src/utils/extractClonableErrorPayload.ts b/packages/twenty-front-component-renderer/src/utils/extractClonableErrorPayload.ts new file mode 100644 index 0000000000..07095c162d --- /dev/null +++ b/packages/twenty-front-component-renderer/src/utils/extractClonableErrorPayload.ts @@ -0,0 +1,36 @@ +import { isObject, isString } from '@sniptt/guards'; + +import { FRONT_COMPONENT_THREAD_ERROR_MARKER } from '@/constants/FrontComponentThreadErrorMarker'; +import { type ClonableErrorPayload } from '@/types/ClonableErrorPayload'; + +export const extractClonableErrorPayload = ( + value: object, +): ClonableErrorPayload | null => { + if (!(FRONT_COMPONENT_THREAD_ERROR_MARKER in value)) { + return null; + } + + const payload = value[FRONT_COMPONENT_THREAD_ERROR_MARKER]; + + if (!isObject(payload)) { + return null; + } + + const { name, message, stack, code } = payload as { + name?: unknown; + message?: unknown; + stack?: unknown; + code?: unknown; + }; + + if (!isString(name) || !isString(message)) { + return null; + } + + return { + name, + message, + stack: isString(stack) ? stack : undefined, + code: isString(code) ? code : undefined, + }; +}; diff --git a/packages/twenty-front-component-renderer/src/utils/isErrorLikeValue.ts b/packages/twenty-front-component-renderer/src/utils/isErrorLikeValue.ts new file mode 100644 index 0000000000..4d78852adf --- /dev/null +++ b/packages/twenty-front-component-renderer/src/utils/isErrorLikeValue.ts @@ -0,0 +1,25 @@ +import { isObject, isString } from '@sniptt/guards'; + +import { type ErrorLikeValue } from '@/types/ErrorLikeValue'; + +export const isErrorLikeValue = (value: unknown): value is ErrorLikeValue => { + if (value instanceof Error) { + return true; + } + + if (typeof DOMException !== 'undefined' && value instanceof DOMException) { + return true; + } + + if (!isObject(value)) { + return false; + } + + const { name, message, stack } = value as { + name?: unknown; + message?: unknown; + stack?: unknown; + }; + + return isString(name) && isString(message) && isString(stack); +}; diff --git a/packages/twenty-front-component-renderer/src/utils/rehydrateClonableError.ts b/packages/twenty-front-component-renderer/src/utils/rehydrateClonableError.ts new file mode 100644 index 0000000000..46bf0ba155 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/utils/rehydrateClonableError.ts @@ -0,0 +1,20 @@ +import { isNonEmptyString } from '@sniptt/guards'; +import { CustomError, isDefined } from 'twenty-shared/utils'; + +import { type ClonableErrorPayload } from '@/types/ClonableErrorPayload'; + +export const rehydrateClonableError = ( + payload: ClonableErrorPayload, +): Error => { + const error = isNonEmptyString(payload.code) + ? new CustomError(payload.message, payload.code) + : new Error(payload.message); + + error.name = payload.name; + + if (isDefined(payload.stack)) { + error.stack = payload.stack; + } + + return error; +};