Make injected script more resilient to build shenanigans (#81)

This commit is contained in:
Mateusz Burzyński 2025-03-27 15:57:42 +01:00 committed by GitHub
parent ac2dac6ef4
commit f5259243b8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 611 additions and 497 deletions

View File

@ -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();

View File

@ -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));

View 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),
};
}

View File

@ -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.",
},
],
},