diff --git a/.changeset/neat-beans-wave.md b/.changeset/neat-beans-wave.md new file mode 100644 index 0000000..a34c058 --- /dev/null +++ b/.changeset/neat-beans-wave.md @@ -0,0 +1,24 @@ +--- +"@statelyai/inspect": minor +--- + +Added new options `sanitizeContext` and `sanitizeEvent` to the inspector configuration. These options allow users to sanitize sensitive data from the context and events before they are sent to the inspector, and also to remove non-serializable data. + +Example usage: + +```typescript +const inspector = createInspector({ + sanitizeContext: (context) => { + // Remove sensitive data from context + const { password, ...safeContext } = context; + return safeContext; + }, + sanitizeEvent: (event) => { + // Remove sensitive data from event + if (event.type === 'SUBMIT_FORM') { + const { creditCardNumber, ...safeEvent } = event; + return safeEvent; + } + return event; + } +}); diff --git a/src/createInspector.test.ts b/src/createInspector.test.ts index a266d3f..0ab3e51 100644 --- a/src/createInspector.test.ts +++ b/src/createInspector.test.ts @@ -260,6 +260,69 @@ test('options.serialize', async () => { }); }); +test('Sanitization options', async () => { + const events: StatelyInspectionEvent[] = []; + const testAdapter: Adapter = { + send: (event) => { + events.push(event); + }, + start: () => {}, + stop: () => {}, + }; + const inspector = createInspector(testAdapter, { + sanitizeContext: (ctx) => ({ + ...ctx, + user: 'anonymous', + }), + sanitizeEvent: (ev) => { + if ('user' in ev) { + return { ...ev, user: 'anonymous' }; + } else { + return ev; + } + }, + }); + + inspector.actor('test', { context: { user: 'David' } }); + + expect((events[0] as StatelyActorEvent).snapshot.context).toEqual({ + user: 'anonymous', + }); + + inspector.snapshot('test', { context: { user: 'David' } }); + + expect((events[1] as StatelyActorEvent).snapshot.context).toEqual({ + user: 'anonymous', + }); + + inspector.event('test', { type: 'updateUser', user: 'David' }); + + expect((events[2] as StatelyEventEvent).event).toEqual({ + type: 'updateUser', + user: 'anonymous', + }); + + inspector.inspect.next?.({ + type: '@xstate.event', + actorRef: {} as any, + event: { + type: 'setUser', + user: 'Another', + }, + rootId: '', + sourceRef: undefined, + }); + + await new Promise((res) => { + setTimeout(res, 10); + }); + + expect((events[3] as StatelyEventEvent).event).toEqual({ + type: 'setUser', + user: 'anonymous', + }); +}); + test('it safely stringifies objects with circular dependencies', () => { const events: StatelyInspectionEvent[] = []; const testAdapter: Adapter = { diff --git a/src/createInspector.ts b/src/createInspector.ts index 0b7ab16..0f39184 100644 --- a/src/createInspector.ts +++ b/src/createInspector.ts @@ -7,7 +7,13 @@ import { } from './types'; import { toEventObject } from './utils'; import { Inspector } from './types'; -import { AnyActorRef, InspectionEvent, Snapshot } from 'xstate'; +import { + AnyActorRef, + AnyEventObject, + InspectionEvent, + MachineContext, + Snapshot, +} from 'xstate'; import pkg from '../package.json'; import { idleCallback } from './idleCallback'; import safeStringify from 'safe-stable-stringify'; @@ -40,7 +46,23 @@ export interface InspectorOptions { * @default true */ autoStart?: boolean; + /** + * The maximum number of deferred events to hold in memory until the inspector is active. + * If the number of deferred events exceeds this number, the oldest events will be dropped. + * + * @default 200 + */ maxDeferredEvents?: number; + + /** + * Sanitizes events sent to actors. Only the sanitized event will be sent to the inspector. + */ + sanitizeEvent?: (event: AnyEventObject) => AnyEventObject; + + /** + * Sanitizes actor snapshot context. Only the sanitized context will be sent to the inspector. + */ + sanitizeContext?: (context: MachineContext) => MachineContext; } export const defaultInspectorOptions: Required = { @@ -48,21 +70,50 @@ export const defaultInspectorOptions: Required = { serialize: (event) => event, autoStart: true, maxDeferredEvents: 200, + sanitizeEvent: (event) => event, + sanitizeContext: (context) => context, }; export function createInspector( adapter: TAdapter, options?: InspectorOptions ): Inspector { - function sendAdapter(event: StatelyInspectionEvent): void { - if (options?.filter && !options.filter(event)) { + function sendAdapter(inspectionEvent: StatelyInspectionEvent): void { + if (options?.filter && !options.filter(inspectionEvent)) { // Event filtered out return; } - const serializedEvent = options?.serialize?.(event) ?? event; - // idleCallback(() => { + + const sanitizedEvent: typeof inspectionEvent = + options?.sanitizeContext || options?.sanitizeEvent + ? inspectionEvent + : { + ...inspectionEvent, + }; + if ( + options?.sanitizeContext && + (sanitizedEvent.type === '@xstate.actor' || + sanitizedEvent.type === '@xstate.snapshot') + ) { + sanitizedEvent.snapshot = { + ...sanitizedEvent.snapshot, + // @ts-ignore + context: options.sanitizeContext( + // @ts-ignore + sanitizedEvent.snapshot.context + ), + }; + } + if ( + options?.sanitizeEvent && + (sanitizedEvent.type === '@xstate.event' || + sanitizedEvent.type === '@xstate.snapshot') + ) { + sanitizedEvent.event = options.sanitizeEvent(sanitizedEvent.event); + } + const serializedEvent = + options?.serialize?.(sanitizedEvent) ?? sanitizedEvent; adapter.send(serializedEvent); - // }) } const inspector: Inspector = { adapter,