activity.ts
209 lines · browse/src/activity.ts at 3f79d81
1/**
2 * Activity streaming — real-time feed of browse commands for the Chrome extension Side Panel
3 *
4 * Architecture:
5 * handleCommand() ──► emitActivity(command_start)
6 * ──► emitActivity(command_end)
7 * wirePageEvents() ──► emitActivity(navigation)
8 *
9 * GET /activity/stream?after=ID ──► SSE via ReadableStream
10 * GET /activity/history?limit=N ──► REST fallback
11 *
12 * Privacy: filterArgs() redacts passwords, auth tokens, and sensitive query params.
13 * Backpressure: subscribers notified via queueMicrotask (never blocks command path).
14 * Gap detection: client sends ?after=ID, server detects if ring buffer overflowed.
15 */
17import { CircularBuffer } from './buffers';
19// ─── Types ──────────────────────────────────────────────────────
21export interface ActivityEntry {
22 id: number;
23 timestamp: number;
24 type: 'command_start' | 'command_end' | 'navigation' | 'error';
25 command?: string;
26 args?: string[];
27 url?: string;
28 duration?: number;
29 status?: 'ok' | 'error';
30 error?: string;
31 result?: string;
32 tabs?: number;
33 mode?: string;
34 clientId?: string;
35}
37// ─── Buffer & Subscribers ───────────────────────────────────────
39const BUFFER_CAPACITY = 1000;
40const activityBuffer = new CircularBuffer<ActivityEntry>(BUFFER_CAPACITY);
41let nextId = 1;
43type ActivitySubscriber = (entry: ActivityEntry) => void;
44const subscribers = new Set<ActivitySubscriber>();
46// ─── Privacy Filtering ─────────────────────────────────────────
48const SENSITIVE_COMMANDS = new Set(['fill', 'type', 'cookie', 'header']);
49const SENSITIVE_PARAM_PATTERN = /\b(password|token|secret|key|auth|bearer|api[_-]?key)\b/i;
51/**
52 * Redact sensitive data from command args before streaming.
53 */
54export function filterArgs(command: string, args: string[]): string[] {
55 if (!args || args.length === 0) return args;
57 // fill: redact the value (last arg) for password-type fields
58 if (command === 'fill' && args.length >= 2) {
59 const selector = args[0];
60 // If the selector suggests a password field, redact the value
61 if (/password|passwd|secret|token/i.test(selector)) {
62 return [selector, '[REDACTED]'];
63 }
64 return args;
65 }
67 // header: redact Authorization and other sensitive headers
68 if (command === 'header' && args.length >= 1) {
69 const headerLine = args[0];
70 if (/^(authorization|x-api-key|cookie|set-cookie)/i.test(headerLine)) {
71 const colonIdx = headerLine.indexOf(':');
72 if (colonIdx > 0) {
73 return [headerLine.substring(0, colonIdx + 1) + '[REDACTED]'];
74 }
75 }
76 return args;
77 }
79 // cookie: redact cookie values
80 if (command === 'cookie' && args.length >= 1) {
81 const cookieStr = args[0];
82 const eqIdx = cookieStr.indexOf('=');
83 if (eqIdx > 0) {
84 return [cookieStr.substring(0, eqIdx + 1) + '[REDACTED]'];
85 }
86 return args;
87 }
89 // type: always redact (could be a password field)
90 if (command === 'type') {
91 return ['[REDACTED]'];
92 }
94 // URL args: redact sensitive query params
95 return args.map(arg => {
96 if (arg.startsWith('http://') || arg.startsWith('https://')) {
97 try {
98 const url = new URL(arg);
99 let redacted = false;
100 for (const key of url.searchParams.keys()) {
101 if (SENSITIVE_PARAM_PATTERN.test(key)) {
102 url.searchParams.set(key, '[REDACTED]');
103 redacted = true;
104 }
105 }
106 return redacted ? url.toString() : arg;
107 } catch {
108 return arg;
109 }
110 }
111 return arg;
112 });
113}
115/**
116 * Truncate result text for streaming (max 200 chars).
117 */
118function truncateResult(result: string | undefined): string | undefined {
119 if (!result) return undefined;
120 if (result.length <= 200) return result;
121 return result.substring(0, 200) + '...';
122}
124// ─── Public API ─────────────────────────────────────────────────
126/**
127 * Emit an activity event. Backpressure-safe: subscribers notified asynchronously.
128 */
129export function emitActivity(entry: Omit<ActivityEntry, 'id' | 'timestamp'>): ActivityEntry {
130 const full: ActivityEntry = {
131 ...entry,
132 id: nextId++,
133 timestamp: Date.now(),
134 args: entry.args ? filterArgs(entry.command || '', entry.args) : undefined,
135 result: truncateResult(entry.result),
136 };
137 activityBuffer.push(full);
139 // Notify subscribers asynchronously — never block the command path
140 for (const notify of subscribers) {
141 queueMicrotask(() => {
142 try { notify(full); } catch { /* subscriber error — don't crash */ }
143 });
144 }
146 return full;
147}
149/**
150 * Subscribe to live activity events. Returns unsubscribe function.
151 */
152export function subscribe(fn: ActivitySubscriber): () => void {
153 subscribers.add(fn);
154 return () => subscribers.delete(fn);
155}
157/**
158 * Get recent activity entries after the given cursor ID.
159 * Returns entries and gap info if the buffer has overflowed.
160 */
161export function getActivityAfter(afterId: number): {
162 entries: ActivityEntry[];
163 gap: boolean;
164 gapFrom?: number;
165 availableFrom?: number;
166 totalAdded: number;
167} {
168 const total = activityBuffer.totalAdded;
169 const allEntries = activityBuffer.toArray();
171 if (afterId === 0) {
172 return { entries: allEntries, gap: false, totalAdded: total };
173 }
175 // Check for gap: if afterId is too old and has been evicted
176 const oldestId = allEntries.length > 0 ? allEntries[0].id : nextId;
177 if (afterId < oldestId) {
178 return {
179 entries: allEntries,
180 gap: true,
181 gapFrom: afterId + 1,
182 availableFrom: oldestId,
183 totalAdded: total,
184 };
185 }
187 // Filter to entries after the cursor
188 const filtered = allEntries.filter(e => e.id > afterId);
189 return { entries: filtered, gap: false, totalAdded: total };
190}
192/**
193 * Get the N most recent activity entries.
194 */
195export function getActivityHistory(limit: number = 50): {
196 entries: ActivityEntry[];
197 totalAdded: number;
198} {
199 const allEntries = activityBuffer.toArray();
200 const sliced = limit < allEntries.length ? allEntries.slice(-limit) : allEntries;
201 return { entries: sliced, totalAdded: activityBuffer.totalAdded };
202}
204/**
205 * Get subscriber count (for debugging/health).
206 */
207export function getSubscriberCount(): number {
208 return subscribers.size;
209}