diff --git a/packages/metro/src/lib/bundle-modules/DeltaClient/__tests__/bundleCache-test.js b/packages/metro/src/lib/bundle-modules/DeltaClient/__tests__/bundleCache-test.js new file mode 100644 index 0000000000..a87dd13011 --- /dev/null +++ b/packages/metro/src/lib/bundle-modules/DeltaClient/__tests__/bundleCache-test.js @@ -0,0 +1,100 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @emails oncall+js_foundation + * @format + */ + +/* eslint-env worker */ + +'use strict'; + +const stringToBundle = require('../stringToBundle'); + +const {getBundle, setBundle} = require('../bundleCache'); +const {Request, Response, Headers} = require('node-fetch'); +const {URL} = require('url'); + +jest.mock('../stringToBundle'); + +describe('bundleCache', () => { + let putMock; + let matchMock; + beforeEach(() => { + global.fetch = jest.fn(); + putMock = jest.fn(); + matchMock = jest.fn(); + + global.URL = URL; + global.Response = Response; + global.Request = Request; + global.Headers = Headers; + global.caches = { + open: jest.fn().mockResolvedValue({ + put: putMock, + match: matchMock, + }), + }; + }); + + describe('getBundle', () => { + it('retrieves a bundle from the bundle cache', async () => { + const bundle = { + base: true, + revisionId: 'revId', + pre: 'pre', + post: 'post', + modules: [[0, '0'], [100, '100']], + }; + const bundleReq = new Request('http://localhost/bundles/cool-bundle'); + matchMock.mockResolvedValue(new Response(JSON.stringify(bundle))); + expect(await getBundle(bundleReq)).toEqual(bundle); + expect(fetch).not.toHaveBeenCalled(); + expect(matchMock).toHaveBeenCalledWith(bundleReq); + }); + + it('retrieves a bundle from the browser cache', async () => { + const stringBundle = 'stringBundle'; + const bundle = { + base: true, + revisionId: 'revId', + pre: 'pre', + post: 'post', + modules: [[0, '0'], [100, '100']], + }; + const bundleReq = new Request('http://localhost/bundles/cool-bundle'); + matchMock.mockResolvedValue(null); + fetch.mockResolvedValue(new Response(stringBundle)); + stringToBundle.mockReturnValue(bundle); + expect(await getBundle(bundleReq)).toEqual(bundle); + expect(fetch).toHaveBeenCalledWith(bundleReq, {cache: 'force-cache'}); + expect(stringToBundle).toHaveBeenCalledWith(stringBundle); + }); + + it('returns null when a bundle cannot be found', async () => { + matchMock.mockResolvedValue(null); + fetch.mockResolvedValue(null); + expect(await getBundle({})).toEqual(null); + }); + }); + + describe('setBundle', () => { + it('stores a bundle in the bundle cache', async () => { + const bundle = { + base: true, + revisionId: 'revId', + pre: 'pre', + post: 'post', + modules: [[0, '0'], [100, '100']], + }; + const bundleReq = new Request('http://localhost/bundles/cool-bundle'); + await setBundle(bundleReq, bundle); + const putCall = putMock.mock.calls[0]; + expect(putCall[0]).toBe(bundleReq); + expect(await putCall[1].json()).toEqual(bundle); + }); + }); +}); diff --git a/packages/metro/src/lib/bundle-modules/DeltaClient/__tests__/bundleToString-test.js b/packages/metro/src/lib/bundle-modules/DeltaClient/__tests__/bundleToString-test.js new file mode 100644 index 0000000000..1f925c6200 --- /dev/null +++ b/packages/metro/src/lib/bundle-modules/DeltaClient/__tests__/bundleToString-test.js @@ -0,0 +1,50 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @emails oncall+js_foundation + * @format + */ + +'use strict'; + +const bundleToString = require('../bundleToString'); + +describe('bundleToString', () => { + it('serializes a bundle into a plain JS bundle', () => { + expect( + bundleToString({ + base: true, + revisionId: 'revisionId', + pre: 'console.log("Hello World!");', + post: 'console.log("That\'s all folks!");', + modules: [[0, 'console.log("Best module.");']], + }), + ).toMatchInlineSnapshot(` +"console.log(\\"Hello World!\\"); +console.log(\\"Best module.\\"); +console.log(\\"That's all folks!\\");" +`); + }); + + it('modules are sorted by id', () => { + expect( + bundleToString({ + base: true, + revisionId: 'revisionId', + pre: 'console.log("Hello World!");', + post: 'console.log("That\'s all folks!");', + modules: [[3, '3'], [0, '0'], [2, '2'], [1, '1']], + }), + ).toMatchInlineSnapshot(` +"console.log(\\"Hello World!\\"); +0 +1 +2 +3 +console.log(\\"That's all folks!\\");" +`); + }); +}); diff --git a/packages/metro/src/lib/bundle-modules/DeltaClient/__tests__/createDeltaClient-test.js b/packages/metro/src/lib/bundle-modules/DeltaClient/__tests__/createDeltaClient-test.js new file mode 100644 index 0000000000..d111612d03 --- /dev/null +++ b/packages/metro/src/lib/bundle-modules/DeltaClient/__tests__/createDeltaClient-test.js @@ -0,0 +1,391 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @emails oncall+js_foundation + * @format + */ + +'use strict'; + +const WebSocketHMRClient = require('../../WebSocketHMRClient'); + +const createDeltaClient = require('../createDeltaClient'); + +const {getBundle, setBundle} = require('../bundleCache'); +const {Request, Response, Headers} = require('node-fetch'); +const {URL} = require('url'); + +jest.mock('../bundleCache'); +jest.mock('../../WebSocketHMRClient'); + +function createBundle(revisionId, modules = []) { + return { + base: true, + revisionId, + pre: `pre(${JSON.stringify(revisionId)});`, + post: `post(${JSON.stringify(revisionId)});`, + modules: modules.map(id => [id, `__d(${id.toString()});`]), + }; +} + +function createDelta(revisionId, modules = [], deleted = []) { + return { + base: false, + revisionId, + modules: modules.map(id => [id, `__d(${id.toString()});`]), + deleted, + }; +} + +describe('createDeltaClient', () => { + let fetch; + beforeEach(() => { + global.__DEV__ = true; + fetch = global.fetch = jest.fn(); + global.URL = URL; + global.Response = Response; + global.Request = Request; + global.Headers = Headers; + }); + + it('retrieves a bundle from cache and patches it with a delta bundle', async () => { + const bundle = createBundle('0', [0]); + const delta = createDelta('1', [1], [0]); + getBundle.mockResolvedValue(bundle); + fetch.mockResolvedValue(new Response(JSON.stringify(delta))); + const deltaClient = createDeltaClient(); + + const bundleReq = new Request('http://localhost/bundles/cool.bundle'); + const res = await deltaClient({ + clientId: 'clientId', + request: bundleReq, + }); + + expect(fetch).toHaveBeenCalledWith( + 'http://localhost/bundles/cool.delta?revisionId=0', + { + includeCredentials: true, + }, + ); + expect(await res.text()).toMatchInlineSnapshot(` +"pre(\\"0\\"); +__d(1); +post(\\"0\\");" +`); + }); + + it('supports a custom getDeltaBundle function', async () => { + const bundle = createBundle('rev0', [0]); + const delta = createDelta('rev2', [2], [0]); + getBundle.mockResolvedValue(bundle); + const getDeltaBundle = jest.fn().mockResolvedValue(delta); + const deltaClient = createDeltaClient({getDeltaBundle}); + + const bundleReq = new Request('http://localhost/bundles/cool.bundle'); + const res = await deltaClient({ + clientId: 'clientId', + request: bundleReq, + }); + + expect(getDeltaBundle).toHaveBeenCalledWith(bundleReq, 'rev0'); + expect(await res.text()).toMatchInlineSnapshot(` +"pre(\\"rev0\\"); +__d(2); +post(\\"rev0\\");" +`); + }); + + it('retrieves a bundle from cache and patches it with a new bundle', async () => { + const bundle = createBundle('rev0', [0]); + const newBundle = createBundle('rev1', [1]); + getBundle.mockResolvedValue(bundle); + fetch.mockResolvedValue(new Response(JSON.stringify(newBundle))); + const deltaClient = createDeltaClient(); + + const bundleReq = new Request('http://localhost/bundles/cool.bundle'); + const res = await deltaClient({ + clientId: 'clientId', + request: bundleReq, + }); + + expect(getBundle).toHaveBeenCalledWith(bundleReq); + expect(await res.text()).toMatchInlineSnapshot(` +"pre(\\"rev1\\"); +__d(1); +post(\\"rev1\\");" +`); + }); + + it('fetches the original bundle if an error is thrown while fetching a delta bundle', async () => { + const bundle = createBundle('rev0', [0]); + getBundle.mockResolvedValue(bundle); + fetch.mockRejectedValueOnce(new Error('Fetch error')).mockResolvedValueOnce( + new Response(`pre1 +1 +post1 +//# offsetTable={"pre": 4,"post":5,"modules":[[1,1]],"revisionId":"rev1"}`), + ); + const deltaClient = createDeltaClient(); + + const bundleReq = new Request('http://localhost/bundles/cool.bundle'); + const res = await deltaClient({ + clientId: 'clientId', + request: bundleReq, + }); + + expect(fetch).toHaveBeenCalledWith(bundleReq, {includeCredentials: true}); + expect(await res.text()).toMatchInlineSnapshot(` +"pre1 +1 +post1" +`); + }); + + it('fetches the original bundle if a previous bundle cannot be found in cache', async () => { + getBundle.mockResolvedValue(null); + fetch.mockResolvedValueOnce( + new Response(`pre +0 +post +//# offsetTable={"pre": 3,"post":4,"modules":[[0,1]],"revisionId":"rev0"}`), + ); + const deltaClient = createDeltaClient(); + + const bundleReq = new Request('http://localhost/bundles/cool.bundle'); + const res = await deltaClient({ + clientId: 'clientId', + request: bundleReq, + }); + + expect(fetch).toHaveBeenCalledWith(bundleReq, {includeCredentials: true}); + expect(await res.text()).toMatchInlineSnapshot(` +"pre +0 +post" +`); + }); + + it('sets the patched bundle in cache', async () => { + const bundle = createBundle('rev0', [0]); + const delta = createDelta('rev1', [1], [0]); + getBundle.mockResolvedValue(bundle); + fetch.mockResolvedValue(new Response(JSON.stringify(delta))); + const deltaClient = createDeltaClient(); + + const bundleReq = new Request('http://localhost/bundles/cool.bundle'); + await deltaClient({ + clientId: 'clientId', + request: bundleReq, + }); + + expect(setBundle).toHaveBeenCalledWith(bundleReq, { + base: true, + revisionId: 'rev1', + pre: 'pre("rev0");', + post: 'post("rev0");', + modules: [[1, '__d(1);']], + }); + }); + + describe('HMR', () => { + beforeEach(() => { + const bundle = createBundle('rev0', [0]); + const delta = createDelta('rev1', [1], [0]); + getBundle.mockResolvedValue(bundle); + fetch.mockResolvedValue(new Response(JSON.stringify(delta))); + WebSocketHMRClient.mockClear(); + }); + + it('sets up the HMR client', async () => { + const deltaClient = createDeltaClient({hot: true}); + + await deltaClient({ + clientId: 'client0', + request: new Request('http://localhost/bundles/cool.bundle'), + }); + + expect(WebSocketHMRClient).toHaveBeenCalledWith( + 'ws://localhost/hot?revisionId=rev1', + ); + }); + + it('sets up the HMR client (HTTPS)', async () => { + const deltaClient = createDeltaClient({hot: true}); + + await deltaClient({ + clientId: 'client0', + request: new Request('https://localhost/bundles/cool.bundle'), + }); + + expect(WebSocketHMRClient).toHaveBeenCalledWith( + 'wss://localhost/hot?revisionId=rev1', + ); + }); + + it('accepts a custom getHmrServerUrl function', async () => { + const getHmrServerUrl = jest.fn().mockReturnValue('ws://whatever'); + const deltaClient = createDeltaClient({hot: true, getHmrServerUrl}); + + const bundleReq = new Request('https://localhost/bundles/cool.bundle'); + await deltaClient({ + clientId: 'client0', + request: bundleReq, + }); + + expect(getHmrServerUrl).toHaveBeenCalledWith(bundleReq, 'rev1'); + expect(WebSocketHMRClient).toHaveBeenCalledWith('ws://whatever'); + }); + + it('sends an HMR update to clients', async () => { + const clientMock = { + postMessage: jest.fn(), + }; + global.clients = { + get: jest.fn().mockResolvedValue(clientMock), + }; + const deltaClient = createDeltaClient({hot: true}); + + await deltaClient({ + clientId: 'client0', + request: new Request('https://localhost/bundles/cool.bundle'), + }); + const hmrUpdate = { + revisionId: 'rev2', + modules: [[0, '0.1']], + deleted: [], + sourceMappingURLs: [], + sourceURLs: [], + }; + const updateHandlers = WebSocketHMRClient.mock.instances[0].on.mock.calls.filter( + call => call[0] === 'update', + ); + updateHandlers.forEach(updateHandler => updateHandler[1](hmrUpdate)); + expect(global.clients.get).toHaveBeenCalledWith('client0'); + + // The default update function is asynchronous. + await new Promise(resolve => setImmediate(resolve)); + expect(clientMock.postMessage).toHaveBeenCalledWith({ + type: 'HMR_UPDATE', + body: hmrUpdate, + }); + }); + + it('patches the cached bundle on update', async () => { + const deltaClient = createDeltaClient({hot: true}); + + const bundleReq = new Request('https://localhost/bundles/cool.bundle'); + await deltaClient({ + clientId: 'client0', + request: bundleReq, + }); + const hmrUpdate = { + revisionId: 'rev2', + modules: [[1, '0.1']], + deleted: [0], + sourceMappingURLs: [], + sourceURLs: [], + }; + const updateHandlers = WebSocketHMRClient.mock.instances[0].on.mock.calls.filter( + call => call[0] === 'update', + ); + updateHandlers.forEach(updateHandler => updateHandler[1](hmrUpdate)); + expect(setBundle).toHaveBeenCalledWith(bundleReq, { + base: true, + revisionId: 'rev2', + pre: 'pre("rev0");', + post: 'post("rev0");', + modules: [[1, '0.1']], + }); + }); + + it('accepts a custom onUpdate function', async () => { + const onUpdate = jest.fn(); + const deltaClient = createDeltaClient({hot: true, onUpdate}); + + await deltaClient({ + clientId: 'client0', + request: new Request('https://localhost/bundles/cool.bundle'), + }); + const hmrUpdate = { + revisionId: 'rev2', + modules: [[0, '0.1']], + deleted: [], + sourceMappingURLs: [], + sourceURLs: [], + }; + const updateHandlers = WebSocketHMRClient.mock.instances[0].on.mock.calls.filter( + call => call[0] === 'update', + ); + updateHandlers.forEach(updateHandler => updateHandler[1](hmrUpdate)); + + expect(onUpdate).toHaveBeenCalledWith('client0', hmrUpdate); + }); + + it('only connects once for a given revisionId', async () => { + const bundle = createBundle('rev0', [0]); + const delta = createDelta('rev0', [], []); + getBundle.mockResolvedValue(bundle); + fetch.mockResolvedValue(new Response(JSON.stringify(delta))); + const onUpdate = jest.fn(); + const deltaClient = createDeltaClient({hot: true, onUpdate}); + + await deltaClient({ + clientId: 'client0', + request: new Request('https://localhost/bundles/cool.bundle'), + }); + fetch.mockResolvedValue(new Response(JSON.stringify(delta))); + await deltaClient({ + clientId: 'client1', + request: new Request('https://localhost/bundles/cool.bundle'), + }); + + const hmrUpdate = { + revisionId: 'rev2', + modules: [[0, '0.1']], + deleted: [], + sourceMappingURLs: [], + sourceURLs: [], + }; + + expect(WebSocketHMRClient).toHaveBeenCalledTimes(1); + const updateHandlers = WebSocketHMRClient.mock.instances[0].on.mock.calls.filter( + call => call[0] === 'update', + ); + updateHandlers.forEach(updateHandler => updateHandler[1](hmrUpdate)); + + expect(onUpdate).toHaveBeenCalledWith('client0', hmrUpdate); + expect(onUpdate).toHaveBeenCalledWith('client1', hmrUpdate); + }); + + it('reconnects when a new request comes in', async () => { + const bundle = createBundle('rev0', [0]); + const delta = createDelta('rev0', [], []); + getBundle.mockResolvedValue(bundle); + fetch.mockResolvedValue(new Response(JSON.stringify(delta))); + const onUpdate = jest.fn(); + const deltaClient = createDeltaClient({hot: true, onUpdate}); + + await deltaClient({ + clientId: 'client0', + request: new Request('https://localhost/bundles/cool.bundle'), + }); + + expect(WebSocketHMRClient).toHaveBeenCalledTimes(1); + const closeHandlers = WebSocketHMRClient.mock.instances[0].on.mock.calls.filter( + call => call[0] === 'close', + ); + closeHandlers.forEach(handler => handler[1]()); + + fetch.mockResolvedValue(new Response(JSON.stringify(delta))); + await deltaClient({ + clientId: 'client1', + request: new Request('https://localhost/bundles/cool.bundle'), + }); + + expect(WebSocketHMRClient).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/packages/metro/src/lib/bundle-modules/DeltaClient/__tests__/patchBundle-test.js b/packages/metro/src/lib/bundle-modules/DeltaClient/__tests__/patchBundle-test.js new file mode 100644 index 0000000000..877deeedc3 --- /dev/null +++ b/packages/metro/src/lib/bundle-modules/DeltaClient/__tests__/patchBundle-test.js @@ -0,0 +1,68 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @emails oncall+js_foundation + * @format + */ + +'use strict'; + +const patchBundle = require('../patchBundle'); + +describe('patchBundle', () => { + it('patches a bundle with a delta bundle', () => { + expect( + patchBundle( + { + base: true, + revisionId: 'rev0', + pre: 'pre', + post: 'post', + modules: [[0, '0'], [1, '1']], + }, + { + base: false, + revisionId: 'rev1', + modules: [[0, '0.1']], + deleted: [1], + }, + ), + ).toEqual({ + base: true, + revisionId: 'rev1', + pre: 'pre', + post: 'post', + modules: [[0, '0.1']], + }); + }); + + it('replaces a bundle with another bundle', () => { + expect( + patchBundle( + { + base: true, + revisionId: 'rev0', + pre: 'pre1', + post: 'post1', + modules: [[0, '0'], [1, '1']], + }, + { + base: true, + revisionId: 'rev1', + pre: 'pre2', + post: 'post2', + modules: [[2, '2']], + }, + ), + ).toEqual({ + base: true, + revisionId: 'rev1', + pre: 'pre2', + post: 'post2', + modules: [[2, '2']], + }); + }); +}); diff --git a/packages/metro/src/lib/bundle-modules/DeltaClient/__tests__/stringToBundle-test.js b/packages/metro/src/lib/bundle-modules/DeltaClient/__tests__/stringToBundle-test.js new file mode 100644 index 0000000000..37faec8753 --- /dev/null +++ b/packages/metro/src/lib/bundle-modules/DeltaClient/__tests__/stringToBundle-test.js @@ -0,0 +1,42 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @emails oncall+js_foundation + * @format + */ + +'use strict'; + +const stringToBundle = require('../stringToBundle'); + +describe('stringToBundle', () => { + it('parses a bundle from a string', () => { + expect( + stringToBundle(`pre +0 +1.0 +post +//# offsetTable={"pre":3,"post":4,"modules":[[0,1],[100,3]],"revisionId":"rev0"}`), + ).toEqual({ + base: true, + revisionId: 'rev0', + pre: 'pre', + post: 'post', + modules: [[0, '0'], [100, '1.0']], + }); + }); + + it('throws an error when the string bundle does not contain a pragma', () => { + expect(() => + stringToBundle(`pre +0 +1.0 +post`), + ).toThrowErrorMatchingInlineSnapshot( + '"stringToBundle: Pragma not found in string bundle."', + ); + }); +}); diff --git a/packages/metro/src/lib/bundle-modules/DeltaClient/bundleCache.js b/packages/metro/src/lib/bundle-modules/DeltaClient/bundleCache.js new file mode 100644 index 0000000000..b40b175aab --- /dev/null +++ b/packages/metro/src/lib/bundle-modules/DeltaClient/bundleCache.js @@ -0,0 +1,88 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +/* eslint-env worker, serviceworker */ + +'use strict'; + +const stringToBundle = require('./stringToBundle'); + +import type {Bundle} from '../types.flow'; + +const BUNDLE_CACHE_NAME = '__metroBundleCache'; + +async function getBundleFromBrowserCache(bundleReq: Request) { + const res = await fetch(bundleReq, { + // This forces using the browser cache, in which the initial bundle request + // will have been stored. + cache: 'force-cache', + }); + + if (!res) { + return null; + } + + return stringToBundle(await res.text()); +} + +async function getBundleFromCustomCache( + cache: Cache, + bundleReq: Request, +): Promise { + const res = await cache.match(bundleReq); + if (!res) { + return null; + } + return await res.json(); +} + +/** + * Retrieves a bundle from either the custom bundle cache or the browser cache. + */ +async function getBundle(bundleReq: Request): Promise { + const cache = await caches.open(BUNDLE_CACHE_NAME); + let deltaBundle = await getBundleFromCustomCache(cache, bundleReq); + + if (deltaBundle != null) { + return deltaBundle; + } + + deltaBundle = await getBundleFromBrowserCache(bundleReq); + + if (deltaBundle != null) { + return deltaBundle; + } + + return null; +} + +/** + * Stores a bundle in the custom bundle cache. + */ +async function setBundle(bundleReq: Request, bundle: Bundle) { + const bundleJson = JSON.stringify(bundle); + const bundleJsonRes = new Response(bundleJson, { + status: 200, + statusText: 'OK', + headers: new Headers({ + 'Content-Length': String(bundleJson.length), + 'Content-Type': 'application/json', + Date: new Date().toUTCString(), + }), + }); + + const cache = await caches.open(BUNDLE_CACHE_NAME); + + // Store the new initial bundle in cache. We don't need to wait for + // this operation to complete before returning a response. + await cache.put(bundleReq, bundleJsonRes); +} + +module.exports = {getBundle, setBundle}; diff --git a/packages/metro/src/lib/bundle-modules/DeltaClient/bundleToString.js b/packages/metro/src/lib/bundle-modules/DeltaClient/bundleToString.js new file mode 100644 index 0000000000..7c3041f3a2 --- /dev/null +++ b/packages/metro/src/lib/bundle-modules/DeltaClient/bundleToString.js @@ -0,0 +1,32 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +'use strict'; + +import type {Bundle} from '../types.flow'; + +/** + * Serializes a bundle into a plain JS bundle. + */ +function bundleToString(bundle: Bundle): string { + return [ + bundle.pre, + bundle.modules + .slice() + // The order of the modules needs to be deterministic in order for source + // maps to work properly. + .sort((a, b) => a[0] - b[0]) + .map(entry => entry[1]) + .join('\n'), + bundle.post, + ].join('\n'); +} + +module.exports = bundleToString; diff --git a/packages/metro/src/lib/bundle-modules/DeltaClient/createDeltaClient.js b/packages/metro/src/lib/bundle-modules/DeltaClient/createDeltaClient.js new file mode 100644 index 0000000000..a8baefc1af --- /dev/null +++ b/packages/metro/src/lib/bundle-modules/DeltaClient/createDeltaClient.js @@ -0,0 +1,180 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +/* eslint-env worker, serviceworker */ + +'use strict'; + +const WebSocketHMRClient = require('../WebSocketHMRClient'); + +const bundleCache = require('./bundleCache'); +const bundleToString = require('./bundleToString'); +const patchBundle = require('./patchBundle'); +const stringToBundle = require('./stringToBundle'); + +import type {Bundle, DeltaBundle, HmrUpdate} from '../types.flow'; + +declare var __DEV__: boolean; + +export type GetDeltaBundle = ( + bundleReq: Request, + revisionId: string, +) => Promise; + +export type GetHmrServerUrl = ( + bundleReq: Request, + revisionId: string, +) => string; + +export type DeltaClientOptions = {| + +hot?: boolean, + +getDeltaBundle?: GetDeltaBundle, + +getHmrServerUrl?: GetHmrServerUrl, + +onUpdate?: (clientId: string, update: HmrUpdate) => void, +|}; + +export type DeltaClient = (event: FetchEvent) => Promise; + +async function fetchBundle(bundleReq: Request): Promise { + const bundleRes = await fetch(bundleReq, { + includeCredentials: true, + }); + return stringToBundle(await bundleRes.text()); +} + +async function getOrFetchBundle( + bundleReq: Request, + getDeltaBundle: GetDeltaBundle, +): Promise { + let bundle = await bundleCache.getBundle(bundleReq); + + if (bundle == null) { + // We couldn't retrieve a delta bundle from either the delta cache nor the + // browser cache. This can happen when the browser cache is cleared but the + // service worker survives. In this case, we retrieve the original bundle. + bundle = await fetchBundle(bundleReq); + } else { + try { + const delta = await getDeltaBundle(bundleReq, bundle.revisionId); + bundle = patchBundle(bundle, delta); + } catch (error) { + console.error('[SW] Error retrieving delta bundle', error); + bundle = await fetchBundle(bundleReq); + } + } + + return bundle; +} + +function defaultGetHmrServerUrl( + bundleReq: Request, + revisionId: string, +): string { + const bundleUrl = new URL(bundleReq.url); + return `${bundleUrl.protocol === 'https:' ? 'wss' : 'ws'}://${ + bundleUrl.host + }/hot?revisionId=${revisionId}`; +} + +async function defaultGetDeltaBundle( + bundleReq: Request, + revisionId: string, +): Promise { + const url = new URL(bundleReq.url); + url.pathname = url.pathname.replace(/\.(bundle|js)$/, '.delta'); + url.searchParams.append('revisionId', revisionId); + const res = await fetch(url.href, { + includeCredentials: true, + }); + return await res.json(); +} + +function defaultOnUpdate(clientId: string, update: HmrUpdate) { + clients.get(clientId).then(client => { + if (client != null) { + client.postMessage({ + type: 'HMR_UPDATE', + body: update, + }); + } + }); +} + +function createDeltaClient({ + hot = false, + getHmrServerUrl = defaultGetHmrServerUrl, + getDeltaBundle = defaultGetDeltaBundle, + onUpdate = defaultOnUpdate, +}: DeltaClientOptions = {}): DeltaClient { + const updateHandlersMap = new Map(); + + return async (event: FetchEvent) => { + const clientId = event.clientId; + const bundleReq = event.request; + + let bundle = await getOrFetchBundle(bundleReq, getDeltaBundle); + + bundleCache.setBundle(bundleReq, bundle); + + if (__DEV__ && hot) { + const existingUpdateHandlers = updateHandlersMap.get(bundle.revisionId); + if (existingUpdateHandlers != null) { + existingUpdateHandlers.add(onUpdate.bind(null, clientId)); + } else { + const updateHandlers = new Set([onUpdate.bind(null, clientId)]); + updateHandlersMap.set(bundle.revisionId, updateHandlers); + + const hmrClient = new WebSocketHMRClient( + getHmrServerUrl(bundleReq, bundle.revisionId), + ); + + hmrClient.on('update', update => { + updateHandlersMap.delete(bundle.revisionId); + updateHandlersMap.set(update.revisionId, updateHandlers); + + for (const updateHandler of updateHandlers) { + updateHandler(update); + } + + bundle = patchBundle(bundle, { + base: false, + revisionId: update.revisionId, + modules: update.modules, + deleted: update.deleted, + }); + + bundleCache.setBundle(bundleReq, bundle); + }); + + hmrClient.on('close', () => { + updateHandlersMap.delete(bundle.revisionId); + }); + + hmrClient.enable(); + } + } + + const bundleString = bundleToString(bundle); + const bundleStringRes = new Response(bundleString, { + status: 200, + statusText: 'OK', + headers: new Headers({ + 'Cache-Control': 'no-cache', + 'Content-Length': String(bundleString.length), + 'Content-Type': 'application/javascript', + Date: new Date().toUTCString(), + }), + }); + + return bundleStringRes; + }; +} + +module.exports = createDeltaClient; diff --git a/packages/metro/src/lib/bundle-modules/DeltaClient/patchBundle.js b/packages/metro/src/lib/bundle-modules/DeltaClient/patchBundle.js new file mode 100644 index 0000000000..322032af7e --- /dev/null +++ b/packages/metro/src/lib/bundle-modules/DeltaClient/patchBundle.js @@ -0,0 +1,44 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +'use strict'; + +import type {Bundle, DeltaBundle} from '../types.flow'; + +/** + * Patches a bundle with a delta. + */ +function patchBundle(bundle: Bundle, delta: Bundle | DeltaBundle): Bundle { + if (delta.base) { + return delta; + } + + const map = new Map(bundle.modules); + + for (const [key, value] of delta.modules) { + map.set(key, value); + } + + for (const key of delta.deleted) { + map.delete(key); + } + + const modules = Array.from(map.entries()); + + return { + base: true, + revisionId: delta.revisionId, + pre: bundle.pre, + post: bundle.post, + modules, + }; +} + +module.exports = patchBundle; diff --git a/packages/metro/src/lib/bundle-modules/DeltaClient/stringToBundle.js b/packages/metro/src/lib/bundle-modules/DeltaClient/stringToBundle.js new file mode 100644 index 0000000000..738537b788 --- /dev/null +++ b/packages/metro/src/lib/bundle-modules/DeltaClient/stringToBundle.js @@ -0,0 +1,84 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +'use strict'; + +import type {Bundle, ModuleMap} from '../types.flow'; + +const PRAGMA = '//# offsetTable='; + +function sliceModules( + offsetTable: Array<[number, number]>, + str: string, + startOffset: number, +): [number, ModuleMap] { + const modules = []; + let offset = startOffset; + for (const [id, length] of offsetTable) { + modules.push([id, str.slice(offset, offset + length)]); + // Modules are separated by a line break. + offset += length + 1; + } + return [offset, modules]; +} + +/** + * Parses a bundle from an embedded delta bundle. + */ +function stringToBundle(str: string): Bundle { + // TODO(T34761233): This is a potential security risk! + // It is prone to failure or exploit if the pragma isn't present at + // the end of the bundle, since it will also match any string that + // contains it. + // + // The only way to be sure that the pragma is a comment is to + // implement a simple tokenizer, and making sure that our pragma is: + // * at the beginning of a line (whitespace notwithstanding) + // * not inside of a multiline comment (/* */); + // * not inside of a multiline string (`` or escaped ""). + // + // One way to avoid this would be to + // require the comment to be either at the very start or at the very + // end of the bundle. + const pragmaIndex = str.lastIndexOf(PRAGMA); + if (pragmaIndex === -1) { + throw new Error('stringToBundle: Pragma not found in string bundle.'); + } + + const tableStart = pragmaIndex + PRAGMA.length; + const tableEnd = str.indexOf('\n', tableStart); + + const offsetTable = JSON.parse( + str.slice(tableStart, tableEnd === -1 ? str.length : tableEnd), + ); + + const pre = str.slice(0, offsetTable.pre); + const [offset, modules] = sliceModules( + offsetTable.modules, + str, + // There's a line break after the pre segment. + offsetTable.pre + 1, + ); + // We technically don't need the bundle post segment length, since it should + // normally stop right before the pragma. + const post = str.slice(offset, offset + offsetTable.post); + + const bundle = { + base: true, + revisionId: offsetTable.revisionId, + pre, + post, + modules, + }; + + return bundle; +} + +module.exports = stringToBundle; diff --git a/packages/metro/src/lib/bundle-modules/deltaClientServiceWorker.js b/packages/metro/src/lib/bundle-modules/deltaClientServiceWorker.js new file mode 100644 index 0000000000..7b075dc0a5 --- /dev/null +++ b/packages/metro/src/lib/bundle-modules/deltaClientServiceWorker.js @@ -0,0 +1,28 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +/* eslint-env worker, serviceworker */ + +'use strict'; + +declare var __DEV__: boolean; + +const createDeltaClient = require('./DeltaClient/createDeltaClient'); + +const deltaClient = createDeltaClient({ + hot: __DEV__, +}); + +self.addEventListener('fetch', event => { + const reqUrl = new URL(event.request.url); + if (reqUrl.pathname.match(/\.bundle$/)) { + event.respondWith(deltaClient(event)); + } +}); diff --git a/packages/metro/src/lib/bundle-modules/registerServiceWorker.js b/packages/metro/src/lib/bundle-modules/registerServiceWorker.js new file mode 100644 index 0000000000..1c6ac39395 --- /dev/null +++ b/packages/metro/src/lib/bundle-modules/registerServiceWorker.js @@ -0,0 +1,70 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +/* eslint-env browser */ + +'use strict'; + +declare var __DEV__: boolean; + +const injectUpdate = require('./injectUpdate'); + +let info; +if (__DEV__) { + info = (...args) => { + // eslint-disable-next-line no-console + console.info(...args); + }; +} else { + info = (...args) => {}; +} + +function registerServiceWorker(swUrl: string) { + if ('serviceWorker' in navigator) { + const sw: ServiceWorkerContainer = (navigator.serviceWorker: $FlowIssue); + window.addEventListener('load', function() { + const registrationPromise = sw.register(swUrl); + + if (__DEV__) { + registrationPromise.then( + registration => { + info( + '[PAGE] ServiceWorker registration successful with scope: ', + registration.scope, + ); + }, + error => { + console.error('[PAGE] ServiceWorker registration failed: ', error); + }, + ); + } + + sw.addEventListener('message', event => { + const messageEvent: ServiceWorkerMessageEvent = (event: $FlowIssue); + switch (messageEvent.data.type) { + case 'HMR_UPDATE': { + if (__DEV__) { + info( + '[PAGE] Received HMR update from SW: ', + messageEvent.data.body, + ); + } + + injectUpdate(messageEvent.data.body); + } + } + }); + }); + } else if (__DEV__) { + info('[PAGE] ServiceWorker not supported'); + } +} + +module.exports = registerServiceWorker;