diff --git a/apps/frontend/package.json b/apps/frontend/package.json index c3ca8b38..5e93cc45 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -57,6 +57,7 @@ "ace-builds": "^1.36.2", "ansi-to-html": "^0.7.2", "dayjs": "^1.11.7", + "devalue": "^5.6.2", "dompurify": "^3.1.7", "floating-vue": "^5.2.2", "fuse.js": "^6.6.2", diff --git a/apps/frontend/src/plugins/payload-debugger.ts b/apps/frontend/src/plugins/payload-debugger.ts index dd579008..c8ed26fc 100644 --- a/apps/frontend/src/plugins/payload-debugger.ts +++ b/apps/frontend/src/plugins/payload-debugger.ts @@ -1,37 +1,99 @@ +import { stringify } from 'devalue' + function findNonPOJOs( obj: unknown, path: string, found: Array<{ path: string; type: string }> = [], + seen = new WeakSet(), ): Array<{ path: string; type: string }> { if (obj === null || typeof obj !== 'object') return found - const proto = Object.getPrototypeOf(obj) - if (proto !== Object.prototype && proto !== null && !Array.isArray(obj)) { - found.push({ path, type: obj.constructor?.name ?? 'Unknown' }) + // Prevent circular reference infinite loops + if (seen.has(obj)) return found + seen.add(obj) + + if (typeof obj === 'function') { + found.push({ path, type: 'Function' }) + return found } - for (const [key, value] of Object.entries(obj)) { - findNonPOJOs(value, `${path}.${key}`, found) + const proto = Object.getPrototypeOf(obj) + const constructorName = obj.constructor?.name ?? 'Unknown' + + // Check for non-POJOs (not Object, Array, or null prototype) + if (proto !== Object.prototype && proto !== null && !Array.isArray(obj)) { + found.push({ path, type: constructorName }) + } + + // Check for Vue internals that shouldn't be serialized + if ('__v_isRef' in obj || '__v_isReactive' in obj || '_rawValue' in obj) { + found.push({ path, type: `Vue:${constructorName}` }) + } + + // Check for IntlMessageFormat specifically + if ('_ast' in obj || constructorName === 'IntlMessageFormat') { + found.push({ path, type: 'IntlMessageFormat' }) + } + + try { + for (const [key, value] of Object.entries(obj)) { + findNonPOJOs(value, `${path}.${key}`, found, seen) + } + } catch { + found.push({ path, type: `NonIterable:${constructorName}` }) } return found } +function checkPayload(payload: unknown, hookName: string): void { + try { + stringify(payload) + } catch (e) { + const nonPOJOs = findNonPOJOs(payload, 'payload') + console.error(`[payload-debugger] [${hookName}] Devalue serialization failed:`, e) + if (nonPOJOs.length > 0) { + console.error(`[payload-debugger] [${hookName}] Non-POJO objects found:`) + for (const { path, type } of nonPOJOs.slice(0, 20)) { + console.error(` - ${path}: ${type}`) + } + if (nonPOJOs.length > 20) { + console.error(` ... and ${nonPOJOs.length - 20} more`) + } + } else { + console.error( + `[payload-debugger] [${hookName}] No non-POJOs found by walker - issue may be circular refs or special values`, + ) + } + } +} + export default defineNuxtPlugin((nuxtApp) => { - if (!import.meta.dev || !import.meta.server) return + if (!import.meta.server) return nuxtApp.hooks.hook('app:rendered', () => { - try { - JSON.stringify(nuxtApp.payload) - } catch (e) { - console.error('[payload-debugger] Payload serialization would fail:', e) - const nonPOJOs = findNonPOJOs(nuxtApp.payload, 'payload') - if (nonPOJOs.length > 0) { - console.error('[payload-debugger] Non-POJO objects found in payload:') - for (const { path, type } of nonPOJOs) { - console.error(` - ${path}: ${type}`) - } - } - } + checkPayload(nuxtApp.payload, 'app:rendered') }) + + if (nuxtApp.payload.data && typeof nuxtApp.payload.data === 'object') { + const originalData = nuxtApp.payload.data + const allowedConstructors = new Set(['Object', 'Array', 'String', 'Number', 'Boolean', 'Date']) + + nuxtApp.payload.data = new Proxy(originalData, { + set(target, prop, value) { + if (value !== null && typeof value === 'object') { + const constructorName = value.constructor?.name + if (constructorName && !allowedConstructors.has(constructorName)) { + console.error( + `[payload-debugger] [proxy] Non-POJO assigned to payload.data.${String(prop)}:`, + constructorName, + ) + console.error(new Error('Stack trace').stack) + } + } + ;(target as Record)[prop] = value + return true + }, + }) + } }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e3f7a90b..fac6bf47 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -293,6 +293,9 @@ importers: dayjs: specifier: ^1.11.7 version: 1.11.19 + devalue: + specifier: ^5.6.2 + version: 5.6.2 dompurify: specifier: ^3.1.7 version: 3.3.1 @@ -5268,6 +5271,9 @@ packages: devalue@5.6.1: resolution: {integrity: sha512-jDwizj+IlEZBunHcOuuFVBnIMPAEHvTsJj0BcIp94xYguLRVBcXO853px/MyIJvbVzWdsGvrRweIUWJw8hBP7A==} + devalue@5.6.2: + resolution: {integrity: sha512-nPRkjWzzDQlsejL1WVifk5rvcFi/y1onBRxjaFMjZeR9mFpqu2gmAZ9xUB9/IEanEP/vBtGeGganC/GO1fmufg==} + devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} @@ -11150,7 +11156,7 @@ snapshots: consola: 3.4.2 defu: 6.1.4 destr: 2.0.5 - devalue: 5.6.1 + devalue: 5.6.2 errx: 0.1.0 escape-string-regexp: 5.0.0 exsolve: 1.0.8 @@ -14359,6 +14365,8 @@ snapshots: devalue@5.6.1: {} + devalue@5.6.2: {} + devlop@1.1.0: dependencies: dequal: 2.0.3 @@ -16809,7 +16817,7 @@ snapshots: cookie-es: 2.0.0 defu: 6.1.4 destr: 2.0.5 - devalue: 5.6.1 + devalue: 5.6.2 errx: 0.1.0 escape-string-regexp: 5.0.0 exsolve: 1.0.8