feat: add protocol.handle (#36674)
This commit is contained in:
parent
6a6908c4c8
commit
fda8ea9277
25 changed files with 1254 additions and 89 deletions
|
@ -1,8 +1,9 @@
|
|||
import { expect } from 'chai';
|
||||
import { v4 } from 'uuid';
|
||||
import { protocol, webContents, WebContents, session, BrowserWindow, ipcMain } from 'electron/main';
|
||||
import { protocol, webContents, WebContents, session, BrowserWindow, ipcMain, net } from 'electron/main';
|
||||
import * as ChildProcess from 'child_process';
|
||||
import * as path from 'path';
|
||||
import * as url from 'url';
|
||||
import * as http from 'http';
|
||||
import * as fs from 'fs';
|
||||
import * as qs from 'querystring';
|
||||
|
@ -10,7 +11,7 @@ import * as stream from 'stream';
|
|||
import { EventEmitter, once } from 'events';
|
||||
import { closeAllWindows, closeWindow } from './lib/window-helpers';
|
||||
import { WebmGenerator } from './lib/video-helpers';
|
||||
import { listen } from './lib/spec-helpers';
|
||||
import { listen, defer, ifit } from './lib/spec-helpers';
|
||||
import { setTimeout } from 'timers/promises';
|
||||
|
||||
const fixturesPath = path.resolve(__dirname, 'fixtures');
|
||||
|
@ -34,7 +35,9 @@ const postData = {
|
|||
};
|
||||
|
||||
function getStream (chunkSize = text.length, data: Buffer | string = text) {
|
||||
const body = new stream.PassThrough();
|
||||
// allowHalfOpen required, otherwise Readable.toWeb gets confused and thinks
|
||||
// the stream isn't done when the readable half ends.
|
||||
const body = new stream.PassThrough({ allowHalfOpen: false });
|
||||
|
||||
async function sendChunks () {
|
||||
await setTimeout(0); // the stream protocol API breaks if you send data immediately.
|
||||
|
@ -54,9 +57,12 @@ function getStream (chunkSize = text.length, data: Buffer | string = text) {
|
|||
sendChunks();
|
||||
return body;
|
||||
}
|
||||
function getWebStream (chunkSize = text.length, data: Buffer | string = text): ReadableStream<ArrayBufferView> {
|
||||
return stream.Readable.toWeb(getStream(chunkSize, data)) as ReadableStream<ArrayBufferView>;
|
||||
}
|
||||
|
||||
// A promise that can be resolved externally.
|
||||
function defer (): Promise<any> & {resolve: Function, reject: Function} {
|
||||
function deferPromise (): Promise<any> & {resolve: Function, reject: Function} {
|
||||
let promiseResolve: Function = null as unknown as Function;
|
||||
let promiseReject: Function = null as unknown as Function;
|
||||
const promise: any = new Promise((resolve, reject) => {
|
||||
|
@ -860,7 +866,7 @@ describe('protocol module', () => {
|
|||
});
|
||||
|
||||
it('can have fetch working in it', async () => {
|
||||
const requestReceived = defer();
|
||||
const requestReceived = deferPromise();
|
||||
const server = http.createServer((req, res) => {
|
||||
res.end();
|
||||
server.close();
|
||||
|
@ -1093,4 +1099,422 @@ describe('protocol module', () => {
|
|||
}
|
||||
}
|
||||
});
|
||||
|
||||
describe('handle', () => {
|
||||
afterEach(closeAllWindows);
|
||||
|
||||
it('receives requests to a custom scheme', async () => {
|
||||
protocol.handle('test-scheme', (req) => new Response('hello ' + req.url));
|
||||
defer(() => { protocol.unhandle('test-scheme'); });
|
||||
const resp = await net.fetch('test-scheme://foo');
|
||||
expect(resp.status).to.equal(200);
|
||||
});
|
||||
|
||||
it('can be unhandled', async () => {
|
||||
protocol.handle('test-scheme', (req) => new Response('hello ' + req.url));
|
||||
defer(() => {
|
||||
try {
|
||||
// In case of failure, make sure we unhandle. But we should succeed
|
||||
// :)
|
||||
protocol.unhandle('test-scheme');
|
||||
} catch (_ignored) { /* ignore */ }
|
||||
});
|
||||
const resp1 = await net.fetch('test-scheme://foo');
|
||||
expect(resp1.status).to.equal(200);
|
||||
protocol.unhandle('test-scheme');
|
||||
await expect(net.fetch('test-scheme://foo')).to.eventually.be.rejectedWith(/ERR_UNKNOWN_URL_SCHEME/);
|
||||
});
|
||||
|
||||
it('receives requests to an existing scheme', async () => {
|
||||
protocol.handle('https', (req) => new Response('hello ' + req.url));
|
||||
defer(() => { protocol.unhandle('https'); });
|
||||
const body = await net.fetch('https://foo').then(r => r.text());
|
||||
expect(body).to.equal('hello https://foo/');
|
||||
});
|
||||
|
||||
it('receives requests to an existing scheme when navigating', async () => {
|
||||
protocol.handle('https', (req) => new Response('hello ' + req.url));
|
||||
defer(() => { protocol.unhandle('https'); });
|
||||
const w = new BrowserWindow({ show: false });
|
||||
await w.loadURL('https://localhost');
|
||||
expect(await w.webContents.executeJavaScript('document.body.textContent')).to.equal('hello https://localhost/');
|
||||
});
|
||||
|
||||
it('can send buffer body', async () => {
|
||||
protocol.handle('test-scheme', (req) => new Response(Buffer.from('hello ' + req.url)));
|
||||
defer(() => { protocol.unhandle('test-scheme'); });
|
||||
const body = await net.fetch('test-scheme://foo').then(r => r.text());
|
||||
expect(body).to.equal('hello test-scheme://foo');
|
||||
});
|
||||
|
||||
it('can send stream body', async () => {
|
||||
protocol.handle('test-scheme', () => new Response(getWebStream()));
|
||||
defer(() => { protocol.unhandle('test-scheme'); });
|
||||
const body = await net.fetch('test-scheme://foo').then(r => r.text());
|
||||
expect(body).to.equal(text);
|
||||
});
|
||||
|
||||
it('accepts urls with no hostname in non-standard schemes', async () => {
|
||||
protocol.handle('test-scheme', (req) => new Response(req.url));
|
||||
defer(() => { protocol.unhandle('test-scheme'); });
|
||||
{
|
||||
const body = await net.fetch('test-scheme://foo').then(r => r.text());
|
||||
expect(body).to.equal('test-scheme://foo');
|
||||
}
|
||||
{
|
||||
const body = await net.fetch('test-scheme:///foo').then(r => r.text());
|
||||
expect(body).to.equal('test-scheme:///foo');
|
||||
}
|
||||
{
|
||||
const body = await net.fetch('test-scheme://').then(r => r.text());
|
||||
expect(body).to.equal('test-scheme://');
|
||||
}
|
||||
});
|
||||
|
||||
it('accepts urls with a port-like component in non-standard schemes', async () => {
|
||||
protocol.handle('test-scheme', (req) => new Response(req.url));
|
||||
defer(() => { protocol.unhandle('test-scheme'); });
|
||||
{
|
||||
const body = await net.fetch('test-scheme://foo:30').then(r => r.text());
|
||||
expect(body).to.equal('test-scheme://foo:30');
|
||||
}
|
||||
});
|
||||
|
||||
it('normalizes urls in standard schemes', async () => {
|
||||
// NB. 'app' is registered as a standard scheme in test setup.
|
||||
protocol.handle('app', (req) => new Response(req.url));
|
||||
defer(() => { protocol.unhandle('app'); });
|
||||
{
|
||||
const body = await net.fetch('app://foo').then(r => r.text());
|
||||
expect(body).to.equal('app://foo/');
|
||||
}
|
||||
{
|
||||
const body = await net.fetch('app:///foo').then(r => r.text());
|
||||
expect(body).to.equal('app://foo/');
|
||||
}
|
||||
// NB. 'app' is registered with the default scheme type of 'host'.
|
||||
{
|
||||
const body = await net.fetch('app://foo:1234').then(r => r.text());
|
||||
expect(body).to.equal('app://foo/');
|
||||
}
|
||||
await expect(net.fetch('app://')).to.be.rejectedWith('Invalid URL');
|
||||
});
|
||||
|
||||
it('fails on URLs with a username', async () => {
|
||||
// NB. 'app' is registered as a standard scheme in test setup.
|
||||
protocol.handle('http', (req) => new Response(req.url));
|
||||
defer(() => { protocol.unhandle('http'); });
|
||||
await expect(contents.loadURL('http://x@foo:1234')).to.be.rejectedWith(/ERR_UNEXPECTED/);
|
||||
});
|
||||
|
||||
it('normalizes http urls', async () => {
|
||||
protocol.handle('http', (req) => new Response(req.url));
|
||||
defer(() => { protocol.unhandle('http'); });
|
||||
{
|
||||
const body = await net.fetch('http://foo').then(r => r.text());
|
||||
expect(body).to.equal('http://foo/');
|
||||
}
|
||||
});
|
||||
|
||||
it('can send errors', async () => {
|
||||
protocol.handle('test-scheme', () => Response.error());
|
||||
defer(() => { protocol.unhandle('test-scheme'); });
|
||||
await expect(net.fetch('test-scheme://foo')).to.eventually.be.rejectedWith('net::ERR_FAILED');
|
||||
});
|
||||
|
||||
it('handles a synchronous error in the handler', async () => {
|
||||
protocol.handle('test-scheme', () => { throw new Error('test'); });
|
||||
defer(() => { protocol.unhandle('test-scheme'); });
|
||||
await expect(net.fetch('test-scheme://foo')).to.be.rejectedWith('net::ERR_UNEXPECTED');
|
||||
});
|
||||
|
||||
it('handles an asynchronous error in the handler', async () => {
|
||||
protocol.handle('test-scheme', () => Promise.reject(new Error('rejected promise')));
|
||||
defer(() => { protocol.unhandle('test-scheme'); });
|
||||
await expect(net.fetch('test-scheme://foo')).to.be.rejectedWith('net::ERR_UNEXPECTED');
|
||||
});
|
||||
|
||||
it('correctly sets statusCode', async () => {
|
||||
protocol.handle('test-scheme', () => new Response(null, { status: 201 }));
|
||||
defer(() => { protocol.unhandle('test-scheme'); });
|
||||
const resp = await net.fetch('test-scheme://foo');
|
||||
expect(resp.status).to.equal(201);
|
||||
});
|
||||
|
||||
it('correctly sets content-type and charset', async () => {
|
||||
protocol.handle('test-scheme', () => new Response(null, { headers: { 'content-type': 'text/html; charset=testcharset' } }));
|
||||
defer(() => { protocol.unhandle('test-scheme'); });
|
||||
const resp = await net.fetch('test-scheme://foo');
|
||||
expect(resp.headers.get('content-type')).to.equal('text/html; charset=testcharset');
|
||||
});
|
||||
|
||||
it('can forward to http', async () => {
|
||||
const server = http.createServer((req, res) => {
|
||||
res.end(text);
|
||||
});
|
||||
defer(() => { server.close(); });
|
||||
const { url } = await listen(server);
|
||||
|
||||
protocol.handle('test-scheme', () => net.fetch(url));
|
||||
defer(() => { protocol.unhandle('test-scheme'); });
|
||||
const body = await net.fetch('test-scheme://foo').then(r => r.text());
|
||||
expect(body).to.equal(text);
|
||||
});
|
||||
|
||||
it('can forward an http request with headers', async () => {
|
||||
const server = http.createServer((req, res) => {
|
||||
res.setHeader('foo', 'bar');
|
||||
res.end(text);
|
||||
});
|
||||
defer(() => { server.close(); });
|
||||
const { url } = await listen(server);
|
||||
|
||||
protocol.handle('test-scheme', (req) => net.fetch(url, { headers: req.headers }));
|
||||
defer(() => { protocol.unhandle('test-scheme'); });
|
||||
|
||||
const resp = await net.fetch('test-scheme://foo');
|
||||
expect(resp.headers.get('foo')).to.equal('bar');
|
||||
});
|
||||
|
||||
it('can forward to file', async () => {
|
||||
protocol.handle('test-scheme', () => net.fetch(url.pathToFileURL(path.join(__dirname, 'fixtures', 'hello.txt')).toString()));
|
||||
defer(() => { protocol.unhandle('test-scheme'); });
|
||||
|
||||
const body = await net.fetch('test-scheme://foo').then(r => r.text());
|
||||
expect(body.trimEnd()).to.equal('hello world');
|
||||
});
|
||||
|
||||
it('can receive simple request body', async () => {
|
||||
protocol.handle('test-scheme', (req) => new Response(req.body));
|
||||
defer(() => { protocol.unhandle('test-scheme'); });
|
||||
const body = await net.fetch('test-scheme://foo', {
|
||||
method: 'POST',
|
||||
body: 'foobar'
|
||||
}).then(r => r.text());
|
||||
expect(body).to.equal('foobar');
|
||||
});
|
||||
|
||||
it('can receive stream request body', async () => {
|
||||
protocol.handle('test-scheme', (req) => new Response(req.body));
|
||||
defer(() => { protocol.unhandle('test-scheme'); });
|
||||
const body = await net.fetch('test-scheme://foo', {
|
||||
method: 'POST',
|
||||
body: getWebStream(),
|
||||
duplex: 'half' // https://github.com/microsoft/TypeScript/issues/53157
|
||||
} as any).then(r => r.text());
|
||||
expect(body).to.equal(text);
|
||||
});
|
||||
|
||||
it('can receive multi-part postData from loadURL', async () => {
|
||||
protocol.handle('test-scheme', (req) => new Response(req.body));
|
||||
defer(() => { protocol.unhandle('test-scheme'); });
|
||||
await contents.loadURL('test-scheme://foo', { postData: [{ type: 'rawData', bytes: Buffer.from('a') }, { type: 'rawData', bytes: Buffer.from('b') }] });
|
||||
expect(await contents.executeJavaScript('document.documentElement.textContent')).to.equal('ab');
|
||||
});
|
||||
|
||||
it('can receive file postData from loadURL', async () => {
|
||||
protocol.handle('test-scheme', (req) => new Response(req.body));
|
||||
defer(() => { protocol.unhandle('test-scheme'); });
|
||||
await contents.loadURL('test-scheme://foo', { postData: [{ type: 'file', filePath: path.join(fixturesPath, 'hello.txt'), length: 'hello world\n'.length, offset: 0, modificationTime: 0 }] });
|
||||
expect(await contents.executeJavaScript('document.documentElement.textContent')).to.equal('hello world\n');
|
||||
});
|
||||
|
||||
it('can receive file postData from a form', async () => {
|
||||
protocol.handle('test-scheme', (req) => new Response(req.body));
|
||||
defer(() => { protocol.unhandle('test-scheme'); });
|
||||
await contents.loadURL('data:text/html,<form action="test-scheme://foo" method=POST enctype="multipart/form-data"><input name=foo type=file>');
|
||||
const { debugger: dbg } = contents;
|
||||
dbg.attach();
|
||||
const { root } = await dbg.sendCommand('DOM.getDocument');
|
||||
const { nodeId: fileInputNodeId } = await dbg.sendCommand('DOM.querySelector', { nodeId: root.nodeId, selector: 'input' });
|
||||
await dbg.sendCommand('DOM.setFileInputFiles', {
|
||||
nodeId: fileInputNodeId,
|
||||
files: [
|
||||
path.join(fixturesPath, 'hello.txt')
|
||||
]
|
||||
});
|
||||
const navigated = once(contents, 'did-finish-load');
|
||||
await contents.executeJavaScript('document.querySelector("form").submit()');
|
||||
await navigated;
|
||||
expect(await contents.executeJavaScript('document.documentElement.textContent')).to.match(/------WebKitFormBoundary.*\nContent-Disposition: form-data; name="foo"; filename="hello.txt"\nContent-Type: text\/plain\n\nhello world\n\n------WebKitFormBoundary.*--\n/);
|
||||
});
|
||||
|
||||
it('can receive streaming fetch upload', async () => {
|
||||
protocol.handle('no-cors', (req) => new Response(req.body));
|
||||
defer(() => { protocol.unhandle('no-cors'); });
|
||||
await contents.loadURL('no-cors://foo');
|
||||
const fetchBodyResult = await contents.executeJavaScript(`
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
controller.enqueue('hello world');
|
||||
controller.close();
|
||||
},
|
||||
}).pipeThrough(new TextEncoderStream());
|
||||
fetch(location.href, {method: 'POST', body: stream, duplex: 'half'}).then(x => x.text())
|
||||
`);
|
||||
expect(fetchBodyResult).to.equal('hello world');
|
||||
});
|
||||
|
||||
it('can receive streaming fetch upload when a webRequest handler is present', async () => {
|
||||
session.defaultSession.webRequest.onBeforeRequest((details, cb) => {
|
||||
console.log('webRequest', details.url, details.method);
|
||||
cb({});
|
||||
});
|
||||
defer(() => {
|
||||
session.defaultSession.webRequest.onBeforeRequest(null);
|
||||
});
|
||||
|
||||
protocol.handle('no-cors', (req) => {
|
||||
console.log('handle', req.url, req.method);
|
||||
return new Response(req.body);
|
||||
});
|
||||
defer(() => { protocol.unhandle('no-cors'); });
|
||||
await contents.loadURL('no-cors://foo');
|
||||
const fetchBodyResult = await contents.executeJavaScript(`
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
controller.enqueue('hello world');
|
||||
controller.close();
|
||||
},
|
||||
}).pipeThrough(new TextEncoderStream());
|
||||
fetch(location.href, {method: 'POST', body: stream, duplex: 'half'}).then(x => x.text())
|
||||
`);
|
||||
expect(fetchBodyResult).to.equal('hello world');
|
||||
});
|
||||
|
||||
it('can receive an error from streaming fetch upload', async () => {
|
||||
protocol.handle('no-cors', (req) => new Response(req.body));
|
||||
defer(() => { protocol.unhandle('no-cors'); });
|
||||
await contents.loadURL('no-cors://foo');
|
||||
const fetchBodyResult = await contents.executeJavaScript(`
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
controller.error('test')
|
||||
},
|
||||
});
|
||||
fetch(location.href, {method: 'POST', body: stream, duplex: 'half'}).then(x => x.text()).catch(err => err)
|
||||
`);
|
||||
expect(fetchBodyResult).to.be.an.instanceOf(Error);
|
||||
});
|
||||
|
||||
it('gets an error from streaming fetch upload when the renderer dies', async () => {
|
||||
let gotRequest: Function;
|
||||
const receivedRequest = new Promise<Request>(resolve => { gotRequest = resolve; });
|
||||
protocol.handle('no-cors', (req) => {
|
||||
if (/fetch/.test(req.url)) gotRequest(req);
|
||||
return new Response();
|
||||
});
|
||||
defer(() => { protocol.unhandle('no-cors'); });
|
||||
await contents.loadURL('no-cors://foo');
|
||||
contents.executeJavaScript(`
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
window.controller = controller // no GC
|
||||
},
|
||||
});
|
||||
fetch(location.href + '/fetch', {method: 'POST', body: stream, duplex: 'half'}).then(x => x.text()).catch(err => err)
|
||||
`);
|
||||
const req = await receivedRequest;
|
||||
contents.destroy();
|
||||
// Undo .destroy() for the next test
|
||||
contents = (webContents as typeof ElectronInternal.WebContents).create({ sandbox: true });
|
||||
await expect(req.body!.getReader().read()).to.eventually.be.rejectedWith('net::ERR_FAILED');
|
||||
});
|
||||
|
||||
it('can bypass intercepeted protocol handlers', async () => {
|
||||
protocol.handle('http', () => new Response('custom'));
|
||||
defer(() => { protocol.unhandle('http'); });
|
||||
const server = http.createServer((req, res) => {
|
||||
res.end('default');
|
||||
});
|
||||
defer(() => server.close());
|
||||
const { url } = await listen(server);
|
||||
expect(await net.fetch(url, { bypassCustomProtocolHandlers: true }).then(r => r.text())).to.equal('default');
|
||||
});
|
||||
|
||||
it('bypassing custom protocol handlers also bypasses new protocols', async () => {
|
||||
protocol.handle('app', () => new Response('custom'));
|
||||
defer(() => { protocol.unhandle('app'); });
|
||||
await expect(net.fetch('app://foo', { bypassCustomProtocolHandlers: true })).to.be.rejectedWith('net::ERR_UNKNOWN_URL_SCHEME');
|
||||
});
|
||||
|
||||
it('can forward to the original handler', async () => {
|
||||
protocol.handle('http', (req) => net.fetch(req, { bypassCustomProtocolHandlers: true }));
|
||||
defer(() => { protocol.unhandle('http'); });
|
||||
const server = http.createServer((req, res) => {
|
||||
res.end('hello');
|
||||
server.close();
|
||||
});
|
||||
const { url } = await listen(server);
|
||||
await contents.loadURL(url);
|
||||
expect(await contents.executeJavaScript('document.documentElement.textContent')).to.equal('hello');
|
||||
});
|
||||
|
||||
it('supports sniffing mime type', async () => {
|
||||
protocol.handle('http', async (req) => {
|
||||
return net.fetch(req, { bypassCustomProtocolHandlers: true });
|
||||
});
|
||||
defer(() => { protocol.unhandle('http'); });
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
if (/html/.test(req.url ?? '')) { res.end('<!doctype html><body>hi'); } else { res.end('hi'); }
|
||||
});
|
||||
const { url } = await listen(server);
|
||||
defer(() => server.close());
|
||||
|
||||
{
|
||||
await contents.loadURL(url);
|
||||
const doc = await contents.executeJavaScript('document.documentElement.outerHTML');
|
||||
expect(doc).to.match(/white-space: pre-wrap/);
|
||||
}
|
||||
{
|
||||
await contents.loadURL(url + '?html');
|
||||
const doc = await contents.executeJavaScript('document.documentElement.outerHTML');
|
||||
expect(doc).to.equal('<html><head></head><body>hi</body></html>');
|
||||
}
|
||||
});
|
||||
|
||||
// TODO(nornagon): this test doesn't pass on Linux currently, investigate.
|
||||
ifit(process.platform !== 'linux')('is fast', async () => {
|
||||
// 128 MB of spaces.
|
||||
const chunk = new Uint8Array(128 * 1024 * 1024);
|
||||
chunk.fill(' '.charCodeAt(0));
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
// The sniffed mime type for the space-filled chunk will be
|
||||
// text/plain, which chews up all its performance in the renderer
|
||||
// trying to wrap lines. Setting content-type to text/html measures
|
||||
// something closer to just the raw cost of getting the bytes over
|
||||
// the wire.
|
||||
res.setHeader('content-type', 'text/html');
|
||||
res.end(chunk);
|
||||
});
|
||||
defer(() => server.close());
|
||||
const { url } = await listen(server);
|
||||
|
||||
const rawTime = await (async () => {
|
||||
await contents.loadURL(url); // warm
|
||||
const begin = Date.now();
|
||||
await contents.loadURL(url);
|
||||
const end = Date.now();
|
||||
return end - begin;
|
||||
})();
|
||||
|
||||
// Fetching through an intercepted handler should not be too much slower
|
||||
// than it would be if the protocol hadn't been intercepted.
|
||||
|
||||
protocol.handle('http', async (req) => {
|
||||
return net.fetch(req, { bypassCustomProtocolHandlers: true });
|
||||
});
|
||||
defer(() => { protocol.unhandle('http'); });
|
||||
|
||||
const interceptedTime = await (async () => {
|
||||
const begin = Date.now();
|
||||
await contents.loadURL(url);
|
||||
const end = Date.now();
|
||||
return end - begin;
|
||||
})();
|
||||
expect(interceptedTime).to.be.lessThan(rawTime * 1.5);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue