mirror of
https://github.com/stackblitz-labs/bolt.diy
synced 2025-06-26 18:26:38 +00:00
Make injected script more resilient to build shenanigans (#81)
This commit is contained in:
parent
ac2dac6ef4
commit
f5259243b8
@ -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<string, string>) {
|
||||
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<SimulationData> {
|
||||
//console.log("GetSimulationData", simulationData.length, numSimulationPacketsSent);
|
||||
const data = simulationData.slice(numSimulationPacketsSent);
|
||||
numSimulationPacketsSent = simulationData.length;
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
async function handleRequest<T extends Request>({
|
||||
request,
|
||||
payload,
|
||||
}: Request): Promise<RequestMap[T['request']]['response']> {
|
||||
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<IDBRequest, any> = 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<Response, RequestInfo>();
|
||||
|
||||
function convertHeaders(headers: Headers) {
|
||||
const result: Record<string, string> = {};
|
||||
headers.forEach((value, key) => {
|
||||
result[key] = value;
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
const responseMethods = {
|
||||
_name: 'Response',
|
||||
json: (v: any, response: Response) =>
|
||||
createFunctionProxy(v, 'json', async (promise: Promise<any>) => {
|
||||
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<any>) => {
|
||||
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<string, string>) {
|
||||
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<SimulationData> {
|
||||
//console.log("GetSimulationData", simulationData.length, numSimulationPacketsSent);
|
||||
const data = simulationData.slice(numSimulationPacketsSent);
|
||||
numSimulationPacketsSent = simulationData.length;
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
async function handleRequest<T extends Request>({
|
||||
request,
|
||||
payload,
|
||||
}: Request): Promise<RequestMap[T['request']]['response']> {
|
||||
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<IDBRequest, any> = 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<Response, RequestInfo>();
|
||||
|
||||
function convertHeaders(headers: Headers) {
|
||||
const result: Record<string, string> = {};
|
||||
headers.forEach((value, key) => {
|
||||
result[key] = value;
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
const responseMethods = {
|
||||
_name: 'Response',
|
||||
json: (v: any, response: Response) =>
|
||||
createFunctionProxy(v, 'json', async (promise: Promise<any>) => {
|
||||
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<any>) => {
|
||||
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();
|
||||
|
||||
@ -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<string, any> = {}) {
|
||||
//console.log(msg, JSON.stringify(tags));
|
||||
|
||||
106
app/lib/replay/injectable.ts
Normal file
106
app/lib/replay/injectable.ts
Normal file
@ -0,0 +1,106 @@
|
||||
import { assert } from './ReplayProtocolClient';
|
||||
|
||||
type UnknownFunction = (...args: never) => unknown;
|
||||
|
||||
type DropDependencies<F extends UnknownFunction> = F extends (dependencies: any, ...args: infer A) => infer R
|
||||
? (...args: A) => R
|
||||
: never;
|
||||
|
||||
type CallableDependencies<Dependencies extends Record<string, Dependency>> = {
|
||||
[K in keyof Dependencies]: 'fn' extends keyof Dependencies[K]
|
||||
? Dependencies[K]['fn'] extends UnknownFunction
|
||||
? DropDependencies<Dependencies[K]['fn']>
|
||||
: never
|
||||
: Dependencies[K];
|
||||
};
|
||||
|
||||
interface InjectableFunction<
|
||||
Dependencies extends Record<string, Dependency> = any,
|
||||
Args extends unknown[] = never,
|
||||
R = unknown,
|
||||
> {
|
||||
dependencies: Dependencies;
|
||||
fn: (dependencies: CallableDependencies<Dependencies>, ...args: Args) => R;
|
||||
asCallString: (...args: Args) => string;
|
||||
}
|
||||
|
||||
type Dependency = UnknownFunction | InjectableFunction;
|
||||
|
||||
function getAllDependencies(
|
||||
dependencies: Record<string, Dependency>,
|
||||
bindableNames: Set<string>,
|
||||
allDependencies: Record<string, UnknownFunction> = {},
|
||||
) {
|
||||
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<string, UnknownFunction>, 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<string, Dependency>, fn: UnknownFunction, ...args: unknown[]) {
|
||||
const bindableNames = new Set<string>();
|
||||
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<string, Dependency>, dependencies: Record<string, Dependency>) {
|
||||
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<Dependencies extends Record<string, Dependency>, Args extends unknown[], R>(
|
||||
dependencies: Dependencies,
|
||||
fn: (dependencies: CallableDependencies<Dependencies>, ...args: Args) => R,
|
||||
): InjectableFunction<Dependencies, Args, R> {
|
||||
validateDependencies(dependencies, dependencies);
|
||||
|
||||
return {
|
||||
dependencies,
|
||||
fn,
|
||||
asCallString: asCallString.bind(null, dependencies, fn),
|
||||
};
|
||||
}
|
||||
@ -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.",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
Loading…
Reference in New Issue
Block a user