Skip to content

Commit ebe746a

Browse files
committed
feat: add ability to download actual file
1 parent 9ebe17a commit ebe746a

File tree

15 files changed

+463
-28
lines changed

15 files changed

+463
-28
lines changed

client/lib/CoreTab.ts

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ import IWaitForOptions from '@secret-agent/interfaces/IWaitForOptions';
88
import IScreenshotOptions from '@secret-agent/interfaces/IScreenshotOptions';
99
import IFrameMeta from '@secret-agent/interfaces/IFrameMeta';
1010
import IFileChooserPrompt from '@secret-agent/interfaces/IFileChooserPrompt';
11-
import IDownload, { IDownloadState } from '@secret-agent/interfaces/IDownload';
11+
import IDownload from '@secret-agent/interfaces/IDownload';
12+
import { URL } from 'url';
1213
import CoreCommandQueue from './CoreCommandQueue';
1314
import CoreEventHeap from './CoreEventHeap';
1415
import IWaitForResourceFilter from '../interfaces/IWaitForResourceFilter';
@@ -60,8 +61,7 @@ export default class CoreTab implements IJsPathEventTarget {
6061
this.eventHeap.registerEventInterceptors({
6162
resource: createResource.bind(null, resolvedThis),
6263
dialog: createDialog.bind(null, resolvedThis),
63-
'download-started': this.createDownload.bind(resolvedThis),
64-
'download-progress': this.onDownloadProgress.bind(resolvedThis),
64+
download: this.createDownload.bind(this),
6565
});
6666
}
6767

@@ -182,15 +182,19 @@ export default class CoreTab implements IJsPathEventTarget {
182182
session?.removeTab(this);
183183
}
184184

185+
public async deleteDownload(id: string): Promise<void> {
186+
await this.commandQueue.run('Tab.deleteDownload', id);
187+
}
188+
185189
private createDownload(download: IDownload): Download {
186-
const newDownload = createDownload(Promise.resolve(this), download);
190+
const newDownload = createDownload(this, download);
187191
this.downloadsById.set(download.id, newDownload);
192+
newDownload.downloadUrl = this.connection.hostOrError.then(host => {
193+
if (host instanceof Error) return null;
194+
const hostUrl = new URL(download.downloadPath, host);
195+
hostUrl.protocol = 'http:';
196+
return hostUrl.href;
197+
});
188198
return newDownload;
189199
}
190-
191-
private onDownloadProgress(data: IDownloadState): void {
192-
const download = this.downloadsById.get(data.id);
193-
if (!download) return;
194-
Object.assign(download, data);
195-
}
196200
}

client/lib/Download.ts

Lines changed: 56 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
11
import StateMachine from 'awaited-dom/base/StateMachine';
22
import Resolvable from '@secret-agent/commons/Resolvable';
3-
import IDownload from '@secret-agent/interfaces/IDownload';
3+
import IDownload, { IDownloadState } from '@secret-agent/interfaces/IDownload';
4+
import { httpGet } from '@secret-agent/commons/downloadFile';
5+
import { createWriteStream } from 'fs';
6+
import * as http from 'http';
7+
import { bindFunctions } from '@secret-agent/commons/utils';
48
import CoreTab from './CoreTab';
59

610
const { getState, setState } = StateMachine<Download, IState>();
711

