/**
* 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.
*
* @format
* @flow
*/
'use strict';
const GraphNotFoundError = require('./IncrementalBundler/GraphNotFoundError');
const IncrementalBundler = require('./IncrementalBundler');
const RevisionNotFoundError = require('./IncrementalBundler/RevisionNotFoundError');
const debounceAsyncQueue = require('./lib/debounceAsyncQueue');
const formatBundlingError = require('./lib/formatBundlingError');
const getGraphId = require('./lib/getGraphId');
const hmrJSBundle = require('./DeltaBundler/Serializers/hmrJSBundle');
const nullthrows = require('nullthrows');
const parseOptionsFromUrl = require('./lib/parseOptionsFromUrl');
const splitBundleOptions = require('./lib/splitBundleOptions');
const transformHelpers = require('./lib/transformHelpers');
const url = require('url');
const {
Logger: {createActionStartEntry, createActionEndEntry, log},
} = require('metro-core');
import type {RevisionId} from './IncrementalBundler';
import type {
HmrMessage,
HmrUpdateMessage,
HmrErrorMessage,
} from './lib/bundle-modules/types.flow';
import type {ConfigT} from 'metro-config/src/configTypes.flow';
type Client = {|
+sendFn: string => void,
revisionId: RevisionId,
|};
type ClientGroup = {|
+clients: Set<Client>,
+unlisten: () => void,
revisionId: RevisionId,
|};
function send(sendFns: Array<(string) => void>, message: HmrMessage) {
const strMessage = JSON.stringify(message);
sendFns.forEach(sendFn => sendFn(strMessage));
}
/**
* The HmrServer (Hot Module Reloading) implements a lightweight interface
* to communicate easily to the logic in the React Native repository (which
* is the one that handles the Web Socket connections).
*
* This interface allows the HmrServer to hook its own logic to WS clients
* getting connected, disconnected or having errors (through the
* `onClientConnect`, `onClientDisconnect` and `onClientError` methods).
*/
class HmrServer<TClient: Client> {
_config: ConfigT;
_bundler: IncrementalBundler;
_createModuleId: (path: string) => number;
_clientGroups: Map<RevisionId, ClientGroup>;
constructor(
bundler: IncrementalBundler,
createModuleId: (path: string) => number,
config: ConfigT,
) {
this._config = config;
this._bundler = bundler;
this._createModuleId = createModuleId;
this._clientGroups = new Map();
}
async onClientConnect(
clientUrl: string,
sendFn: (data: string) => void,
): Promise<?Client> {
const urlObj = nullthrows(url.parse(clientUrl, true));
const query = nullthrows(urlObj.query);
let revPromise;
if (query.bundleEntry != null) {
// TODO(T34760695): Deprecate
urlObj.pathname = query.bundleEntry.replace(/\.js$/, '.bundle');
delete query.bundleEntry;
const {options} = parseOptionsFromUrl(
url.format(urlObj),
new Set(this._config.resolver.platforms),
);
const {entryFile, transformOptions} = splitBundleOptions(options);
/**
* `entryFile` is relative to projectRoot, we need to use resolution function
* to find the appropriate file with supported extensions.
*/
const resolutionFn = await transformHelpers.getResolveDependencyFn(
this._bundler.getBundler(),
transformOptions.platform,
);
const resolvedEntryFilePath = resolutionFn(
`${this._config.projectRoot}/.`,
entryFile,
);
const graphId = getGraphId(resolvedEntryFilePath, transformOptions);
revPromise = this._bundler.getRevisionByGraphId(graphId);
if (!revPromise) {
send([sendFn], {
type: 'error',
body: formatBundlingError(new GraphNotFoundError(graphId)),
});
return null;
}
} else {
const revisionId = query.revisionId;
revPromise = this._bundler.getRevision(revisionId);
if (!revPromise) {
send([sendFn], {
type: 'error',
body: formatBundlingError(new RevisionNotFoundError(revisionId)),
});
return null;
}
}
const {graph, id} = await revPromise;
const client = {
sendFn,
revisionId: id,
};
let clientGroup = this._clientGroups.get(id);
if (clientGroup != null) {
clientGroup.clients.add(client);
} else {
clientGroup = {
clients: new Set([client]),
unlisten: () => unlisten(),
revisionId: id,
};
this._clientGroups.set(id, clientGroup);
const unlisten = this._bundler
.getDeltaBundler()
.listen(
graph,
debounceAsyncQueue(
this._handleFileChange.bind(this, clientGroup),
50,
),
);
}
await this._handleFileChange(clientGroup);
return client;
}
onClientError(client: TClient, e: Error) {
this._config.reporter.update({
type: 'hmr_client_error',
error: e,
});
this.onClientDisconnect(client);
}
onClientDisconnect(client: TClient) {
const group = this._clientGroups.get(client.revisionId);
if (group != null) {
if (group.clients.size === 1) {
this._clientGroups.delete(client.revisionId);
group.unlisten();
} else {
group.clients.delete(client);
}
}
}
async _handleFileChange(group: ClientGroup) {
const processingHmrChange = log(
createActionStartEntry({action_name: 'Processing HMR change'}),
);
const sendFns = [...group.clients].map(client => client.sendFn);
send(sendFns, {type: 'update-start'});
const message = await this._prepareMessage(group);
send(sendFns, message);
send(sendFns, {type: 'update-done'});
log({
...createActionEndEntry(processingHmrChange),
outdated_modules:
message.type === 'update'
? message.body.added.length + message.body.modified.length
: undefined,
});
}
async _prepareMessage(
group: ClientGroup,
): Promise<HmrUpdateMessage | HmrErrorMessage> {
try {
const revPromise = this._bundler.getRevision(group.revisionId);
if (!revPromise) {
return {
type: 'error',
body: formatBundlingError(
new RevisionNotFoundError(group.revisionId),
),
};
}
const {revision, delta} = await this._bundler.updateGraph(
await revPromise,
false,
);
this._clientGroups.delete(group.revisionId);
group.revisionId = revision.id;
for (const client of group.clients) {
client.revisionId = revision.id;
}
this._clientGroups.set(group.revisionId, group);
const hmrUpdate = hmrJSBundle(delta, revision.graph, {
createModuleId: this._createModuleId,
projectRoot: this._config.projectRoot,
});
return {
type: 'update',
body: {
revisionId: revision.id,
...hmrUpdate,
},
};
} catch (error) {
const formattedError = formatBundlingError(error);
this._config.reporter.update({type: 'bundling_error', error});
return {type: 'error', body: formattedError};
}
}
}
module.exports = HmrServer;