From f5259243b81ef7065ca49435f311c1cd700740e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Burzy=C5=84ski?= Date: Thu, 27 Mar 2025 15:57:42 +0100 Subject: [PATCH] Make injected script more resilient to build shenanigans (#81) --- app/lib/replay/Recording.ts | 962 +++++++++++++------------ app/lib/replay/ReplayProtocolClient.ts | 8 +- app/lib/replay/injectable.ts | 106 +++ eslint.config.mjs | 32 +- 4 files changed, 611 insertions(+), 497 deletions(-) create mode 100644 app/lib/replay/injectable.ts diff --git a/app/lib/replay/Recording.ts b/app/lib/replay/Recording.ts index cb4b21ee..df9d9fb8 100644 --- a/app/lib/replay/Recording.ts +++ b/app/lib/replay/Recording.ts @@ -1,5 +1,6 @@ // Manage state around recording Preview behavior for generating a Replay recording. +import { createInjectableFunction } from './injectable'; import { assert, stringToBase64, uint8ArrayToBase64 } from './ReplayProtocolClient'; import type { IndexedDBAccess, @@ -87,498 +88,503 @@ export async function getMouseData(iframe: HTMLIFrameElement, position: { x: num } // Add handlers to the current iframe's window. -function addRecordingMessageHandler(_messageHandlerId: string) { - const simulationData: SimulationData = []; - let numSimulationPacketsSent = 0; +const addRecordingMessageHandler = createInjectableFunction( + { + assert, + stringToBase64, + uint8ArrayToBase64, + }, + (deps) => { + const assert: typeof deps.assert = deps.assert; + const { stringToBase64 } = deps; - function pushSimulationData(packet: SimulationPacket) { - packet.time = new Date().toISOString(); - simulationData.push(packet); - } + const simulationData: SimulationData = []; + let numSimulationPacketsSent = 0; - const startTime = Date.now(); - - pushSimulationData({ - kind: 'viewport', - size: { width: window.innerWidth, height: window.innerHeight }, - }); - pushSimulationData({ - kind: 'locationHref', - href: window.location.href, - }); - pushSimulationData({ - kind: 'documentURL', - url: window.location.href, - }); - - interface RequestInfo { - url: string; - requestBody: string; - } - - function clamp(value: number, min: number, max: number) { - return Math.min(Math.max(value, min), max); - } - - function addNetworkResource(resource: NetworkResource) { - pushSimulationData({ - kind: 'resource', - resource, - }); - } - - function addTextResource(info: RequestInfo, text: string, responseHeaders: Record) { - const url = new URL(info.url, window.location.href).href; - addNetworkResource({ - url, - requestBodyBase64: stringToBase64(info.requestBody), - responseBodyBase64: stringToBase64(text), - responseStatus: 200, - responseHeaders, - }); - } - - function addInteraction(interaction: UserInteraction) { - pushSimulationData({ - kind: 'interaction', - interaction, - }); - } - - function addIndexedDBAccess(access: IndexedDBAccess) { - pushSimulationData({ - kind: 'indexedDB', - access, - }); - } - - function addLocalStorageAccess(access: LocalStorageAccess) { - pushSimulationData({ - kind: 'localStorage', - access, - }); - } - - async function getSimulationData(): Promise { - //console.log("GetSimulationData", simulationData.length, numSimulationPacketsSent); - const data = simulationData.slice(numSimulationPacketsSent); - numSimulationPacketsSent = simulationData.length; - - return data; - } - - async function handleRequest({ - request, - payload, - }: Request): Promise { - switch (request) { - case 'recording-data': { - const data = await getSimulationData(); - - const encoder = new TextEncoder(); - const serializedData = encoder.encode(JSON.stringify(data)); - const buffer = serializedData.buffer; - - return buffer; - } - case 'mouse-data': { - const { x, y } = payload; - const element = document.elementFromPoint(x, y); - assert(element); - - const selector = computeSelector(element); - const rect = element.getBoundingClientRect(); - - return { - selector, - width: rect.width, - height: rect.height, - x: x - rect.x, - y: y - rect.y, - }; - } - } - throw new Error(`Unknown request type: ${request}`); - } - - window.addEventListener('message', async (event) => { - if (event.data?.source !== '@@replay-nut') { - return; + function pushSimulationData(packet: SimulationPacket) { + packet.time = new Date().toISOString(); + simulationData.push(packet); } - const response = await handleRequest(event.data.request); - window.parent.postMessage({ id: event.data.id, response, source: '@@replay-nut' }, '*'); - }); + const startTime = Date.now(); - // Evaluated function to find the selector and associated data. - function getMouseEventTargetData(event: MouseEvent) { - assert(event.target); + pushSimulationData({ + kind: 'viewport', + size: { width: window.innerWidth, height: window.innerHeight }, + }); + pushSimulationData({ + kind: 'locationHref', + href: window.location.href, + }); + pushSimulationData({ + kind: 'documentURL', + url: window.location.href, + }); - const target = event.target as Element; - const selector = computeSelector(target); - const rect = target.getBoundingClientRect(); - - return { - selector, - width: rect.width, - height: rect.height, - - /* - * at times `event.clientX` and `event.clientY` can be slighly off in relation to the element's position - * it's possible that this position might lie outside the element's bounds - * the difference likely comes from a subpixel rounding or hit target calculation in the browser - * it's possible that we should account for `event.width` and `event.height` here but clamping the values to the bounds of the element should be good enough - */ - x: clamp(event.clientX - rect.x, 0, rect.width), - y: clamp(event.clientY - rect.y, 0, rect.height), - }; - } - - function getKeyboardEventTargetData(event: KeyboardEvent) { - assert(event.target); - - const target = event.target as Element; - const selector = computeSelector(target); - - return { - selector, - key: event.key, - }; - } - - function computeSelector(target: Element): string { - // Build a unique selector by walking up the DOM tree - const path: string[] = []; - let current: Element | null = target; - - while (current) { - // If element has an ID, use it as it's the most specific - if (current.id) { - path.unshift(`#${current.id}`); - break; - } - - // Get the element's tag name - let selector = current.tagName.toLowerCase(); - - // Add nth-child if there are siblings - const parent = current.parentElement; - - if (parent) { - const siblings = Array.from(parent.children); - const index = siblings.indexOf(current) + 1; - - if (siblings.filter((el) => el.tagName === current!.tagName).length > 1) { - selector += `:nth-child(${index})`; - } - } - - path.unshift(selector); - current = current.parentElement; + interface RequestInfo { + url: string; + requestBody: string; } - return path.join(' > '); - } + function clamp(value: number, min: number, max: number) { + return Math.min(Math.max(value, min), max); + } - window.addEventListener( - 'click', - (event) => { - if (event.target) { - addInteraction({ - kind: 'click', - time: Date.now() - startTime, - ...getMouseEventTargetData(event), - ...(event.button && { button: event.button }), - clickCount: event.detail, - }); - } - }, - { capture: true, passive: true }, - ); - - window.addEventListener( - 'pointermove', - (event) => { - if (event.target) { - addInteraction({ - kind: 'pointermove', - time: Date.now() - startTime, - ...getMouseEventTargetData(event), - }); - } - }, - { capture: true, passive: true }, - ); - - window.addEventListener( - 'keydown', - (event) => { - if (event.key) { - addInteraction({ - kind: 'keydown', - time: Date.now() - startTime, - ...getKeyboardEventTargetData(event), - }); - } - }, - { capture: true, passive: true }, - ); - - window.addEventListener( - 'scroll', - (event) => { - const target = event.target == window.document ? undefined : (event.target as Element); - const selector = target ? computeSelector(target) : undefined; - - addInteraction({ - kind: 'scroll', - time: Date.now() - startTime, - selector, - windowScrollX: window.scrollX, - windowScrollY: window.scrollY, - targetScrollX: target?.scrollLeft, - targetScrollY: target?.scrollTop, + function addNetworkResource(resource: NetworkResource) { + pushSimulationData({ + kind: 'resource', + resource, }); - }, - { capture: true, passive: true }, - ); - - function onInterceptedOperation(_name: string) { - //console.log(`InterceptedOperation ${name}`); - } - - function interceptProperty(obj: object, prop: string, interceptor: (basevalue: any) => any) { - const descriptor = Object.getOwnPropertyDescriptor(obj, prop); - assert(descriptor?.get, 'Property must have a getter'); - - let interceptValue: any; - Object.defineProperty(obj, prop, { - ...descriptor, - get() { - onInterceptedOperation(`Getter:${prop}`); - - if (!interceptValue) { - const baseValue = (descriptor?.get as any).call(obj); - interceptValue = interceptor(baseValue); - } - - return interceptValue; - }, - }); - } - - const idbFactoryMethods = { - _name: 'IDBFactory', - open: (v: any) => createFunctionProxy(v, 'open'), - }; - - const idbOpenDBRequestMethods = { - _name: 'IDBOpenDBRequest', - result: createProxy, - }; - - const idbDatabaseMethods = { - _name: 'IDBDatabase', - transaction: (v: any) => createFunctionProxy(v, 'transaction'), - }; - - const idbTransactionMethods = { - _name: 'IDBTransaction', - objectStore: (v: any) => createFunctionProxy(v, 'objectStore'), - }; - - function pushIndexedDBAccess(request: IDBRequest, kind: IndexedDBAccess['kind'], key: any, item: any) { - addIndexedDBAccess({ - kind, - key, - item, - storeName: (request.source as any).name, - databaseName: (request.transaction as any).db.name, - databaseVersion: (request.transaction as any).db.version, - }); - } - - // Map "get" requests to their keys. - const getRequestKeys: Map = new Map(); - - const idbObjectStoreMethods = { - _name: 'IDBObjectStore', - get: (v: any) => - createFunctionProxy(v, 'get', (request, key) => { - // Wait to add the request until the value is known. - getRequestKeys.set(request, key); - return createProxy(request); - }), - put: (v: any) => - createFunctionProxy(v, 'put', (request, item, key) => { - pushIndexedDBAccess(request, 'put', key, item); - return createProxy(request); - }), - add: (v: any) => - createFunctionProxy(v, 'add', (request, item, key) => { - pushIndexedDBAccess(request, 'add', key, item); - return createProxy(request); - }), - }; - - const idbRequestMethods = { - _name: 'IDBRequest', - result: (value: any, target: any) => { - const key = getRequestKeys.get(target); - - if (key) { - pushIndexedDBAccess(target, 'get', key, value); - } - - return value; - }, - }; - - function pushLocalStorageAccess(kind: LocalStorageAccess['kind'], key: string, value?: string) { - addLocalStorageAccess({ kind, key, value }); - } - - const storageMethods = { - _name: 'Storage', - getItem: (v: any) => - createFunctionProxy(v, 'getItem', (value: string, key: string) => { - pushLocalStorageAccess('get', key, value); - return value; - }), - setItem: (v: any) => - createFunctionProxy(v, 'setItem', (_rv: undefined, key: string) => { - pushLocalStorageAccess('set', key); - }), - }; - - // Map Response to the info associated with the original request (before redirects). - const responseToRequestInfo = new WeakMap(); - - function convertHeaders(headers: Headers) { - const result: Record = {}; - headers.forEach((value, key) => { - result[key] = value; - }); - - return result; - } - - const responseMethods = { - _name: 'Response', - json: (v: any, response: Response) => - createFunctionProxy(v, 'json', async (promise: Promise) => { - const json = await promise; - const requestInfo = responseToRequestInfo.get(response); - - if (requestInfo) { - addTextResource(requestInfo, JSON.stringify(json), convertHeaders(response.headers)); - } - - return json; - }), - text: (v: any, response: Response) => - createFunctionProxy(v, 'text', async (promise: Promise) => { - const text = await promise; - const requestInfo = responseToRequestInfo.get(response); - - if (requestInfo) { - addTextResource(requestInfo, text, convertHeaders(response.headers)); - } - - return text; - }), - }; - - function createProxy(obj: any) { - let methods; - - if (obj instanceof IDBFactory) { - methods = idbFactoryMethods; - } else if (obj instanceof IDBOpenDBRequest) { - methods = idbOpenDBRequestMethods; - } else if (obj instanceof IDBDatabase) { - methods = idbDatabaseMethods; - } else if (obj instanceof IDBTransaction) { - methods = idbTransactionMethods; - } else if (obj instanceof IDBObjectStore) { - methods = idbObjectStoreMethods; - } else if (obj instanceof IDBRequest) { - methods = idbRequestMethods; - } else if (obj instanceof Storage) { - methods = storageMethods; - } else if (obj instanceof Response) { - methods = responseMethods; } - assert(methods, 'Unknown object for createProxy'); - - const name = methods._name; - - return new Proxy(obj, { - get(target, prop) { - onInterceptedOperation(`ProxyGetter:${name}.${String(prop)}`); - - let value = target[prop]; - - if (typeof value === 'function') { - value = value.bind(target); - } - - if (methods[prop]) { - value = methods[prop](value, target); - } - - return value; - }, - - set(target, prop, value) { - onInterceptedOperation(`ProxySetter:${name}.${String(prop)}`); - target[prop] = value; - - return true; - }, - }); - } - - function createFunctionProxy(fn: any, name: string, handler?: (v: any, ...args: any[]) => any) { - return (...args: any[]) => { - onInterceptedOperation(`FunctionCall:${name}`); - - const v = fn(...args); - - return handler ? handler(v, ...args) : createProxy(v); - }; - } - - interceptProperty(window, 'indexedDB', createProxy); - interceptProperty(window, 'localStorage', createProxy); - - const baseFetch = window.fetch; - - window.fetch = async (info, options) => { - const url = info instanceof Request ? info.url : info.toString(); - const requestBody = typeof options?.body == 'string' ? options.body : ''; - const requestInfo: RequestInfo = { url, requestBody }; - - try { - const rv = await baseFetch(info, options); - responseToRequestInfo.set(rv, requestInfo); - - return createProxy(rv); - } catch (error) { + function addTextResource(info: RequestInfo, text: string, responseHeaders: Record) { + const url = new URL(info.url, window.location.href).href; addNetworkResource({ url, - requestBodyBase64: stringToBase64(requestBody), - error: String(error), + requestBodyBase64: stringToBase64(info.requestBody), + responseBodyBase64: stringToBase64(text), + responseStatus: 200, + responseHeaders, }); - throw error; } - }; -} -export const recordingMessageHandlerScript = ` - ${assert} - ${stringToBase64} - ${uint8ArrayToBase64} - (${addRecordingMessageHandler})() -`; + function addInteraction(interaction: UserInteraction) { + pushSimulationData({ + kind: 'interaction', + interaction, + }); + } + + function addIndexedDBAccess(access: IndexedDBAccess) { + pushSimulationData({ + kind: 'indexedDB', + access, + }); + } + + function addLocalStorageAccess(access: LocalStorageAccess) { + pushSimulationData({ + kind: 'localStorage', + access, + }); + } + + async function getSimulationData(): Promise { + //console.log("GetSimulationData", simulationData.length, numSimulationPacketsSent); + const data = simulationData.slice(numSimulationPacketsSent); + numSimulationPacketsSent = simulationData.length; + + return data; + } + + async function handleRequest({ + request, + payload, + }: Request): Promise { + switch (request) { + case 'recording-data': { + const data = await getSimulationData(); + + const encoder = new TextEncoder(); + const serializedData = encoder.encode(JSON.stringify(data)); + const buffer = serializedData.buffer; + + return buffer; + } + case 'mouse-data': { + const { x, y } = payload; + const element = document.elementFromPoint(x, y); + assert(element); + + const selector = computeSelector(element); + const rect = element.getBoundingClientRect(); + + return { + selector, + width: rect.width, + height: rect.height, + x: x - rect.x, + y: y - rect.y, + }; + } + } + throw new Error(`Unknown request type: ${request}`); + } + + window.addEventListener('message', async (event) => { + if (event.data?.source !== '@@replay-nut') { + return; + } + + const response = await handleRequest(event.data.request); + window.parent.postMessage({ id: event.data.id, response, source: '@@replay-nut' }, '*'); + }); + + // Evaluated function to find the selector and associated data. + function getMouseEventTargetData(event: MouseEvent) { + assert(event.target); + + const target = event.target as Element; + const selector = computeSelector(target); + const rect = target.getBoundingClientRect(); + + return { + selector, + width: rect.width, + height: rect.height, + + /* + * at times `event.clientX` and `event.clientY` can be slighly off in relation to the element's position + * it's possible that this position might lie outside the element's bounds + * the difference likely comes from a subpixel rounding or hit target calculation in the browser + * it's possible that we should account for `event.width` and `event.height` here but clamping the values to the bounds of the element should be good enough + */ + x: clamp(event.clientX - rect.x, 0, rect.width), + y: clamp(event.clientY - rect.y, 0, rect.height), + }; + } + + function getKeyboardEventTargetData(event: KeyboardEvent) { + assert(event.target); + + const target = event.target as Element; + const selector = computeSelector(target); + + return { + selector, + key: event.key, + }; + } + + function computeSelector(target: Element): string { + // Build a unique selector by walking up the DOM tree + const path: string[] = []; + let current: Element | null = target; + + while (current) { + // If element has an ID, use it as it's the most specific + if (current.id) { + path.unshift(`#${current.id}`); + break; + } + + // Get the element's tag name + let selector = current.tagName.toLowerCase(); + + // Add nth-child if there are siblings + const parent = current.parentElement; + + if (parent) { + const siblings = Array.from(parent.children); + const index = siblings.indexOf(current) + 1; + + if (siblings.filter((el) => el.tagName === current!.tagName).length > 1) { + selector += `:nth-child(${index})`; + } + } + + path.unshift(selector); + current = current.parentElement; + } + + return path.join(' > '); + } + + window.addEventListener( + 'click', + (event) => { + if (event.target) { + addInteraction({ + kind: 'click', + time: Date.now() - startTime, + ...getMouseEventTargetData(event), + ...(event.button && { button: event.button }), + clickCount: event.detail, + }); + } + }, + { capture: true, passive: true }, + ); + + window.addEventListener( + 'pointermove', + (event) => { + if (event.target) { + addInteraction({ + kind: 'pointermove', + time: Date.now() - startTime, + ...getMouseEventTargetData(event), + }); + } + }, + { capture: true, passive: true }, + ); + + window.addEventListener( + 'keydown', + (event) => { + if (event.key) { + addInteraction({ + kind: 'keydown', + time: Date.now() - startTime, + ...getKeyboardEventTargetData(event), + }); + } + }, + { capture: true, passive: true }, + ); + + window.addEventListener( + 'scroll', + (event) => { + const target = event.target == window.document ? undefined : (event.target as Element); + const selector = target ? computeSelector(target) : undefined; + + addInteraction({ + kind: 'scroll', + time: Date.now() - startTime, + selector, + windowScrollX: window.scrollX, + windowScrollY: window.scrollY, + targetScrollX: target?.scrollLeft, + targetScrollY: target?.scrollTop, + }); + }, + { capture: true, passive: true }, + ); + + function onInterceptedOperation(_name: string) { + //console.log(`InterceptedOperation ${name}`); + } + + function interceptProperty(obj: object, prop: string, interceptor: (basevalue: any) => any) { + const descriptor = Object.getOwnPropertyDescriptor(obj, prop); + assert(descriptor?.get, 'Property must have a getter'); + + let interceptValue: any; + Object.defineProperty(obj, prop, { + ...descriptor, + get() { + onInterceptedOperation(`Getter:${prop}`); + + if (!interceptValue) { + const baseValue = (descriptor?.get as any).call(obj); + interceptValue = interceptor(baseValue); + } + + return interceptValue; + }, + }); + } + + const idbFactoryMethods = { + _name: 'IDBFactory', + open: (v: any) => createFunctionProxy(v, 'open'), + }; + + const idbOpenDBRequestMethods = { + _name: 'IDBOpenDBRequest', + result: createProxy, + }; + + const idbDatabaseMethods = { + _name: 'IDBDatabase', + transaction: (v: any) => createFunctionProxy(v, 'transaction'), + }; + + const idbTransactionMethods = { + _name: 'IDBTransaction', + objectStore: (v: any) => createFunctionProxy(v, 'objectStore'), + }; + + function pushIndexedDBAccess(request: IDBRequest, kind: IndexedDBAccess['kind'], key: any, item: any) { + addIndexedDBAccess({ + kind, + key, + item, + storeName: (request.source as any).name, + databaseName: (request.transaction as any).db.name, + databaseVersion: (request.transaction as any).db.version, + }); + } + + // Map "get" requests to their keys. + const getRequestKeys: Map = new Map(); + + const idbObjectStoreMethods = { + _name: 'IDBObjectStore', + get: (v: any) => + createFunctionProxy(v, 'get', (request, key) => { + // Wait to add the request until the value is known. + getRequestKeys.set(request, key); + return createProxy(request); + }), + put: (v: any) => + createFunctionProxy(v, 'put', (request, item, key) => { + pushIndexedDBAccess(request, 'put', key, item); + return createProxy(request); + }), + add: (v: any) => + createFunctionProxy(v, 'add', (request, item, key) => { + pushIndexedDBAccess(request, 'add', key, item); + return createProxy(request); + }), + }; + + const idbRequestMethods = { + _name: 'IDBRequest', + result: (value: any, target: any) => { + const key = getRequestKeys.get(target); + + if (key) { + pushIndexedDBAccess(target, 'get', key, value); + } + + return value; + }, + }; + + function pushLocalStorageAccess(kind: LocalStorageAccess['kind'], key: string, value?: string) { + addLocalStorageAccess({ kind, key, value }); + } + + const storageMethods = { + _name: 'Storage', + getItem: (v: any) => + createFunctionProxy(v, 'getItem', (value: string, key: string) => { + pushLocalStorageAccess('get', key, value); + return value; + }), + setItem: (v: any) => + createFunctionProxy(v, 'setItem', (_rv: undefined, key: string) => { + pushLocalStorageAccess('set', key); + }), + }; + + // Map Response to the info associated with the original request (before redirects). + const responseToRequestInfo = new WeakMap(); + + function convertHeaders(headers: Headers) { + const result: Record = {}; + headers.forEach((value, key) => { + result[key] = value; + }); + + return result; + } + + const responseMethods = { + _name: 'Response', + json: (v: any, response: Response) => + createFunctionProxy(v, 'json', async (promise: Promise) => { + const json = await promise; + const requestInfo = responseToRequestInfo.get(response); + + if (requestInfo) { + addTextResource(requestInfo, JSON.stringify(json), convertHeaders(response.headers)); + } + + return json; + }), + text: (v: any, response: Response) => + createFunctionProxy(v, 'text', async (promise: Promise) => { + const text = await promise; + const requestInfo = responseToRequestInfo.get(response); + + if (requestInfo) { + addTextResource(requestInfo, text, convertHeaders(response.headers)); + } + + return text; + }), + }; + + function createProxy(obj: any) { + let methods; + + if (obj instanceof IDBFactory) { + methods = idbFactoryMethods; + } else if (obj instanceof IDBOpenDBRequest) { + methods = idbOpenDBRequestMethods; + } else if (obj instanceof IDBDatabase) { + methods = idbDatabaseMethods; + } else if (obj instanceof IDBTransaction) { + methods = idbTransactionMethods; + } else if (obj instanceof IDBObjectStore) { + methods = idbObjectStoreMethods; + } else if (obj instanceof IDBRequest) { + methods = idbRequestMethods; + } else if (obj instanceof Storage) { + methods = storageMethods; + } else if (obj instanceof Response) { + methods = responseMethods; + } + + assert(methods, 'Unknown object for createProxy'); + + const name = methods._name; + + return new Proxy(obj, { + get(target, prop) { + onInterceptedOperation(`ProxyGetter:${name}.${String(prop)}`); + + let value = target[prop]; + + if (typeof value === 'function') { + value = value.bind(target); + } + + if (methods[prop]) { + value = methods[prop](value, target); + } + + return value; + }, + + set(target, prop, value) { + onInterceptedOperation(`ProxySetter:${name}.${String(prop)}`); + target[prop] = value; + + return true; + }, + }); + } + + function createFunctionProxy(fn: any, name: string, handler?: (v: any, ...args: any[]) => any) { + return (...args: any[]) => { + onInterceptedOperation(`FunctionCall:${name}`); + + const v = fn(...args); + + return handler ? handler(v, ...args) : createProxy(v); + }; + } + + interceptProperty(window, 'indexedDB', createProxy); + interceptProperty(window, 'localStorage', createProxy); + + const baseFetch = window.fetch; + + window.fetch = async (info, options) => { + const url = info instanceof Request ? info.url : info.toString(); + const requestBody = typeof options?.body == 'string' ? options.body : ''; + const requestInfo: RequestInfo = { url, requestBody }; + + try { + const rv = await baseFetch(info, options); + responseToRequestInfo.set(rv, requestInfo); + + return createProxy(rv); + } catch (error) { + addNetworkResource({ + url, + requestBodyBase64: stringToBase64(requestBody), + error: String(error), + }); + throw error; + } + }; + }, +); + +export const recordingMessageHandlerScript = addRecordingMessageHandler.asCallString(); diff --git a/app/lib/replay/ReplayProtocolClient.ts b/app/lib/replay/ReplayProtocolClient.ts index 68b73c4a..44f09ebf 100644 --- a/app/lib/replay/ReplayProtocolClient.ts +++ b/app/lib/replay/ReplayProtocolClient.ts @@ -1,3 +1,5 @@ +import { createInjectableFunction } from './injectable'; + const replayWsServer = 'wss://dispatch.replay.io'; export function assert(condition: any, message: string = 'Assertion failed!'): asserts condition { @@ -33,7 +35,9 @@ export function uint8ArrayToBase64(data: Uint8Array) { return btoa(str); } -export function stringToBase64(inputString: string) { +export const stringToBase64 = createInjectableFunction({ uint8ArrayToBase64 }, (deps, inputString: string) => { + const { uint8ArrayToBase64 } = deps; + if (typeof inputString !== 'string') { throw new TypeError('Input must be a string.'); } @@ -42,7 +46,7 @@ export function stringToBase64(inputString: string) { const data = encoder.encode(inputString); return uint8ArrayToBase64(data); -} +}); function logDebug(msg: string, _tags: Record = {}) { //console.log(msg, JSON.stringify(tags)); diff --git a/app/lib/replay/injectable.ts b/app/lib/replay/injectable.ts new file mode 100644 index 00000000..b8a7fcc1 --- /dev/null +++ b/app/lib/replay/injectable.ts @@ -0,0 +1,106 @@ +import { assert } from './ReplayProtocolClient'; + +type UnknownFunction = (...args: never) => unknown; + +type DropDependencies = F extends (dependencies: any, ...args: infer A) => infer R + ? (...args: A) => R + : never; + +type CallableDependencies> = { + [K in keyof Dependencies]: 'fn' extends keyof Dependencies[K] + ? Dependencies[K]['fn'] extends UnknownFunction + ? DropDependencies + : never + : Dependencies[K]; +}; + +interface InjectableFunction< + Dependencies extends Record = any, + Args extends unknown[] = never, + R = unknown, +> { + dependencies: Dependencies; + fn: (dependencies: CallableDependencies, ...args: Args) => R; + asCallString: (...args: Args) => string; +} + +type Dependency = UnknownFunction | InjectableFunction; + +function getAllDependencies( + dependencies: Record, + bindableNames: Set, + allDependencies: Record = {}, +) { + for (const [key, value] of Object.entries(dependencies)) { + if ('fn' in value) { + allDependencies[key] = value.fn; + bindableNames.add(key); + getAllDependencies(value.dependencies, bindableNames, allDependencies); + } else { + allDependencies[key] = value; + } + } + return allDependencies; +} + +function bindDependencies(dependencies: Record, bindableNames: string[]) { + for (const name of bindableNames) { + dependencies[name] = dependencies[name].bind(null, dependencies); + } + return dependencies; +} + +function serializeValue(value: unknown): string { + switch (typeof value) { + case 'symbol': + throw new Error("Symbols can't be serialized"); + case 'function': + throw new Error('Functions should be injected as dependencies'); + case 'undefined': + return 'undefined'; + case 'object': + if (Array.isArray(value)) { + return `[${value.map(serializeValue).join(', ')}]`; + } + // fallthrough + default: + return JSON.stringify(value); + } +} + +function asCallString(dependencies: Record, fn: UnknownFunction, ...args: unknown[]) { + const bindableNames = new Set(); + const dependenciesString = Object.entries(getAllDependencies(dependencies, bindableNames)) + .map(([key, value]) => `${JSON.stringify(key)}: ${value}`) + .join(',\n'); + const boundDependencies = `(${bindDependencies})({\n${dependenciesString}\n}, [${Array.from(bindableNames) + .map((name) => JSON.stringify(name)) + .join(', ')}])`; + const argsString = args.map(serializeValue).join(', '); + return `(${fn})(${boundDependencies}, ${argsString})`; +} + +function validateDependencies(rootDependencies: Record, dependencies: Record) { + for (const [key, value] of Object.entries(dependencies)) { + assert( + !(key in rootDependencies) || rootDependencies[key] === dependencies[key], + `"${key}" dependency is not the same as the root dependency at the same key`, + ); + if ('dependencies' in value) { + validateDependencies(rootDependencies, value.dependencies); + } + } +} + +export function createInjectableFunction, Args extends unknown[], R>( + dependencies: Dependencies, + fn: (dependencies: CallableDependencies, ...args: Args) => R, +): InjectableFunction { + validateDependencies(dependencies, dependencies); + + return { + dependencies, + fn, + asCallString: asCallString.bind(null, dependencies, fn), + }; +} diff --git a/eslint.config.mjs b/eslint.config.mjs index 8f01018a..c51b4bb3 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -4,13 +4,7 @@ import { getNamingConventionRule, tsFileExtensions } from '@blitz/eslint-plugin/ export default [ { - ignores: [ - '**/dist', - '**/node_modules', - '**/.wrangler', - '**/bolt/build', - '**/.history', - ], + ignores: ['**/dist', '**/node_modules', '**/.wrangler', '**/bolt/build', '**/.history'], }, ...blitzPlugin.configs.recommended(), { @@ -21,15 +15,19 @@ export default [ '@typescript-eslint/no-empty-function': 'off', '@blitz/comment-syntax': 'off', '@blitz/block-scope-case': 'off', - 'array-bracket-spacing': ["error", "never"], - 'object-curly-newline': ["error", { "consistent": true }], - 'keyword-spacing': ["error", { "before": true, "after": true }], - 'consistent-return': "error", - 'semi': ["error", "always"], - 'curly': ["error"], - 'no-eval': ["error"], - 'linebreak-style': ["error", "unix"], - 'arrow-spacing': ["error", { "before": true, "after": true }] + 'array-bracket-spacing': ['error', 'never'], + 'object-curly-newline': ['error', { consistent: true }], + 'keyword-spacing': ['error', { before: true, after: true }], + 'consistent-return': 'error', + semi: ['error', 'always'], + curly: ['error'], + 'no-eval': ['error'], + 'linebreak-style': ['error', 'unix'], + 'arrow-spacing': ['error', { before: true, after: true }], + 'multiline-comment-style': ['off'], + 'padding-line-between-statements': ['off'], + '@blitz/lines-around-comment': ['off'], + '@blitz/newline-before-return': ['off'], }, }, { @@ -54,7 +52,7 @@ export default [ patterns: [ { group: ['../'], - message: 'Relative imports are not allowed. Please use \'~/\' instead.', + message: "Relative imports are not allowed. Please use '~/' instead.", }, ], },