812
interface IState {
9-
coreTab: Promise<CoreTab>;
13+
coreTab: CoreTab;
1014
downloadPromise: Resolvable<void>;
1115
complete: boolean;
1216
}
@@ -17,34 +21,77 @@ export default class Download {
1721
path: string;
1822
suggestedFilename: string;
1923

20-
progress: number;
21-
totalBytes: number;
22-
canceled: boolean;
24+
progress = 0;
25+
totalBytes = 0;
26+
canceled = false;
27+
28+
#downloadUrl: Promise<string>;
2329

2430
get complete(): boolean {
2531
return getState(this).complete;
2632
}
2733

2834
set complete(value) {
2935
setState(this, { complete: value });
30-
if (value) getState(this).downloadPromise.resolve();
36+
if (value) {
37+
getState(this).downloadPromise.resolve();
38+
}
3139
}
3240

3341
waitForFinished(): Promise<void> {
3442
return getState(this).downloadPromise.promise;
3543
}
3644

37-
async saveAs(): Promise<Buffer> {
38-
// todo: add streaming ability
45+
set downloadUrl(value: Promise<string>) {
46+
this.#downloadUrl = value;
47+
}
48+
49+
async delete(): Promise<void> {
50+
const coreTab = await getState(this).coreTab;
51+
await coreTab.deleteDownload(this.id);
52+
}
53+
54+
async data(): Promise<Buffer> {
55+
await this.waitForFinished();
56+
const url = await this.#downloadUrl;
57+
const downloaderPromise = new Resolvable<Buffer>();
58+
const request = httpGet(url, async response => {
59+
if (response.statusCode !== 200) {
60+
const error = new Error(
61+
`Download failed: server returned code ${response.statusCode}. URL: ${url}`,
62+
);
63+
// consume response data to free up memory
64+
response.resume();
65+
downloaderPromise.reject(error);
66+
return;
67+
}
68+
const buffer: Buffer[] = [];
69+
for await (const chunk of response) {
70+
buffer.push(chunk);
71+
}
72+
downloaderPromise.resolve(Buffer.concat(buffer));
73+
});
74+
request.once('error', downloaderPromise.reject);
75+
return downloaderPromise.promise;
3976
}
4077
}
4178

42-
export function createDownload(coreTab: Promise<CoreTab>, data: IDownload): Download {
79+
export function createDownload(coreTab: CoreTab, data: IDownload): Download {
4380
const download = new Download();
4481
Object.assign(download, data);
4582
setState(download, {
4683
coreTab,
4784
downloadPromise: new Resolvable<void>(),
4885
});
86+
87+
function onDownloadProgress(progress: IDownloadState): void {
88+
if (progress.complete) {
89+
coreTab.removeEventListener(null, 'download-progress', onDownloadProgress).catch(() => null);
90+
}
91+
Object.assign(download, progress);
92+
}
93+
94+
coreTab.addEventListener(null, 'download-progress', onDownloadProgress).catch(() => null);
95+
4996
return download;
5097
}

commons/downloadFile.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ export function httpGet(
6060
return request;
6161
}
6262

63-
function getRequestOptionsWithProxy(url: string): RequestOptions {
63+
export function getRequestOptionsWithProxy(url: string): RequestOptions {
6464
const urlParsed = parse(url);
6565

6666
const options: https.RequestOptions = {

core/lib/Session.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import CommandRecorder from './CommandRecorder';
3131
import DetachedTabState from './DetachedTabState';
3232
import CorePlugins from './CorePlugins';
3333
import { IOutputChangeRecord } from '../models/OutputTable';
34+
import IDownload from '../../interfaces/IDownload';
3435

3536
const { log } = Log(module);
3637

@@ -60,6 +61,7 @@ export default class Session extends TypedEventEmitter<{
6061
public userProfile?: IUserProfile;
6162

6263
public tabsById = new Map<number, Tab>();
64+
public downloadsById = new Map<string, IDownload>();
6365

6466
public get isClosing() {
6567
return this._isClosing;

core/lib/Tab.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@ import { LoadStatus } from '@secret-agent/interfaces/INavigation';
2424
import IPuppetDialog from '@secret-agent/interfaces/IPuppetDialog';
2525
import IFileChooserPrompt from '@secret-agent/interfaces/IFileChooserPrompt';
2626
import IDownload, { IDownloadState } from '@secret-agent/interfaces/IDownload';
27+
import { encode } from 'querystring';
28+
import * as Fs from 'fs';
29+
import { existsAsync } from '@secret-agent/commons/fileUtils';
2730
import FrameNavigations from './FrameNavigations';
2831
import CommandRecorder from './CommandRecorder';
2932
import FrameEnvironment from './FrameEnvironment';
@@ -140,6 +143,7 @@ export default class Tab extends TypedEventEmitter<ITabEventParams> {
140143
this.commandRecorder = new CommandRecorder(this, this.session, this.id, this.mainFrameId, [
141144
this.focus,
142145
this.dismissDialog,
146+
this.deleteDownload,
143147
this.getFrameEnvironments,
144148
this.goto,
145149
this.goBack,
@@ -438,6 +442,12 @@ export default class Tab extends TypedEventEmitter<ITabEventParams> {
438442
return this.puppetPage.dismissDialog(accept, promptText);
439443
}
440444

445+
public async deleteDownload(id: string): Promise<void> {
446+
const download = this.session.downloadsById.get(id);
447+
if (!download) return;
448+
if (await existsAsync(download.path)) await Fs.promises.unlink(download.path);
449+
}
450+
441451
public async waitForNewTab(options: IWaitForOptions = {}): Promise<Tab> {
442452
// last command is the one running right now
443453
const startCommandId = Number.isInteger(options.sinceCommandId)
@@ -917,7 +927,13 @@ export default class Tab extends TypedEventEmitter<ITabEventParams> {
917927
/////// DOWNLOADS ////////////////////////////////////////////////////////////////////////////////
918928

919929
private onDownloadStarted(event: IPuppetPageEvents['download-started']): void {
920-
this.emit('download-started', event);
930+
const broadcast = event as IDownload;
931+
broadcast.downloadPath = `/downloads?${encode({
932+
id: event.id,
933+
sessionId: this.sessionId,
934+
})}`;
935+
this.session.downloadsById.set(event.id, broadcast);
936+
this.emit('download', broadcast);
921937
}
922938

923939
private onDownloadProgress(event: IPuppetPageEvents['download-progress']): void {
@@ -969,7 +985,7 @@ interface ITabEventParams {
969985
'resource-requested': IResourceMeta;
970986
resource: IResourceMeta;
971987
dialog: IPuppetDialog;
972-
'download-started': IDownload;
988+
download: IDownload;
973989
'download-progress': IDownloadState;
974990
'websocket-message': IWebsocketResourceMessage;
975991
'child-tab-created': Tab;

core/server/index.ts

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@ import * as http from 'http';
55
import { IncomingMessage, ServerResponse } from 'http';
66
import { createPromise } from '@secret-agent/commons/utils';
77
import TypeSerializer from '@secret-agent/commons/TypeSerializer';
8-
import Core, { GlobalPool } from '../index';
8+
import * as Url from 'url';
9+
import * as Fs from 'fs';
10+
import Core, { GlobalPool, Session } from '../index';
911
import ConnectionToReplay from './ConnectionToReplay';
1012
import InjectedScripts from '../lib/InjectedScripts';
1113
import * as pkg from '../package.json';
@@ -51,6 +53,7 @@ export default class CoreServer {
5153
this.routes = [
5254
['/replay/domReplayer.js', this.handleReplayerScriptRequest.bind(this)],
5355
[/\/replay\/([\d\w-]+)\/resource\/(\d+)/, this.handleResourceRequest.bind(this)],
56+
[/\/downloads?.+/, this.handleDownloadRequest.bind(this)],
5457
];
5558
}
5659

@@ -175,6 +178,36 @@ export default class CoreServer {
175178
res.end(InjectedScripts.getReplayScript());
176179
}
177180

181+
private async handleDownloadRequest(req: IncomingMessage, res: ServerResponse): Promise<void> {
182+
const url = Url.parse(req.url, true);
183+
const sessionId = url.query.sessionId as string;
184+
const id = url.query.id as string;
185+
186+
const session = Session.get(sessionId);
187+
if (!session) {
188+
return res.writeHead(404).end('Session not found');
189+
}
190+
191+
const download = session.downloadsById.get(id);
192+
if (!download) {
193+
return res.writeHead(404).end('Download not found');
194+
}
195+
196+
try {
197+
res.writeHead(200, {
198+
'content-disposition': `attachment; filename="${download.suggestedFilename ?? id}"`,
199+
});
200+
await new Promise((resolve, reject) => {
201+
Fs.createReadStream(download.path, { autoClose: true })
202+
.pipe(res, { end: true })
203+
.on('finish', resolve)
204+
.on('error', reject);
205+
});
206+
} catch (err) {
207+
return res.writeHead(500).end(String(err));
208+
}
209+
}
210+
178211
private async handleResourceRequest(
179212
req: IncomingMessage,
180213
res: ServerResponse,

full-client/test/downloads.test.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { Helpers } from '@secret-agent/testing';
2+
import CoreServer from '@secret-agent/core/server';
3+
import * as Fs from 'fs';
4+
import Download from '@secret-agent/client/lib/Download';
5+
import { Handler } from '../index';
6+
7+
let koaServer;
8+
let handler: Handler;
9+
beforeAll(async () => {
10+
const coreServer = new CoreServer();
11+
await coreServer.listen({ port: 0 });
12+
handler = new Handler({ maxConcurrency: 1, host: await coreServer.address });
13+
Helpers.onClose(() => {
14+
handler.close();
15+
coreServer.close();
16+
}, true);
17+
koaServer = await Helpers.runKoaServer();
18+
});
19+
afterAll(Helpers.afterAll);
20+
afterEach(Helpers.afterEach);
21+
22+
describe('Downloads tests', () => {
23+
it('can get a download', async () => {
24+
koaServer.get('/download', ctx => {
25+
ctx.set('Content-Type', 'application/octet-stream');
26+
ctx.set('Content-Disposition', 'attachment');
27+
ctx.body = 'This is a download';
28+
});
29+
koaServer.get('/download-page', ctx => {
30+
ctx.body = `<html>
31+
<body>
32+
<h1>Download Page</h1>
33+
<a href="/download" download="test.txt">Click me</a>
34+
</html>`;
35+
});
36+
37+
const agent = await handler.createAgent();
38+
Helpers.needsClosing.push(agent);
39+
40+
await agent.goto(`${koaServer.baseUrl}/download-page`);
41+
await agent.waitForPaintingStable();
42+
const input = await agent.document.querySelector('a');
43+
// TODO: hangs here - never sends event to chrome
44+
const downloadPromise = new Promise<Download>(resolve =>
45+
agent.activeTab.once('download', event => {
46+
resolve(event);
47+
}),
48+
);
49+
50+
await agent.click(input);
51+
const download = await downloadPromise;
52+
53+
expect(download.progress).toBeGreaterThanOrEqual(0);
54+
expect(download.id).toBeTruthy();
55+
await download.waitForFinished();
56+
expect(download.complete).toBe(true);
57+
58+
const data = (await download.data()).toString();
59+
expect(data).toBe('This is a download');
60+
61+
const path = download.path;
62+
expect(Fs.existsSync(path)).toBeTruthy();
63+
64+
await download.delete();
65+
expect(Fs.existsSync(path)).toBeFalsy();
66+
});
67+
});

interfaces/IBrowserEngine.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export default interface IBrowserEngine {
77
executablePathEnvVar: string;
88
launchArguments: string[];
99
isInstalled: boolean;
10+
userDataDir?: string;
1011

1112
isHeaded?: boolean;
1213
verifyLaunchable?(): Promise<any>;

interfaces/IDownload.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ export default interface IDownload {
33
path: string;
44
suggestedFilename: string;
55
url: string;
6+
downloadPath: string;
67
}
78

89
export interface IDownloadState {

mitm/lib/BrowserRequestMatcher.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,13 @@ export default class BrowserRequestMatcher {
5858
if (fetchDest === 'sharedworker' || fetchDest === 'serviceworker') {
5959
browserRequest.browserRequestedPromise.resolve(null);
6060
}
61+
// if navigate and empty, this is likely a download - it won't trigger in chrome
62+
if (
63+
HeadersHandler.getRequestHeader(mitmResource, 'sec-fetch-mode') === 'navigate' &&
64+
fetchDest === 'empty'
65+
) {
66+
browserRequest.browserRequestedPromise.resolve(null);
67+
}
6168

6269
mitmResource.browserHasRequested = browserRequest.browserRequestedPromise.promise
6370
.then(() => {

0 commit comments

Comments
 (0)