mirror of https://github.com/status-im/metro.git
Send progress events via multipart response
Summary: Context: I'm trying to add support for sending packager progress events to the client that is downloading the bundle over HTTP multipart response. The idea is for the client to send `Accept: multipart/mixed` header, and if present the server will stream progress events to the client. This will ensure the change is backwards-compatible - the clients who don't know about progress events won't receive them. In the future we can use this approach to download RAM bundle modules in one request. Reviewed By: davidaurelio Differential Revision: D3926984 fbshipit-source-id: 39a6e38e40a79f7a2f2cf40765a0655fb13b7918
This commit is contained in:
parent
d9797e2de7
commit
3d23012d06
|
@ -0,0 +1,84 @@
|
|||
/**
|
||||
* Copyright (c) 2015-present, Facebook, Inc.
|
||||
* All rights reserved.
|
||||
*
|
||||
* This source code is licensed under the BSD-style license found in the
|
||||
* LICENSE file in the root directory of this source tree. An additional grant
|
||||
* of patent rights can be found in the PATENTS file in the same directory.
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
const CRLF = '\r\n';
|
||||
const BOUNDARY = '3beqjf3apnqeu3h5jqorms4i';
|
||||
|
||||
class MultipartResponse {
|
||||
static wrap(req, res) {
|
||||
if (acceptsMultipartResponse(req)) {
|
||||
return new MultipartResponse(res);
|
||||
}
|
||||
// Ugly hack, ideally wrap function should always return a proxy
|
||||
// object with the same interface
|
||||
res.writeChunk = () => {}; // noop
|
||||
return res;
|
||||
}
|
||||
|
||||
constructor(res) {
|
||||
this.res = res;
|
||||
this.headers = {};
|
||||
|
||||
res.writeHead(200, {
|
||||
'Content-Type': `multipart/mixed; boundary="${BOUNDARY}"`,
|
||||
});
|
||||
res.write('If you are seeing this, your client does not support multipart response');
|
||||
}
|
||||
|
||||
writeChunk(headers, data, isLast = false) {
|
||||
let chunk = `${CRLF}--${BOUNDARY}${CRLF}`;
|
||||
if (headers) {
|
||||
chunk += MultipartResponse.serializeHeaders(headers) + CRLF + CRLF;
|
||||
}
|
||||
|
||||
if (data) {
|
||||
chunk += data;
|
||||
}
|
||||
|
||||
if (isLast) {
|
||||
chunk += `${CRLF}--${BOUNDARY}--${CRLF}`;
|
||||
}
|
||||
|
||||
this.res.write(chunk);
|
||||
}
|
||||
|
||||
writeHead(status, headers) {
|
||||
// We can't actually change the response HTTP status code
|
||||
// because the headers have already been sent
|
||||
this.setHeader('X-Http-Status', status);
|
||||
if (!headers) {
|
||||
return;
|
||||
}
|
||||
for (let key in headers) {
|
||||
this.setHeader(key, headers[key]);
|
||||
}
|
||||
}
|
||||
|
||||
setHeader(name, value) {
|
||||
this.headers[name] = value;
|
||||
}
|
||||
|
||||
end(data) {
|
||||
this.writeChunk(this.headers, data, true);
|
||||
this.res.end();
|
||||
}
|
||||
|
||||
static serializeHeaders(headers) {
|
||||
return Object.keys(headers)
|
||||
.map((key) => `${key}: ${headers[key]}`)
|
||||
.join(CRLF);
|
||||
}
|
||||
}
|
||||
|
||||
function acceptsMultipartResponse(req) {
|
||||
return req.headers && req.headers['accept'] === 'multipart/mixed';
|
||||
}
|
||||
|
||||
module.exports = MultipartResponse;
|
|
@ -0,0 +1,150 @@
|
|||
/**
|
||||
* Copyright (c) 2015-present, Facebook, Inc.
|
||||
* All rights reserved.
|
||||
*
|
||||
* This source code is licensed under the BSD-style license found in the
|
||||
* LICENSE file in the root directory of this source tree. An additional grant
|
||||
* of patent rights can be found in the PATENTS file in the same directory.
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
jest.dontMock('../MultipartResponse');
|
||||
|
||||
const MultipartResponse = require('../MultipartResponse');
|
||||
|
||||
describe('MultipartResponse', () => {
|
||||
it('forwards calls to response', () => {
|
||||
const nreq = mockNodeRequest({accept: 'text/html'});
|
||||
const nres = mockNodeResponse();
|
||||
const res = MultipartResponse.wrap(nreq, nres);
|
||||
|
||||
expect(res).toBe(nres);
|
||||
|
||||
res.writeChunk({}, 'foo');
|
||||
expect(nres.write).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('writes multipart response', () => {
|
||||
const nreq = mockNodeRequest({accept: 'multipart/mixed'});
|
||||
const nres = mockNodeResponse();
|
||||
const res = MultipartResponse.wrap(nreq, nres);
|
||||
|
||||
expect(res).not.toBe(nres);
|
||||
|
||||
res.setHeader('Result-Header-1', 1);
|
||||
res.writeChunk({foo: 'bar'}, 'first chunk');
|
||||
res.writeChunk({test: 2}, 'second chunk');
|
||||
res.writeChunk(null, 'empty headers third chunk');
|
||||
res.setHeader('Result-Header-2', 2);
|
||||
res.end('Hello, world!');
|
||||
|
||||
expect(nres.toString()).toEqual([
|
||||
'HTTP/1.1 200',
|
||||
'Content-Type: multipart/mixed; boundary="3beqjf3apnqeu3h5jqorms4i"',
|
||||
'',
|
||||
'If you are seeing this, your client does not support multipart response',
|
||||
'--3beqjf3apnqeu3h5jqorms4i',
|
||||
'foo: bar',
|
||||
'',
|
||||
'first chunk',
|
||||
'--3beqjf3apnqeu3h5jqorms4i',
|
||||
'test: 2',
|
||||
'',
|
||||
'second chunk',
|
||||
'--3beqjf3apnqeu3h5jqorms4i',
|
||||
'empty headers third chunk',
|
||||
'--3beqjf3apnqeu3h5jqorms4i',
|
||||
'Result-Header-1: 1',
|
||||
'Result-Header-2: 2',
|
||||
'',
|
||||
'Hello, world!',
|
||||
'--3beqjf3apnqeu3h5jqorms4i--',
|
||||
'',
|
||||
].join('\r\n'));
|
||||
});
|
||||
|
||||
it('sends status code as last chunk header', () => {
|
||||
const nreq = mockNodeRequest({accept: 'multipart/mixed'});
|
||||
const nres = mockNodeResponse();
|
||||
const res = MultipartResponse.wrap(nreq, nres);
|
||||
|
||||
res.writeChunk({foo: 'bar'}, 'first chunk');
|
||||
res.writeHead(500, {
|
||||
'Content-Type': 'application/json; boundary="3beqjf3apnqeu3h5jqorms4i"',
|
||||
});
|
||||
res.end('{}');
|
||||
|
||||
expect(nres.toString()).toEqual([
|
||||
'HTTP/1.1 200',
|
||||
'Content-Type: multipart/mixed; boundary="3beqjf3apnqeu3h5jqorms4i"',
|
||||
'',
|
||||
'If you are seeing this, your client does not support multipart response',
|
||||
'--3beqjf3apnqeu3h5jqorms4i',
|
||||
'foo: bar',
|
||||
'',
|
||||
'first chunk',
|
||||
'--3beqjf3apnqeu3h5jqorms4i',
|
||||
'X-Http-Status: 500',
|
||||
'Content-Type: application/json; boundary="3beqjf3apnqeu3h5jqorms4i"',
|
||||
'',
|
||||
'{}',
|
||||
'--3beqjf3apnqeu3h5jqorms4i--',
|
||||
'',
|
||||
].join('\r\n'));
|
||||
});
|
||||
|
||||
it('supports empty responses', () => {
|
||||
const nreq = mockNodeRequest({accept: 'multipart/mixed'});
|
||||
const nres = mockNodeResponse();
|
||||
const res = MultipartResponse.wrap(nreq, nres);
|
||||
|
||||
res.writeHead(304, {
|
||||
'Content-Type': 'application/json; boundary="3beqjf3apnqeu3h5jqorms4i"',
|
||||
});
|
||||
res.end();
|
||||
|
||||
expect(nres.toString()).toEqual([
|
||||
'HTTP/1.1 200',
|
||||
'Content-Type: multipart/mixed; boundary="3beqjf3apnqeu3h5jqorms4i"',
|
||||
'',
|
||||
'If you are seeing this, your client does not support multipart response',
|
||||
'--3beqjf3apnqeu3h5jqorms4i',
|
||||
'X-Http-Status: 304',
|
||||
'Content-Type: application/json; boundary="3beqjf3apnqeu3h5jqorms4i"',
|
||||
'',
|
||||
'',
|
||||
'--3beqjf3apnqeu3h5jqorms4i--',
|
||||
'',
|
||||
].join('\r\n'));
|
||||
});
|
||||
});
|
||||
|
||||
function mockNodeRequest(headers = {}) {
|
||||
return {headers};
|
||||
}
|
||||
|
||||
function mockNodeResponse() {
|
||||
let status = 200;
|
||||
let headers = {};
|
||||
let body = '';
|
||||
return {
|
||||
writeHead: jest.fn((st, hdrs) => {
|
||||
status = st;
|
||||
headers = {...headers, ...hdrs};
|
||||
}),
|
||||
setHeader: jest.fn((key, val) => { headers[key] = val; }),
|
||||
write: jest.fn((data) => { body += data; }),
|
||||
end: jest.fn((data) => { body += (data || ''); }),
|
||||
|
||||
// For testing only
|
||||
toString() {
|
||||
return [
|
||||
`HTTP/1.1 ${status}`,
|
||||
MultipartResponse.serializeHeaders(headers),
|
||||
'',
|
||||
body,
|
||||
].join('\r\n');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -47,9 +47,11 @@ describe('processRequest', () => {
|
|||
reqHandler(
|
||||
{ url: requrl, headers:{}, ...reqOptions },
|
||||
{
|
||||
statusCode: 200,
|
||||
headers: {},
|
||||
getHeader(header) { return this.headers[header]; },
|
||||
setHeader(header, value) { this.headers[header] = value; },
|
||||
writeHead(statusCode) { this.statusCode = statusCode; },
|
||||
end(body) {
|
||||
this.body = body;
|
||||
resolve(this);
|
||||
|
@ -157,6 +159,7 @@ describe('processRequest', () => {
|
|||
sourceMapUrl: 'index.ios.includeRequire.map',
|
||||
dev: true,
|
||||
platform: undefined,
|
||||
onProgress: jasmine.any(Function),
|
||||
runBeforeMainModule: ['InitializeJavaScriptAppEngine'],
|
||||
unbundle: false,
|
||||
entryModuleOnly: false,
|
||||
|
@ -181,6 +184,7 @@ describe('processRequest', () => {
|
|||
sourceMapUrl: 'index.map?platform=ios',
|
||||
dev: true,
|
||||
platform: 'ios',
|
||||
onProgress: jasmine.any(Function),
|
||||
runBeforeMainModule: ['InitializeJavaScriptAppEngine'],
|
||||
unbundle: false,
|
||||
entryModuleOnly: false,
|
||||
|
@ -205,6 +209,7 @@ describe('processRequest', () => {
|
|||
sourceMapUrl: 'index.map?assetPlugin=assetPlugin1&assetPlugin=assetPlugin2',
|
||||
dev: true,
|
||||
platform: undefined,
|
||||
onProgress: jasmine.any(Function),
|
||||
runBeforeMainModule: ['InitializeJavaScriptAppEngine'],
|
||||
unbundle: false,
|
||||
entryModuleOnly: false,
|
||||
|
|
|
@ -13,6 +13,7 @@ const AssetServer = require('../AssetServer');
|
|||
const FileWatcher = require('../node-haste').FileWatcher;
|
||||
const getPlatformExtension = require('../node-haste').getPlatformExtension;
|
||||
const Bundler = require('../Bundler');
|
||||
const MultipartResponse = require('./MultipartResponse');
|
||||
const ProgressBar = require('progress');
|
||||
const Promise = require('promise');
|
||||
const SourceMapConsumer = require('source-map').SourceMapConsumer;
|
||||
|
@ -657,6 +658,7 @@ class Server {
|
|||
},
|
||||
);
|
||||
|
||||
let consoleProgress = () => {};
|
||||
if (process.stdout.isTTY && !this._opts.silent) {
|
||||
const bar = new ProgressBar('transformed :current/:total (:percent)', {
|
||||
complete: '=',
|
||||
|
@ -664,8 +666,15 @@ class Server {
|
|||
width: 40,
|
||||
total: 1,
|
||||
});
|
||||
options.onProgress = debouncedTick(bar);
|
||||
consoleProgress = debouncedTick(bar);
|
||||
}
|
||||
|
||||
const mres = MultipartResponse.wrap(req, res);
|
||||
options.onProgress = (done, total) => {
|
||||
consoleProgress(done, total);
|
||||
mres.writeChunk({'Content-Type': 'application/json'}, JSON.stringify({done, total}));
|
||||
};
|
||||
|
||||
debug('Getting bundle for request');
|
||||
const building = this._useCachedOrUpdateOrCreateBundle(options);
|
||||
building.then(
|
||||
|
@ -678,15 +687,16 @@ class Server {
|
|||
dev: options.dev,
|
||||
});
|
||||
debug('Writing response headers');
|
||||
res.setHeader('Content-Type', 'application/javascript');
|
||||
res.setHeader('ETag', p.getEtag());
|
||||
if (req.headers['if-none-match'] === res.getHeader('ETag')){
|
||||
const etag = p.getEtag();
|
||||
mres.setHeader('Content-Type', 'application/javascript');
|
||||
mres.setHeader('ETag', etag);
|
||||
|
||||
if (req.headers['if-none-match'] === etag) {
|
||||
debug('Responding with 304');
|
||||
res.statusCode = 304;
|
||||
res.end();
|
||||
mres.writeHead(304);
|
||||
mres.end();
|
||||
} else {
|
||||
debug('Writing request body');
|
||||
res.end(bundleSource);
|
||||
mres.end(bundleSource);
|
||||
}
|
||||
debug('Finished response');
|
||||
Activity.endEvent(startReqEventId);
|
||||
|
@ -700,17 +710,17 @@ class Server {
|
|||
sourceMap = JSON.stringify(sourceMap);
|
||||
}
|
||||
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(sourceMap);
|
||||
mres.setHeader('Content-Type', 'application/json');
|
||||
mres.end(sourceMap);
|
||||
Activity.endEvent(startReqEventId);
|
||||
} else if (requestType === 'assets') {
|
||||
const assetsList = JSON.stringify(p.getAssets());
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(assetsList);
|
||||
mres.setHeader('Content-Type', 'application/json');
|
||||
mres.end(assetsList);
|
||||
Activity.endEvent(startReqEventId);
|
||||
}
|
||||
},
|
||||
error => this._handleError(res, this.optionsHash(options), error)
|
||||
error => this._handleError(mres, this.optionsHash(options), error)
|
||||
).catch(error => {
|
||||
process.nextTick(() => {
|
||||
throw error;
|
||||
|
|
Loading…
Reference in New Issue