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(
|
reqHandler(
|
||||||
{ url: requrl, headers:{}, ...reqOptions },
|
{ url: requrl, headers:{}, ...reqOptions },
|
||||||
{
|
{
|
||||||
|
statusCode: 200,
|
||||||
headers: {},
|
headers: {},
|
||||||
getHeader(header) { return this.headers[header]; },
|
getHeader(header) { return this.headers[header]; },
|
||||||
setHeader(header, value) { this.headers[header] = value; },
|
setHeader(header, value) { this.headers[header] = value; },
|
||||||
|
writeHead(statusCode) { this.statusCode = statusCode; },
|
||||||
end(body) {
|
end(body) {
|
||||||
this.body = body;
|
this.body = body;
|
||||||
resolve(this);
|
resolve(this);
|
||||||
|
@ -157,6 +159,7 @@ describe('processRequest', () => {
|
||||||
sourceMapUrl: 'index.ios.includeRequire.map',
|
sourceMapUrl: 'index.ios.includeRequire.map',
|
||||||
dev: true,
|
dev: true,
|
||||||
platform: undefined,
|
platform: undefined,
|
||||||
|
onProgress: jasmine.any(Function),
|
||||||
runBeforeMainModule: ['InitializeJavaScriptAppEngine'],
|
runBeforeMainModule: ['InitializeJavaScriptAppEngine'],
|
||||||
unbundle: false,
|
unbundle: false,
|
||||||
entryModuleOnly: false,
|
entryModuleOnly: false,
|
||||||
|
@ -181,6 +184,7 @@ describe('processRequest', () => {
|
||||||
sourceMapUrl: 'index.map?platform=ios',
|
sourceMapUrl: 'index.map?platform=ios',
|
||||||
dev: true,
|
dev: true,
|
||||||
platform: 'ios',
|
platform: 'ios',
|
||||||
|
onProgress: jasmine.any(Function),
|
||||||
runBeforeMainModule: ['InitializeJavaScriptAppEngine'],
|
runBeforeMainModule: ['InitializeJavaScriptAppEngine'],
|
||||||
unbundle: false,
|
unbundle: false,
|
||||||
entryModuleOnly: false,
|
entryModuleOnly: false,
|
||||||
|
@ -205,6 +209,7 @@ describe('processRequest', () => {
|
||||||
sourceMapUrl: 'index.map?assetPlugin=assetPlugin1&assetPlugin=assetPlugin2',
|
sourceMapUrl: 'index.map?assetPlugin=assetPlugin1&assetPlugin=assetPlugin2',
|
||||||
dev: true,
|
dev: true,
|
||||||
platform: undefined,
|
platform: undefined,
|
||||||
|
onProgress: jasmine.any(Function),
|
||||||
runBeforeMainModule: ['InitializeJavaScriptAppEngine'],
|
runBeforeMainModule: ['InitializeJavaScriptAppEngine'],
|
||||||
unbundle: false,
|
unbundle: false,
|
||||||
entryModuleOnly: false,
|
entryModuleOnly: false,
|
||||||
|
|
|
@ -13,6 +13,7 @@ const AssetServer = require('../AssetServer');
|
||||||
const FileWatcher = require('../node-haste').FileWatcher;
|
const FileWatcher = require('../node-haste').FileWatcher;
|
||||||
const getPlatformExtension = require('../node-haste').getPlatformExtension;
|
const getPlatformExtension = require('../node-haste').getPlatformExtension;
|
||||||
const Bundler = require('../Bundler');
|
const Bundler = require('../Bundler');
|
||||||
|
const MultipartResponse = require('./MultipartResponse');
|
||||||
const ProgressBar = require('progress');
|
const ProgressBar = require('progress');
|
||||||
const Promise = require('promise');
|
const Promise = require('promise');
|
||||||
const SourceMapConsumer = require('source-map').SourceMapConsumer;
|
const SourceMapConsumer = require('source-map').SourceMapConsumer;
|
||||||
|
@ -657,6 +658,7 @@ class Server {
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let consoleProgress = () => {};
|
||||||
if (process.stdout.isTTY && !this._opts.silent) {
|
if (process.stdout.isTTY && !this._opts.silent) {
|
||||||
const bar = new ProgressBar('transformed :current/:total (:percent)', {
|
const bar = new ProgressBar('transformed :current/:total (:percent)', {
|
||||||
complete: '=',
|
complete: '=',
|
||||||
|
@ -664,8 +666,15 @@ class Server {
|
||||||
width: 40,
|
width: 40,
|
||||||
total: 1,
|
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');
|
debug('Getting bundle for request');
|
||||||
const building = this._useCachedOrUpdateOrCreateBundle(options);
|
const building = this._useCachedOrUpdateOrCreateBundle(options);
|
||||||
building.then(
|
building.then(
|
||||||
|
@ -678,15 +687,16 @@ class Server {
|
||||||
dev: options.dev,
|
dev: options.dev,
|
||||||
});
|
});
|
||||||
debug('Writing response headers');
|
debug('Writing response headers');
|
||||||
res.setHeader('Content-Type', 'application/javascript');
|
const etag = p.getEtag();
|
||||||
res.setHeader('ETag', p.getEtag());
|
mres.setHeader('Content-Type', 'application/javascript');
|
||||||
if (req.headers['if-none-match'] === res.getHeader('ETag')){
|
mres.setHeader('ETag', etag);
|
||||||
|
|
||||||
|
if (req.headers['if-none-match'] === etag) {
|
||||||
debug('Responding with 304');
|
debug('Responding with 304');
|
||||||
res.statusCode = 304;
|
mres.writeHead(304);
|
||||||
res.end();
|
mres.end();
|
||||||
} else {
|
} else {
|
||||||
debug('Writing request body');
|
mres.end(bundleSource);
|
||||||
res.end(bundleSource);
|
|
||||||
}
|
}
|
||||||
debug('Finished response');
|
debug('Finished response');
|
||||||
Activity.endEvent(startReqEventId);
|
Activity.endEvent(startReqEventId);
|
||||||
|
@ -700,17 +710,17 @@ class Server {
|
||||||
sourceMap = JSON.stringify(sourceMap);
|
sourceMap = JSON.stringify(sourceMap);
|
||||||
}
|
}
|
||||||
|
|
||||||
res.setHeader('Content-Type', 'application/json');
|
mres.setHeader('Content-Type', 'application/json');
|
||||||
res.end(sourceMap);
|
mres.end(sourceMap);
|
||||||
Activity.endEvent(startReqEventId);
|
Activity.endEvent(startReqEventId);
|
||||||
} else if (requestType === 'assets') {
|
} else if (requestType === 'assets') {
|
||||||
const assetsList = JSON.stringify(p.getAssets());
|
const assetsList = JSON.stringify(p.getAssets());
|
||||||
res.setHeader('Content-Type', 'application/json');
|
mres.setHeader('Content-Type', 'application/json');
|
||||||
res.end(assetsList);
|
mres.end(assetsList);
|
||||||
Activity.endEvent(startReqEventId);
|
Activity.endEvent(startReqEventId);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
error => this._handleError(res, this.optionsHash(options), error)
|
error => this._handleError(mres, this.optionsHash(options), error)
|
||||||
).catch(error => {
|
).catch(error => {
|
||||||
process.nextTick(() => {
|
process.nextTick(() => {
|
||||||
throw error;
|
throw error;
|
||||||
|
|
Loading…
Reference in New Issue