diff --git a/packager/react-packager/src/Server/MultipartResponse.js b/packager/react-packager/src/Server/MultipartResponse.js new file mode 100644 index 0000000000..417f5f99b4 --- /dev/null +++ b/packager/react-packager/src/Server/MultipartResponse.js @@ -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; diff --git a/packager/react-packager/src/Server/__tests__/MultipartResponse-test.js b/packager/react-packager/src/Server/__tests__/MultipartResponse-test.js new file mode 100644 index 0000000000..db751d74fa --- /dev/null +++ b/packager/react-packager/src/Server/__tests__/MultipartResponse-test.js @@ -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'); + } + }; +} + diff --git a/packager/react-packager/src/Server/__tests__/Server-test.js b/packager/react-packager/src/Server/__tests__/Server-test.js index 466144650b..4cb073972c 100644 --- a/packager/react-packager/src/Server/__tests__/Server-test.js +++ b/packager/react-packager/src/Server/__tests__/Server-test.js @@ -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, diff --git a/packager/react-packager/src/Server/index.js b/packager/react-packager/src/Server/index.js index c267ac697d..1b679e8139 100644 --- a/packager/react-packager/src/Server/index.js +++ b/packager/react-packager/src/Server/index.js @@ -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;