Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
ulixee
GitHub Repository: ulixee/secret-agent
Path: blob/main/replay/backend/api/index.ts
1030 views
1
import * as WebSocket from 'ws';
2
import { ChildProcess, spawn } from 'child_process';
3
import * as Path from 'path';
4
import * as Http from 'http';
5
import * as Fs from 'fs';
6
import { app, ProtocolResponse } from 'electron';
7
import ISaSession, { ISessionTab } from '~shared/interfaces/ISaSession';
8
import IReplayMeta from '~shared/interfaces/IReplayMeta';
9
import ReplayResources from '~backend/api/ReplayResources';
10
import getResolvable from '~shared/utils/promise';
11
import ReplayTabState from '~backend/api/ReplayTabState';
12
import ReplayTime from '~backend/api/ReplayTime';
13
import ReplayOutput from '~backend/api/ReplayOutput';
14
import storage from '~backend/storage';
15
16
export default class ReplayApi {
17
public static serverProcess: ChildProcess;
18
public static serverStartPath: string;
19
public static nodePath: string;
20
private static websockets = new Set<WebSocket>();
21
private static replayScriptCacheByHost = new Map<string, string>();
22
private static localApiHost: URL;
23
24
public readonly saSession: ISaSession;
25
public tabsById = new Map<number, ReplayTabState>();
26
public apiHost: URL;
27
public lastActivityDate: Date;
28
public lastCommandName: string;
29
public showUnresponsiveMessage = true;
30
public hasAllData = false;
31
public output = new ReplayOutput();
32
33
public onTabChange?: (tab: ReplayTabState) => any;
34
35
public get isReady() {
36
return this.isReadyResolvable.promise;
37
}
38
39
public get startTab(): ReplayTabState {
40
return this.tabsById.values().next().value;
41
}
42
43
private replayTime: ReplayTime;
44
private readonly isReadyResolvable = getResolvable<void>();
45
46
private readonly websocket: WebSocket;
47
48
private resources = new ReplayResources();
49
50
constructor(apiHost: URL, replay: IReplayMeta) {
51
this.apiHost = apiHost;
52
this.saSession = {
53
...replay,
54
name: replay.sessionName,
55
id: replay.sessionId,
56
} as any;
57
58
const headers: any = {};
59
for (const [key, value] of Object.entries({
60
'data-location': this.saSession.dataLocation,
61
'session-name': this.saSession.name,
62
'session-id': this.saSession.id,
63
'script-instance-id': this.saSession.scriptInstanceId,
64
'script-entrypoint': this.saSession.scriptEntrypoint,
65
})) {
66
if (value) headers[key] = value;
67
}
68
69
this.websocket = new WebSocket(apiHost, {
70
headers,
71
});
72
73
this.websocket.once('open', () => {
74
this.websocket.off('error', this.isReadyResolvable.reject);
75
});
76
this.websocket.once('error', this.isReadyResolvable.reject);
77
78
ReplayApi.websockets.add(this.websocket);
79
this.websocket.on('close', () => {
80
ReplayApi.websockets.delete(this.websocket);
81
console.log('Ws Session closed', this.saSession.id);
82
});
83
this.websocket.on('message', this.onMessage.bind(this));
84
}
85
86
public async getReplayScript(): Promise<string> {
87
// only load from memory so we have latest version
88
const cached = ReplayApi.replayScriptCacheByHost.get(this.apiHost.href);
89
if (cached) return cached;
90
91
const scriptsDir = `${app.getPath('userData')}/scripts`;
92
if (!Fs.existsSync(scriptsDir)) {
93
Fs.mkdirSync(scriptsDir, { recursive: true });
94
}
95
const scriptUrl = `http://${this.apiHost.host}/replay/domReplayer.js`;
96
97
console.log('Fetching %s', scriptUrl);
98
99
await new Promise<void>((resolve, reject) => {
100
const req = Http.get(scriptUrl, async res => {
101
res.on('error', reject);
102
const data: Buffer[] = [];
103
for await (const chunk of res) {
104
data.push(chunk);
105
}
106
const result = Buffer.concat(data).toString();
107
108
// cheap sanitization check to avoid accessing electron here
109
if (
110
result.includes('import(') ||
111
result.match(/^\s*import/g) ||
112
result.includes(' require.') ||
113
result.includes(' require(')
114
) {
115
throw new Error(
116
`Disallowed nodejs module (require or import) access requested by domReplayer.js script at "${scriptUrl}"`,
117
);
118
}
119
120
const scriptPath = `${scriptsDir}/${res.headers.filename}`;
121
await Fs.promises.writeFile(scriptPath, result);
122
ReplayApi.replayScriptCacheByHost.set(this.apiHost.href, scriptPath);
123
124
resolve();
125
});
126
req.on('error', reject);
127
req.end();
128
});
129
return ReplayApi.replayScriptCacheByHost.get(this.apiHost.href);
130
}
131
132
public async getResource(url: string): Promise<ProtocolResponse> {
133
const resource = await this.resources.get(url);
134
if (resource.redirectedToUrl) {
135
return <ProtocolResponse>{
136
statusCode: resource.statusCode,
137
headers: { location: resource.redirectedToUrl },
138
};
139
}
140
141
const localHost = ReplayApi.localApiHost;
142
const apiHost = `http://${localHost.host}/replay/${this.saSession.id}`;
143
return this.resources.getContent(resource.id, apiHost, this.saSession.dataLocation);
144
}
145
146
public close(): void {
147
for (const value of this.tabsById.values()) {
148
if (value.isActive) return;
149
}
150
151
this.websocket.close();
152
ReplayApi.websockets.delete(this.websocket);
153
}
154
155
public getTab(tabId: number): ReplayTabState {
156
return this.tabsById.get(tabId);
157
}
158
159
private async onMessage(messageData: WebSocket.Data): Promise<void> {
160
const { event, data } = parseJSON(messageData);
161
if (event === 'trailer') {
162
this.hasAllData = true;
163
for (const tab of this.tabsById.values()) tab.hasAllData = true;
164
console.log('All data received', data);
165
return;
166
}
167
168
if (event === 'error') {
169
this.isReadyResolvable.reject(data.message);
170
return;
171
}
172
173
if (event === 'session') {
174
this.onSession(data);
175
return;
176
}
177
178
// don't load api data until the session is ready
179
await this.isReady;
180
this.lastActivityDate ??= new Date();
181
182
const tabsWithChanges = new Set<ReplayTabState>();
183
184
if (event === 'script-state') {
185
console.log('ScriptState', data);
186
const closeDate = data.closeDate ? new Date(data.closeDate) : null;
187
this.replayTime.update(closeDate);
188
this.lastActivityDate = data.lastActivityDate ? new Date(data.lastActivityDate) : null;
189
this.lastCommandName = data.lastCommandName;
190
for (const tab of this.tabsById.values()) tabsWithChanges.add(tab);
191
} else if (event === 'output') {
192
this.output.onOutput(data);
193
} else {
194
if (!this.replayTime.close) {
195
this.replayTime.update();
196
}
197
198
for (const record of data) {
199
const tabId = record.tabId;
200
let tab = this.getTab(tabId);
201
if (!tab) {
202
const timestamp = Number(record.timestamp ?? record.startDate);
203
console.log('New Tab created in replay');
204
tab = this.onApiHasNewTab(tabId, timestamp);
205
}
206
tabsWithChanges.add(tab);
207
208
if (event === 'resources') this.resources.onResource(record);
209
else tab.onApiFeed(event, record);
210
}
211
}
212
213
for (const tab of tabsWithChanges) tab.sortTicks();
214
// if this is a detached tab command, we should create a new tab here
215
if (event === 'commands') {
216
for (const record of data) {
217
if (record.name !== 'detachTab' || !record.result) continue;
218
console.log('Loading a detached Tab', record);
219
const tab = this.getTab(record.tabId);
220
const detachedTabId = record.result.detachedTab.id;
221
const detachedState = record.result.detachedState;
222
const { timestampRange, indexRange } = detachedState.domChangeRange;
223
const paintEvents = tab.copyPaintEvents(timestampRange, indexRange);
224
const newTab = this.onApiHasNewTab(detachedTabId, record.startDate, record.tabId);
225
newTab.loadDetachedState(
226
record.tabId,
227
paintEvents,
228
record.timestamp,
229
record.id,
230
detachedState.url,
231
);
232
}
233
}
234
}
235
236
private onApiHasNewTab(
237
tabId: number,
238
timestamp: number,
239
detachedFromTabId?: number,
240
): ReplayTabState {
241
const firstTab = this.startTab;
242
const tabMeta = <ISessionTab>{
243
tabId,
244
detachedFromTabId,
245
createdTime: timestamp,
246
width: firstTab.viewportWidth,
247
height: firstTab.viewportHeight,
248
};
249
const tab = new ReplayTabState(tabMeta, this.replayTime);
250
if (this.onTabChange) this.onTabChange(tab);
251
252
this.tabsById.set(tabId, tab);
253
return tab;
254
}
255
256
private onSession(data: ISaSession) {
257
// parse strings to dates from api
258
data.startDate = new Date(data.startDate);
259
data.closeDate = data.closeDate ? new Date(data.closeDate) : null;
260
261
Object.assign(this.saSession, data);
262
263
console.log(`Loaded ReplayApi.sessionMeta`, {
264
sessionId: data.id,
265
dataLocation: data.dataLocation,
266
start: data.startDate,
267
close: data.closeDate,
268
tabs: data.tabs,
269
});
270
271
this.replayTime = new ReplayTime(data.startDate, data.closeDate);
272
for (const tab of data.tabs) {
273
this.tabsById.set(tab.tabId, new ReplayTabState(tab, this.replayTime));
274
}
275
this.isReadyResolvable.resolve();
276
}
277
278
public static quit() {
279
console.log(
280
'Shutting down Replay API. Process? %s. Open Sessions: %s',
281
!!ReplayApi.serverProcess,
282
ReplayApi.websockets.size,
283
);
284
for (const socket of ReplayApi.websockets) socket.terminate();
285
if (ReplayApi.serverProcess) ReplayApi.serverProcess.kill();
286
}
287
288
public static async connect(replay: IReplayMeta) {
289
await ReplayApi.startServer(replay);
290
291
const replayApiUrl = replay.replayApiUrl ? new URL(replay.replayApiUrl) : this.localApiHost;
292
293
console.log('Connecting to Replay API', replay.replayApiUrl);
294
const api = new ReplayApi(replayApiUrl, replay);
295
try {
296
await api.isReady;
297
} catch (err) {
298
if (err.code === 'ECONNREFUSED') {
299
replay.replayApiUrl = null;
300
if (this.serverProcess) {
301
this.serverProcess.kill();
302
this.serverProcess = null;
303
}
304
return this.connect(replay);
305
}
306
throw err;
307
}
308
return api;
309
}
310
311
private static async startServer(replayMeta: IReplayMeta) {
312
if (this.localApiHost || this.serverProcess) return;
313
314
const args = [];
315
// look in script instance directory first
316
if (!this.serverStartPath && replayMeta.scriptEntrypoint) {
317
this.serverStartPath = findCoreForScript(replayMeta.scriptEntrypoint);
318
console.log('Looking for core path for script entrypoint', {
319
scriptEntrypoint: replayMeta.scriptEntrypoint,
320
serverStartPath: this.serverStartPath,
321
});
322
}
323
324
// load a previous script
325
if (!this.serverStartPath) {
326
const history = storage.fetchHistory();
327
for (const item of history) {
328
if (item.scriptEntrypoint) {
329
this.serverStartPath = findCoreForScript(item.scriptEntrypoint);
330
console.log('Looking for core path from previously loaded script', {
331
scriptEntrypoint: item.scriptEntrypoint,
332
serverStartPath: this.serverStartPath,
333
});
334
}
335
if (this.serverStartPath) break;
336
}
337
}
338
339
// check workspace?
340
if (!this.serverStartPath) {
341
const replayDir = __dirname.split(`${Path.sep}replay${Path.sep}`).shift();
342
this.serverStartPath = Path.resolve(replayDir, 'core', 'start');
343
console.log('Looking for core path from monorepo', {
344
serverStartPath: this.serverStartPath,
345
});
346
if (!Fs.existsSync(this.serverStartPath) && !Fs.existsSync(`${this.serverStartPath}.js`)) {
347
this.serverStartPath = null;
348
return;
349
}
350
}
351
352
if (!this.nodePath) {
353
this.nodePath = 'node';
354
}
355
console.log('Launching Replay API Server at %s', this.serverStartPath);
356
const child = spawn(`${this.nodePath} "${this.serverStartPath}"`, args, {
357
stdio: ['ignore', 'pipe', 'pipe', 'ipc'],
358
shell: true,
359
windowsHide: true,
360
});
361
362
child.on('error', console.error);
363
child.stdout.pipe(process.stdout);
364
child.stderr.pipe(process.stderr);
365
process.once('exit', () => {
366
this.serverProcess?.kill();
367
});
368
this.serverProcess = child;
369
this.serverProcess.once('exit', () => {
370
child.stderr.unpipe();
371
child.stdout.unpipe();
372
this.serverProcess = null;
373
});
374
375
const promise = await new Promise((resolve, reject) => {
376
child.once('error', reject);
377
child.once('message', message => {
378
resolve(message as string);
379
child.off('error', reject);
380
});
381
});
382
383
this.localApiHost = new URL(`${await promise}/replay`);
384
return child;
385
}
386
}
387
388
function findCoreForScript(scriptEntrypoint: string) {
389
let startDir = Path.dirname(scriptEntrypoint);
390
do {
391
const startPath = `${startDir}/node_modules/@secret-agent/core/start.js`;
392
if (Fs.existsSync(startPath)) {
393
return startPath;
394
}
395
396
if (Path.dirname(startDir) === startDir) return null;
397
398
startDir = Path.dirname(startDir);
399
} while (startDir && Fs.existsSync(startDir));
400
}
401
402
function parseJSON(data: WebSocket.Data) {
403
return JSON.parse(data.toString(), (key, value) => {
404
if (
405
typeof value === 'object' &&
406
value !== null &&
407
value.type === 'Buffer' &&
408
Array.isArray(value.data)
409
) {
410
return Buffer.from(value.data);
411
}
412
return value;
413
});
414
}
415
416