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:
Alex Kotliarskyi 2016-10-03 17:58:22 -07:00 committed by Facebook Github Bot
parent d9797e2de7
commit 3d23012d06
4 changed files with 262 additions and 13 deletions

View File

@ -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;

View File

@ -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');
}
};
}

View File

@ -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,

View File

@ -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;