diff --git a/.changeset/moody-cheetahs-lick.md b/.changeset/moody-cheetahs-lick.md new file mode 100644 index 000000000..4565575e0 --- /dev/null +++ b/.changeset/moody-cheetahs-lick.md @@ -0,0 +1,6 @@ +--- +"@callstack/repack-dev-server": minor +"@callstack/repack": minor +--- + +Reworked DevServer HMR pipeline - improved performance & recovery from errors diff --git a/apps/tester-app/ios/Podfile.lock b/apps/tester-app/ios/Podfile.lock index b2dd9dfcf..26afa91f7 100644 --- a/apps/tester-app/ios/Podfile.lock +++ b/apps/tester-app/ios/Podfile.lock @@ -1,6 +1,6 @@ PODS: - boost (1.84.0) - - callstack-repack (5.0.0-rc.10): + - callstack-repack (5.0.0-rc.11): - DoubleConversion - glog - hermes-engine @@ -2011,7 +2011,7 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: boost: 1dca942403ed9342f98334bf4c3621f011aa7946 - callstack-repack: 3ffe9c49dd09333ddb3cd0ac85ff6724dd6fa560 + callstack-repack: 028c13131834d77421120d3dc1de340f10899bcb DoubleConversion: f16ae600a246532c4020132d54af21d0ddb2a385 FBLazyVector: be7314029d6ec6b90f0f75ce1195b8130ed9ac4f fmt: 10c6e61f4be25dc963c36bd73fc7b1705fe975be diff --git a/apps/tester-federation-v2/ios/Podfile.lock b/apps/tester-federation-v2/ios/Podfile.lock index 254579deb..105f74ef6 100644 --- a/apps/tester-federation-v2/ios/Podfile.lock +++ b/apps/tester-federation-v2/ios/Podfile.lock @@ -1,6 +1,6 @@ PODS: - boost (1.84.0) - - callstack-repack (5.0.0-rc.10): + - callstack-repack (5.0.0-rc.11): - DoubleConversion - glog - hermes-engine @@ -1895,7 +1895,7 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: boost: 1dca942403ed9342f98334bf4c3621f011aa7946 - callstack-repack: 3ffe9c49dd09333ddb3cd0ac85ff6724dd6fa560 + callstack-repack: 028c13131834d77421120d3dc1de340f10899bcb DoubleConversion: f16ae600a246532c4020132d54af21d0ddb2a385 FBLazyVector: be7314029d6ec6b90f0f75ce1195b8130ed9ac4f fmt: 10c6e61f4be25dc963c36bd73fc7b1705fe975be diff --git a/apps/tester-federation/ios/Podfile.lock b/apps/tester-federation/ios/Podfile.lock index c97f90498..9b6d18e5d 100644 --- a/apps/tester-federation/ios/Podfile.lock +++ b/apps/tester-federation/ios/Podfile.lock @@ -1,6 +1,6 @@ PODS: - boost (1.84.0) - - callstack-repack (5.0.0-rc.10): + - callstack-repack (5.0.0-rc.11): - DoubleConversion - glog - hermes-engine @@ -1919,7 +1919,7 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: boost: 1dca942403ed9342f98334bf4c3621f011aa7946 - callstack-repack: 3ffe9c49dd09333ddb3cd0ac85ff6724dd6fa560 + callstack-repack: 028c13131834d77421120d3dc1de340f10899bcb DoubleConversion: f16ae600a246532c4020132d54af21d0ddb2a385 FBLazyVector: be7314029d6ec6b90f0f75ce1195b8130ed9ac4f fmt: 10c6e61f4be25dc963c36bd73fc7b1705fe975be diff --git a/packages/dev-server/src/createServer.ts b/packages/dev-server/src/createServer.ts index 35dd72ea3..61e7c63e8 100644 --- a/packages/dev-server/src/createServer.ts +++ b/packages/dev-server/src/createServer.ts @@ -57,8 +57,8 @@ export async function createServer(config: Server.Config) { platform, }); }, - broadcastToHmrClients: (event, platform, clientIds) => { - instance.wss.hmrServer.send(event, platform, clientIds); + broadcastToHmrClients: (event) => { + instance.wss.hmrServer.send(event); }, broadcastToMessageClients: ({ method, params }) => { instance.wss.messageServer.broadcast(method, params); diff --git a/packages/dev-server/src/plugins/compiler/compilerPlugin.ts b/packages/dev-server/src/plugins/compiler/compilerPlugin.ts index 98ad7ce48..a34fb091b 100644 --- a/packages/dev-server/src/plugins/compiler/compilerPlugin.ts +++ b/packages/dev-server/src/plugins/compiler/compilerPlugin.ts @@ -26,8 +26,8 @@ async function compilerPlugin( if (!filename) { // This technically should never happen - this route should not be called if file is missing. - request.log.error('File was not provided'); - return reply.notFound(); + request.log.debug('File was not provided'); + return reply.notFound('File was not provided'); } // Let consumer infer the platform. If function is not provided fallback @@ -42,6 +42,7 @@ async function compilerPlugin( JSON.stringify({ done: completed, total, + status: 'Bundling with Re.Pack', }) ); }; @@ -70,7 +71,7 @@ async function compilerPlugin( return reply.code(200).type(mimeType).send(asset); } } catch (error) { - request.log.error(error); + request.log.debug(error); return reply.notFound((error as Error).message); } }, diff --git a/packages/dev-server/src/plugins/favicon/faviconPlugin.ts b/packages/dev-server/src/plugins/favicon/faviconPlugin.ts index bd989abfc..58a299d8f 100644 --- a/packages/dev-server/src/plugins/favicon/faviconPlugin.ts +++ b/packages/dev-server/src/plugins/favicon/faviconPlugin.ts @@ -4,7 +4,6 @@ import type { FastifyInstance } from 'fastify'; import fastifyFavicon from 'fastify-favicon'; import fastifyPlugin from 'fastify-plugin'; -// @ts-ignore const dirname = path.dirname(fileURLToPath(import.meta.url)); const pathToImgDir = path.join(dirname, '../../../static'); diff --git a/packages/dev-server/src/plugins/wss/WebSocketServer.ts b/packages/dev-server/src/plugins/wss/WebSocketServer.ts index 912dd70ed..9866088b2 100644 --- a/packages/dev-server/src/plugins/wss/WebSocketServer.ts +++ b/packages/dev-server/src/plugins/wss/WebSocketServer.ts @@ -1,47 +1,63 @@ import type { IncomingMessage } from 'node:http'; import type { Socket } from 'node:net'; import type { FastifyInstance } from 'fastify'; -import { - type ServerOptions, - type WebSocket, - WebSocketServer as WebSocketServerImpl, -} from 'ws'; -import type { WebSocketServerInterface } from './types.js'; +import { type WebSocket, WebSocketServer as WebSocketServerImpl } from 'ws'; +import type { + WebSocketServerInterface, + WebSocketServerOptions, +} from './types.js'; /** * Abstract class for providing common logic (eg routing) for all WebSocket servers. * * @category Development server */ -export abstract class WebSocketServer implements WebSocketServerInterface { +export abstract class WebSocketServer + implements WebSocketServerInterface +{ /** An instance of the underlying WebSocket server. */ protected server: WebSocketServerImpl; /** Fastify instance from which {@link server} will receive upgrade connections. */ protected fastify: FastifyInstance; + protected name: string; + protected paths: string[]; + protected clients: Map; + protected nextClientId = 0; + + private timer: NodeJS.Timer | null = null; + /** * Create a new instance of the WebSocketServer. * Any logging information, will be passed through standard `fastify.log` API. * * @param fastify Fastify instance to which the WebSocket will be attached to. * @param path Path on which this WebSocketServer will be accepting connections. - * @param wssOptions WebSocket Server options. + * @param options WebSocketServer options. */ - constructor( - fastify: FastifyInstance, - path: string | string[], - wssOptions: Omit< - ServerOptions, - 'noServer' | 'server' | 'host' | 'port' | 'path' - > = {} - ) { + constructor(fastify: FastifyInstance, options: WebSocketServerOptions) { this.fastify = fastify; - this.server = new WebSocketServerImpl({ noServer: true, ...wssOptions }); + this.name = options.name; + this.paths = Array.isArray(options.path) ? options.path : [options.path]; + this.server = new WebSocketServerImpl({ noServer: true, ...options.wss }); this.server.on('connection', this.onConnection.bind(this)); - this.paths = Array.isArray(path) ? path : [path]; + + this.clients = new Map(); + + // setup heartbeat timer + this.timer = setInterval(() => { + this.clients.forEach((socket) => { + if (!socket.isAlive) { + socket.terminate(); + } else { + socket.isAlive = false; + socket.ping(() => {}); + } + }); + }, 30000); } shouldUpgrade(pathname: string) { @@ -54,11 +70,29 @@ export abstract class WebSocketServer implements WebSocketServerInterface { }); } - /** - * Process incoming WebSocket connection. - * - * @param socket Incoming WebSocket connection. - * @param request Upgrade request for the connection. - */ - abstract onConnection(socket: WebSocket, request: IncomingMessage): void; + onConnection(socket: T, _request: IncomingMessage): string { + const clientId = `client#${this.nextClientId++}`; + this.clients.set(clientId, socket); + this.fastify.log.debug({ msg: `${this.name} client connected`, clientId }); + + const errorHandler = () => { + this.fastify.log.debug({ + msg: `${this.name} client disconnected`, + clientId, + }); + socket.removeAllListeners(); // should we do this? + this.clients.delete(clientId); + }; + + socket.addEventListener('error', errorHandler); + socket.addEventListener('close', errorHandler); + + // heartbeat + socket.isAlive = true; + socket.on('pong', () => { + socket.isAlive = true; + }); + + return clientId; + } } diff --git a/packages/dev-server/src/plugins/wss/servers/WebSocketApiServer.ts b/packages/dev-server/src/plugins/wss/servers/WebSocketApiServer.ts index dd5155ca6..1b7484c29 100644 --- a/packages/dev-server/src/plugins/wss/servers/WebSocketApiServer.ts +++ b/packages/dev-server/src/plugins/wss/servers/WebSocketApiServer.ts @@ -1,5 +1,4 @@ import type { FastifyInstance } from 'fastify'; -import type WebSocket from 'ws'; import { WebSocketServer } from '../WebSocketServer.js'; /** @@ -9,9 +8,6 @@ import { WebSocketServer } from '../WebSocketServer.js'; * @category Development server */ export class WebSocketApiServer extends WebSocketServer { - private clients = new Map(); - private nextClientId = 0; - /** * Create new instance of WebSocketApiServer and attach it to the given Fastify instance. * Any logging information, will be passed through standard `fastify.log` API. @@ -19,7 +15,7 @@ export class WebSocketApiServer extends WebSocketServer { * @param fastify Fastify instance to attach the WebSocket server to. */ constructor(fastify: FastifyInstance) { - super(fastify, '/api'); + super(fastify, { name: 'API', path: '/api' }); } /** @@ -38,28 +34,4 @@ export class WebSocketApiServer extends WebSocketServer { } } } - - /** - * Process new WebSocket connection from client application. - * - * @param socket Incoming client's WebSocket connection. - */ - onConnection(socket: WebSocket) { - const clientId = `client#${this.nextClientId++}`; - this.clients.set(clientId, socket); - - this.fastify.log.debug({ msg: 'API client connected', clientId }); - this.clients.set(clientId, socket); - - const onClose = () => { - this.fastify.log.debug({ - msg: 'API client disconnected', - clientId, - }); - this.clients.delete(clientId); - }; - - socket.addEventListener('error', onClose); - socket.addEventListener('close', onClose); - } } diff --git a/packages/dev-server/src/plugins/wss/servers/WebSocketDevClientServer.ts b/packages/dev-server/src/plugins/wss/servers/WebSocketDevClientServer.ts index 54835ca78..8a48744a5 100644 --- a/packages/dev-server/src/plugins/wss/servers/WebSocketDevClientServer.ts +++ b/packages/dev-server/src/plugins/wss/servers/WebSocketDevClientServer.ts @@ -1,3 +1,4 @@ +import type { IncomingMessage } from 'node:http'; import type { FastifyInstance } from 'fastify'; import type WebSocket from 'ws'; import { WebSocketServer } from '../WebSocketServer.js'; @@ -9,9 +10,6 @@ import { WebSocketServer } from '../WebSocketServer.js'; * @category Development server */ export class WebSocketDevClientServer extends WebSocketServer { - private clients = new Map(); - private nextClientId = 0; - /** * Create new instance of WebSocketDevClientServer and attach it to the given Fastify instance. * Any logging information, will be passed through standard `fastify.log` API. @@ -19,7 +17,7 @@ export class WebSocketDevClientServer extends WebSocketServer { * @param fastify Fastify instance to attach the WebSocket server to. */ constructor(fastify: FastifyInstance) { - super(fastify, '/__client'); + super(fastify, { name: 'React Native', path: '/__client' }); } /** @@ -47,28 +45,13 @@ export class WebSocketDevClientServer extends WebSocketServer { } } - /** - * Process new WebSocket connection from client application. - * - * @param socket Incoming client's WebSocket connection. - */ - onConnection(socket: WebSocket) { - const clientId = `client#${this.nextClientId++}`; - this.clients.set(clientId, socket); - this.fastify.log.debug({ msg: 'React Native client connected', clientId }); - - const onClose = () => { - this.fastify.log.debug({ - msg: 'React Native client disconnected', - clientId, - }); - this.clients.delete(clientId); - }; + override onConnection(socket: WebSocket, request: IncomingMessage): string { + const clientId = super.onConnection(socket, request); - socket.addEventListener('error', onClose); - socket.addEventListener('close', onClose); socket.addEventListener('message', (event) => { this.processMessage(event.data.toString()); }); + + return clientId; } } diff --git a/packages/dev-server/src/plugins/wss/servers/WebSocketEventsServer.ts b/packages/dev-server/src/plugins/wss/servers/WebSocketEventsServer.ts index 286ae6368..8b4f8f75a 100644 --- a/packages/dev-server/src/plugins/wss/servers/WebSocketEventsServer.ts +++ b/packages/dev-server/src/plugins/wss/servers/WebSocketEventsServer.ts @@ -1,3 +1,4 @@ +import type { IncomingMessage } from 'node:http'; import type { FastifyInstance } from 'fastify'; import * as prettyFormat from 'pretty-format'; import type WebSocket from 'ws'; @@ -41,9 +42,6 @@ export interface EventMessage { export class WebSocketEventsServer extends WebSocketServer { static readonly PROTOCOL_VERSION = 2; - private clients = new Map(); - private nextClientId = 0; - /** * Create new instance of WebSocketHMRServer and attach it to the given Fastify instance. * Any logging information, will be passed through standard `fastify.log` API. @@ -55,10 +53,14 @@ export class WebSocketEventsServer extends WebSocketServer { fastify: FastifyInstance, private config: WebSocketEventsServerConfig ) { - super(fastify, '/events', { - verifyClient: (({ origin }) => { - return /^(https?:\/\/localhost|file:\/\/)/.test(origin); - }) as WebSocket.VerifyClientCallbackSync, + super(fastify, { + name: 'Events', + path: '/events', + wss: { + verifyClient: (({ origin }) => { + return /^(https?:\/\/localhost|file:\/\/)/.test(origin); + }) as WebSocket.VerifyClientCallbackSync, + }, }); } @@ -159,24 +161,9 @@ export class WebSocketEventsServer extends WebSocketServer { } } - /** - * Process new client's WebSocket connection. - * - * @param socket Incoming WebSocket connection. - */ - onConnection(socket: WebSocket) { - const clientId = `client#${this.nextClientId++}`; - this.clients.set(clientId, socket); - this.fastify.log.debug({ msg: 'Events client connected', clientId }); - - const onClose = () => { - this.fastify.log.debug({ msg: 'Events client disconnected', clientId }); - socket.removeAllListeners(); - this.clients.delete(clientId); - }; - - socket.addEventListener('error', onClose); - socket.addEventListener('close', onClose); + override onConnection(socket: WebSocket, request: IncomingMessage) { + const clientId = super.onConnection(socket, request); + socket.addEventListener('message', (event) => { const message = this.parseMessage(event.data.toString()); @@ -203,5 +190,7 @@ export class WebSocketEventsServer extends WebSocketServer { }); } }); + + return clientId; } } diff --git a/packages/dev-server/src/plugins/wss/servers/WebSocketHMRServer.ts b/packages/dev-server/src/plugins/wss/servers/WebSocketHMRServer.ts index 8d5263ab7..39d8534a3 100644 --- a/packages/dev-server/src/plugins/wss/servers/WebSocketHMRServer.ts +++ b/packages/dev-server/src/plugins/wss/servers/WebSocketHMRServer.ts @@ -1,9 +1,5 @@ -import type { IncomingMessage } from 'node:http'; -import { URL } from 'node:url'; import type { FastifyInstance } from 'fastify'; -import type WebSocket from 'ws'; import { WebSocketServer } from '../WebSocketServer.js'; -import type { HmrDelegate } from '../types.js'; /** * Class for creating a WebSocket server for Hot Module Replacement. @@ -11,44 +7,28 @@ import type { HmrDelegate } from '../types.js'; * @category Development server */ export class WebSocketHMRServer extends WebSocketServer { - private clients = new Map< - { clientId: string; platform: string }, - WebSocket - >(); - private nextClientId = 0; - /** * Create new instance of WebSocketHMRServer and attach it to the given Fastify instance. * Any logging information, will be passed through standard `fastify.log` API. * * @param fastify Fastify instance to attach the WebSocket server to. - * @param delegate HMR delegate instance. */ - constructor( - fastify: FastifyInstance, - private delegate: HmrDelegate - ) { - super(fastify, delegate.getUriPath()); + constructor(fastify: FastifyInstance) { + super(fastify, { + name: 'HMR', + path: '/__hmr', + }); } /** * Send action to all connected HMR clients. * * @param event Event to send to the clients. - * @param platform Platform of clients to send the event to. - * @param clientIds Ids of clients who should receive the event. */ - send(event: any, platform: string, clientIds?: string[]) { + send(event: any) { const data = typeof event === 'string' ? event : JSON.stringify(event); - for (const [key, socket] of this.clients) { - if ( - key.platform !== platform || - !(clientIds ?? [key.clientId]).includes(key.clientId) - ) { - continue; - } - + this.clients.forEach((socket) => { try { socket.send(data); } catch (error) { @@ -56,51 +36,8 @@ export class WebSocketHMRServer extends WebSocketServer { msg: 'Cannot send action to client', event, error, - ...key, }); } - } - } - - /** - * Process new WebSocket connection from HMR client. - * - * @param socket Incoming HMR client's WebSocket connection. - */ - onConnection(socket: WebSocket, request: IncomingMessage) { - const { searchParams } = new URL(request.url || '', 'http://localhost'); - const platform = searchParams.get('platform'); - - if (!platform) { - this.fastify.log.debug({ - msg: 'HMR connection disconnected - missing platform', - }); - socket.close(); - return; - } - - const clientId = `client#${this.nextClientId++}`; - - const client = { - clientId, - platform, - }; - - this.clients.set(client, socket); - - this.fastify.log.debug({ msg: 'HMR client connected', ...client }); - - const onClose = () => { - this.fastify.log.debug({ - msg: 'HMR client disconnected', - ...client, - }); - this.clients.delete(client); - }; - - socket.addEventListener('error', onClose); - socket.addEventListener('close', onClose); - - this.delegate.onClientConnected(platform, clientId); + }); } } diff --git a/packages/dev-server/src/plugins/wss/servers/WebSocketMessageServer.ts b/packages/dev-server/src/plugins/wss/servers/WebSocketMessageServer.ts index 5cc7329e8..ae0d033d8 100644 --- a/packages/dev-server/src/plugins/wss/servers/WebSocketMessageServer.ts +++ b/packages/dev-server/src/plugins/wss/servers/WebSocketMessageServer.ts @@ -25,8 +25,6 @@ export interface ReactNativeMessage { params?: Record; } -type WebSocketWithUpgradeReq = WebSocket & { upgradeReq?: IncomingMessage }; - /** * Class for creating a WebSocket server and sending messages between development server * and the React Native applications. @@ -80,8 +78,7 @@ export class WebSocketMessageServer extends WebSocketServer { ); } - private clients = new Map(); - private nextClientId = 0; + private upgradeRequests: Record = {}; /** * Create new instance of WebSocketMessageServer and attach it to the given Fastify instance. @@ -90,7 +87,7 @@ export class WebSocketMessageServer extends WebSocketServer { * @param fastify Fastify instance to attach the WebSocket server to. */ constructor(fastify: FastifyInstance) { - super(fastify, '/message'); + super(fastify, { name: 'Message', path: '/message' }); } /** @@ -264,9 +261,11 @@ export class WebSocketMessageServer extends WebSocketServer { break; case 'getpeers': { const output: Record> = {}; - this.clients.forEach((peerSocket, peerId) => { + this.clients.forEach((_, peerId) => { if (clientId !== peerId) { - const { searchParams } = new URL(peerSocket.upgradeReq?.url || ''); + const { searchParams } = new URL( + this.upgradeRequests[peerId]?.url || '' + ); output[peerId] = [...searchParams.entries()].reduce( (acc, [key, value]) => { acc[key] = value; @@ -349,27 +348,11 @@ export class WebSocketMessageServer extends WebSocketServer { this.sendBroadcast(undefined, { method, params }); } - /** - * Process new client's WebSocket connection. - * - * @param socket Incoming WebSocket connection. - * @param request Upgrade request for the connection. - */ - onConnection(socket: WebSocket, request: IncomingMessage) { - const clientId = `client#${this.nextClientId++}`; - const client: WebSocketWithUpgradeReq = socket; - client.upgradeReq = request; - this.clients.set(clientId, client); - this.fastify.log.debug({ msg: 'Message client connected', clientId }); + override onConnection(socket: WebSocket, request: IncomingMessage): string { + const clientId = super.onConnection(socket, request); - const onClose = () => { - this.fastify.log.debug({ msg: 'Message client disconnected', clientId }); - socket.removeAllListeners(); - this.clients.delete(clientId); - }; + this.upgradeRequests[clientId] = request; - socket.addEventListener('error', onClose); - socket.addEventListener('close', onClose); socket.addEventListener('message', (event) => { const message = this.parseMessage( event.data.toString(), @@ -409,5 +392,7 @@ export class WebSocketMessageServer extends WebSocketServer { this.handleError(clientId, message, error as Error); } }); + + return clientId; } } diff --git a/packages/dev-server/src/plugins/wss/types.ts b/packages/dev-server/src/plugins/wss/types.ts index b9b48959a..d57f4778e 100644 --- a/packages/dev-server/src/plugins/wss/types.ts +++ b/packages/dev-server/src/plugins/wss/types.ts @@ -1,25 +1,14 @@ import type { IncomingMessage } from 'node:http'; import type { Socket } from 'node:net'; - -/** - * Delegate with implementation for HMR-specific functions. - */ -export interface HmrDelegate { - /** Get URI under which HMR server will be running, e.g: `/hmr` */ - getUriPath: () => string; - - /** - * Callback for when the new HMR client is connected. - * - * Useful for running initial synchronization or any other side effect. - * - * @param platform Platform of the connected client. - * @param clientId Id of the connected client. - */ - onClientConnected: (platform: string, clientId: string) => void; -} +import type { ServerOptions } from 'ws'; export interface WebSocketServerInterface { shouldUpgrade(pathname: string): boolean; upgrade(request: IncomingMessage, socket: Socket, head: Buffer): void; } + +export type WebSocketServerOptions = { + name: string; + path: string | string[]; + wss?: Omit; +}; diff --git a/packages/dev-server/src/plugins/wss/wssPlugin.ts b/packages/dev-server/src/plugins/wss/wssPlugin.ts index 766017cee..b3079bff4 100644 --- a/packages/dev-server/src/plugins/wss/wssPlugin.ts +++ b/packages/dev-server/src/plugins/wss/wssPlugin.ts @@ -25,6 +25,12 @@ declare module 'fastify' { } } +declare module 'ws' { + interface WebSocket { + isAlive?: boolean; + } +} + /** * Defined in @react-native/dev-middleware * Reference: https://github.com/facebook/react-native/blob/main/packages/dev-middleware/src/inspector-proxy/InspectorProxy.js @@ -35,7 +41,6 @@ const WS_DEBUGGER_URL = '/inspector/debug'; async function wssPlugin( instance: FastifyInstance, { - delegate, endpoints, }: { delegate: Server.Delegate; @@ -50,7 +55,7 @@ async function wssPlugin( webSocketMessageServer: messageServer, }); const apiServer = new WebSocketApiServer(instance); - const hmrServer = new WebSocketHMRServer(instance, delegate.hmr); + const hmrServer = new WebSocketHMRServer(instance); // @react-native/dev-middleware servers const deviceConnectionServer = new WebSocketServerAdapter( diff --git a/packages/dev-server/src/types.ts b/packages/dev-server/src/types.ts index 5a6d8d236..bfcc712e3 100644 --- a/packages/dev-server/src/types.ts +++ b/packages/dev-server/src/types.ts @@ -9,7 +9,6 @@ import type { SymbolicatorDelegate, SymbolicatorResults, } from './plugins/symbolicate/types.js'; -import type { HmrDelegate } from './plugins/wss/types.js'; import type { NormalizedOptions } from './utils/normalizeOptions.js'; export type { CompilerDelegate }; @@ -21,7 +20,6 @@ export type { SymbolicatorDelegate, SymbolicatorResults, }; -export type { HmrDelegate }; export interface DevServerOptions { /** @@ -77,9 +75,6 @@ export namespace Server { /** A logger delegate. */ logger: LoggerDelegate; - /** An HMR delegate. */ - hmr: HmrDelegate; - /** An messages delegate. */ messages: MessagesDelegate; @@ -109,15 +104,8 @@ export namespace Server { * Broadcast arbitrary event to all connected HMR clients for given `platform`. * * @param event Arbitrary event to broadcast. - * @param platform Platform of the clients to which broadcast should be sent. - * @param clientIds Ids of the client to which broadcast should be sent. - * If `undefined` the broadcast will be sent to all connected clients for the given `platform`. */ - broadcastToHmrClients: ( - event: E, - platform: string, - clientIds?: string[] - ) => void; + broadcastToHmrClients: (event: E) => void; /** * Broadcast arbitrary method-like event to all connected message clients. diff --git a/packages/repack/src/commands/rspack/Compiler.ts b/packages/repack/src/commands/rspack/Compiler.ts index a8e23ee2f..0c4734f84 100644 --- a/packages/repack/src/commands/rspack/Compiler.ts +++ b/packages/repack/src/commands/rspack/Compiler.ts @@ -10,7 +10,7 @@ import type { } from '@rspack/core'; import memfs from 'memfs'; import type { Reporter } from '../../logging/types.js'; -import type { HMRMessageBody } from '../../types.js'; +import type { HMRMessage } from '../../types.js'; import { CLIError } from '../common/error.js'; import { adaptFilenameToPlatform, runAdbReverse } from '../common/index.js'; import { DEV_SERVER_ASSET_TYPES } from '../consts.js'; @@ -69,6 +69,10 @@ export class Compiler { }); } this.devServerContext.notifyBuildStart(platform); + this.devServerContext.broadcastToHmrClients({ + action: 'compiling', + body: { name: platform }, + }); }); }); @@ -76,10 +80,10 @@ export class Compiler { this.isCompilationInProgress = true; this.platforms.forEach((platform) => { this.devServerContext.notifyBuildStart(platform); - this.devServerContext.broadcastToHmrClients( - { action: 'building' }, - platform - ); + this.devServerContext.broadcastToHmrClients({ + action: 'compiling', + body: { name: platform }, + }); }); }); @@ -96,11 +100,15 @@ export class Compiler { }); try { - stats.children?.map((childStats) => { + stats.children!.map((childStats) => { const platform = childStats.name!; - this.statsCache[platform] = childStats; + this.devServerContext.broadcastToHmrClients({ + action: 'hash', + body: { name: platform, hash: childStats.hash }, + }); - const assets = this.statsCache[platform]!.assets!; + this.statsCache[platform] = childStats; + const assets = childStats.assets!; this.assetsCache[platform] = assets .filter((asset) => asset.type === 'asset') @@ -137,12 +145,8 @@ export class Compiler { return acc; }, - // keep old assets, discard HMR-related ones - Object.fromEntries( - Object.entries(this.assetsCache[platform] ?? {}).filter( - ([_, asset]) => !asset.info.hotModuleReplacement - ) - ) + // keep old assets + this.assetsCache[platform] ?? {} ); }); } catch (error) { @@ -163,10 +167,10 @@ export class Compiler { const platform = childStats.name!; this.callPendingResolvers(platform); this.devServerContext.notifyBuildEnd(platform); - this.devServerContext.broadcastToHmrClients( - { action: 'built', body: this.getHmrBody(platform) }, - platform - ); + this.devServerContext.broadcastToHmrClients({ + action: 'ok', + body: { name: platform }, + }); }); }); } @@ -278,19 +282,4 @@ export class Compiler { ); } } - - getHmrBody(platform: string): HMRMessageBody | null { - const stats = this.statsCache[platform]; - if (!stats) { - return null; - } - - return { - name: stats.name ?? '', - time: stats.time ?? 0, - hash: stats.hash ?? '', - warnings: stats.warnings || [], - errors: stats.errors || [], - }; - } } diff --git a/packages/repack/src/commands/rspack/start.ts b/packages/repack/src/commands/rspack/start.ts index ebbdc4762..4b65ef1fa 100644 --- a/packages/repack/src/commands/rspack/start.ts +++ b/packages/repack/src/commands/rspack/start.ts @@ -145,16 +145,6 @@ export async function start( return !/webpack[/\\]runtime[/\\].+\s/.test(frame.file); }, }, - hmr: { - getUriPath: () => '/__hmr', - onClientConnected: (platform, clientId) => { - ctx.broadcastToHmrClients( - { action: 'sync', body: compiler.getHmrBody(platform) }, - platform, - [clientId] - ); - }, - }, messages: { getHello: () => 'React Native packager is running', getStatus: () => 'packager-status:running', diff --git a/packages/repack/src/commands/webpack/Compiler.ts b/packages/repack/src/commands/webpack/Compiler.ts index 0d4c4c956..9a0c1904f 100644 --- a/packages/repack/src/commands/webpack/Compiler.ts +++ b/packages/repack/src/commands/webpack/Compiler.ts @@ -91,12 +91,8 @@ export class Compiler extends EventEmitter { this.statsCache[platform] = value.stats; this.assetsCache[platform] = { - // keep old assets, discard HMR-related ones - ...Object.fromEntries( - Object.entries(this.assetsCache[platform] ?? {}).filter( - ([_, asset]) => !asset.info.hotModuleReplacement - ) - ), + // keep old assets + ...(this.assetsCache[platform] ?? {}), // convert asset data Uint8Array to Buffer ...Object.fromEntries( Object.entries(value.assets).map(([name, { data, info, size }]) => { diff --git a/packages/repack/src/commands/webpack/start.ts b/packages/repack/src/commands/webpack/start.ts index b8cb11643..3bbde71c3 100644 --- a/packages/repack/src/commands/webpack/start.ts +++ b/packages/repack/src/commands/webpack/start.ts @@ -11,6 +11,7 @@ import { composeReporters, makeLogEntryFromFastifyLog, } from '../../logging/index.js'; +import type { HMRMessage } from '../../types.js'; import { makeCompilerConfig } from '../common/config/makeCompilerConfig.js'; import { CLIError } from '../common/error.js'; import { @@ -22,7 +23,6 @@ import { import { setupEnvironment } from '../common/setupEnvironment.js'; import type { StartArguments } from '../types.js'; import { Compiler } from './Compiler.js'; -import type { HMRMessageBody } from './types.js'; /** * Start command for React Native Community CLI. @@ -125,10 +125,12 @@ export async function start( }); } - const lastStats: Record = {}; - compiler.on('watchRun', ({ platform }) => { ctx.notifyBuildStart(platform); + ctx.broadcastToHmrClients({ + action: 'compiling', + body: { name: platform }, + }); if (platform === 'android') { void runAdbReverse({ port: ctx.options.port, @@ -139,7 +141,10 @@ export async function start( compiler.on('invalid', ({ platform }) => { ctx.notifyBuildStart(platform); - ctx.broadcastToHmrClients({ action: 'building' }, platform); + ctx.broadcastToHmrClients({ + action: 'compiling', + body: { name: platform }, + }); }); compiler.on( @@ -152,11 +157,14 @@ export async function start( stats: StatsCompilation; }) => { ctx.notifyBuildEnd(platform); - lastStats[platform] = stats; - ctx.broadcastToHmrClients( - { action: 'built', body: createHmrBody(stats) }, - platform - ); + ctx.broadcastToHmrClients({ + action: 'hash', + body: { name: platform, hash: stats.hash }, + }); + ctx.broadcastToHmrClients({ + action: 'ok', + body: { name: platform }, + }); } ); @@ -190,16 +198,6 @@ export async function start( return !/webpack[/\\]runtime[/\\].+\s/.test(frame.file); }, }, - hmr: { - getUriPath: () => '/__hmr', - onClientConnected: (platform, clientId) => { - ctx.broadcastToHmrClients( - { action: 'sync', body: createHmrBody(lastStats[platform]) }, - platform, - [clientId] - ); - }, - }, messages: { getHello: () => 'React Native packager is running', getStatus: () => 'packager-status:running', @@ -235,17 +233,3 @@ export async function start( }, }; } - -function createHmrBody(stats?: StatsCompilation): HMRMessageBody | null { - if (!stats) { - return null; - } - - return { - name: stats.name ?? '', - time: stats.time ?? 0, - hash: stats.hash ?? '', - warnings: stats.warnings || [], - errors: stats.errors || [], - }; -} diff --git a/packages/repack/src/commands/webpack/types.ts b/packages/repack/src/commands/webpack/types.ts index f8abbf8b9..f1020964a 100644 --- a/packages/repack/src/commands/webpack/types.ts +++ b/packages/repack/src/commands/webpack/types.ts @@ -8,19 +8,6 @@ export interface WebpackWorkerOptions { reactNativePath: string; } -export interface HMRMessageBody { - name: string; - time: number; - hash: string; - warnings: StatsCompilation['warnings']; - errors: StatsCompilation['errors']; -} - -export interface HMRMessage { - action: 'building' | 'built' | 'sync'; - body: HMRMessageBody | null; -} - type WebpackStatsAsset = RemoveRecord; export interface CompilerAsset { diff --git a/packages/repack/src/modules/WebpackHMRClient.ts b/packages/repack/src/modules/WebpackHMRClient.ts index 6214cad1b..1c494dc81 100644 --- a/packages/repack/src/modules/WebpackHMRClient.ts +++ b/packages/repack/src/modules/WebpackHMRClient.ts @@ -1,4 +1,4 @@ -import type { HMRMessage, HMRMessageBody } from '../types.js'; +import type { HMRMessage } from '../types.js'; import { getDevServerLocation } from './getDevServerLocation.js'; interface LoadingViewModule { @@ -9,7 +9,8 @@ interface LoadingViewModule { class HMRClient { url: string; socket: WebSocket; - lastHash = ''; + // state + lastCompilationHash: string | null = null; constructor( private app: { @@ -19,7 +20,7 @@ class HMRClient { hideLoadingView: () => void; } ) { - this.url = `ws://${getDevServerLocation().host}/__hmr?platform=${__PLATFORM__}`; + this.url = `ws://${getDevServerLocation().host}/__hmr`; this.socket = new WebSocket(this.url); console.debug('[HMRClient] Connecting...', { @@ -28,6 +29,8 @@ class HMRClient { this.socket.onopen = () => { console.debug('[HMRClient] Connected'); + // hide the `Downloading 100%` message + this.app.hideLoadingView(); }; this.socket.onclose = () => { @@ -47,125 +50,96 @@ class HMRClient { }; } - upToDate(hash?: string) { - if (hash) { - this.lastHash = hash; + processMessage(message: HMRMessage) { + // Only process messages for the target platform + if (message.body.name !== __PLATFORM__) { + return; } - return this.lastHash === __webpack_hash__; - } - processMessage(message: HMRMessage) { switch (message.action) { - case 'building': - this.app.showLoadingView('Rebuilding...', 'refresh'); - console.debug('[HMRClient] Bundle rebuilding', { - name: message.body?.name, - }); + case 'compiling': + this.handleCompilationInProgress(); break; - case 'built': - console.debug('[HMRClient] Bundle rebuilt', { - name: message.body?.name, - time: message.body?.time, - }); - // Fall through - case 'sync': - if (!message.body) { - console.warn('[HMRClient] HMR message body is empty'); - return; - } + case 'hash': + this.handleHashUpdate(message.body.hash); + break; + case 'ok': + this.handleBundleUpdate(); + break; + } + } - if (message.body.errors?.length) { - message.body.errors.forEach((error) => { - console.error('Cannot apply update due to error:', error); - }); - this.app.hideLoadingView(); - return; - } + handleCompilationInProgress() { + console.debug('[HMRClient] Processing progress update'); + this.app.showLoadingView('Compiling...', 'refresh'); + } - if (message.body.warnings?.length) { - message.body.warnings.forEach((warning) => { - console.warn('[HMRClient] Bundle contains warnings:', warning); - }); - } + handleHashUpdate(hash?: string) { + console.debug('[HMRClient] Processing hash update'); + this.lastCompilationHash = hash ?? null; + } - this.applyUpdate(message.body); - } + handleBundleUpdate() { + console.debug('[HMRClient] Processing bundle update'); + this.tryApplyUpdates(); + this.app.hideLoadingView(); + } + + isUpdateAvailable() { + return this.lastCompilationHash !== __webpack_hash__; } - applyUpdate(update: HMRMessageBody) { + // Attempt to update code on the fly, fall back to a hard reload. + tryApplyUpdates() { + // detect is there a newer version of this code available + if (!this.isUpdateAvailable()) { + return; + } + if (!module.hot) { - throw new Error('[HMRClient] Hot Module Replacement is disabled.'); + // HMR is not enabled + this.app.reload(); + return; } - if (!this.upToDate(update.hash) && module.hot.status() === 'idle') { - console.debug('[HMRClient] Checking for updates on the server...'); - void this.checkUpdates(update); + if (module.hot.status() !== 'idle') { + // HMR is disallowed in other states than 'idle' + return; } - } - async checkUpdates(update: HMRMessageBody) { - try { - this.app.showLoadingView('Refreshing...', 'refresh'); - const updatedModules = await module.hot?.check(false); - if (!updatedModules) { - console.warn('[HMRClient] Cannot find update - full reload needed'); + const handleApplyUpdates = ( + err: unknown, + updatedModules: (string | number)[] | null + ) => { + const forcedReload = err || !updatedModules; + if (forcedReload) { + console.warn('[HMRClient] Forced reload'); + if (err) { + console.debug('[HMRClient] Forced reload caused by: ', err); + } this.app.reload(); return; } - const renewedModules = await module.hot?.apply({ - ignoreDeclined: true, - ignoreUnaccepted: false, - ignoreErrored: false, - onDeclined: (data) => { - // This module declined update, no need to do anything - console.warn('[HMRClient] Ignored an update due to declined module', { - chain: data.chain, - }); - }, - }); - - if (!this.upToDate()) { - void this.checkUpdates(update); - return; - } - - // No modules updated - leave everything as it is (including errors) - if (!renewedModules || renewedModules.length === 0) { - console.debug('[HMRClient] No renewed modules - app is up to date'); - return; + if (this.isUpdateAvailable()) { + // While we were updating, there was a new update! Do it again. + this.tryApplyUpdates(); } + }; - // Double check to make sure all updated modules were accepted (renewed) - const unacceptedModules = updatedModules.filter((moduleId) => { - return renewedModules.indexOf(moduleId) < 0; - }); - - if (unacceptedModules.length) { - console.warn( - '[HMRClient] Not every module was accepted - full reload needed', - { unacceptedModules } - ); - this.app.reload(); - } else { - console.debug('[HMRClient] Renewed modules - app is up to date', { - renewedModules, - }); - this.app.dismissErrors(); - } - } catch (error) { - if (module.hot?.status() === 'fail' || module.hot?.status() === 'abort') { - console.warn( - '[HMRClient] Cannot check for update - full reload needed' - ); - console.warn('[HMRClient]', error); - this.app.reload(); - } else { - console.warn('[HMRClient] Update check failed', { error }); - } - } finally { - this.app.hideLoadingView(); - } + console.debug('[HMRClient] Checking for updates on the server...'); + module.hot + .check({ + onAccepted: this.app.dismissErrors, + onDeclined: this.app.dismissErrors, + onErrored: this.app.dismissErrors, + onUnaccepted: this.app.dismissErrors, + onDisposed: this.app.dismissErrors, + }) + .then( + (outdatedModules) => handleApplyUpdates(null, outdatedModules), + (err) => handleApplyUpdates(err, null) + ); } } @@ -176,8 +150,7 @@ if (__DEV__ && module.hot) { }; const dismissErrors = () => { - const Platform = require('react-native/Libraries/Utilities/Platform'); - if (Platform.OS === 'ios') { + if (__PLATFORM__ === 'ios') { const NativeRedBox = require('react-native/Libraries/NativeModules/specs/NativeRedBox').default; NativeRedBox?.dismiss?.(); @@ -198,7 +171,6 @@ if (__DEV__ && module.hot) { LoadingView = require('react-native/Libraries/Utilities/LoadingView'); } - // @ts-ignore LoadingView.showMessage(text, type); }; @@ -210,7 +182,6 @@ if (__DEV__ && module.hot) { LoadingView = require('react-native/Libraries/Utilities/LoadingView'); } - // @ts-ignore LoadingView.hide(); }; diff --git a/packages/repack/src/types.ts b/packages/repack/src/types.ts index e458b9626..fce056413 100644 --- a/packages/repack/src/types.ts +++ b/packages/repack/src/types.ts @@ -1,5 +1,3 @@ -import type { StatsCompilation } from '@rspack/core'; - export type Rule = string | RegExp; /** @@ -81,17 +79,9 @@ export interface EnvOptions { devServer?: DevServerOptions; } -export interface HMRMessageBody { - name: string; - time: number; - hash: string; - warnings: StatsCompilation['warnings']; - errors: StatsCompilation['errors']; -} - export interface HMRMessage { - action: 'building' | 'built' | 'sync'; - body: HMRMessageBody | null; + action: 'compiling' | 'hash' | 'ok'; + body: { name: string; hash?: string }; } export interface Logger { diff --git a/packages/repack/src/types/runtime-globals.d.ts b/packages/repack/src/types/runtime-globals.d.ts index e1e7e1111..d5cc44780 100644 --- a/packages/repack/src/types/runtime-globals.d.ts +++ b/packages/repack/src/types/runtime-globals.d.ts @@ -6,27 +6,31 @@ declare namespace RepackRuntimeGlobals { moduleId: string | number; } + declare type HMRStatus = + | 'idle' + | 'check' + | 'prepare' + | 'ready' + | 'dispose' + | 'apply' + | 'abort' + | 'fail'; + + declare interface HMRApplyOptions { + ignoreUnaccepted?: boolean; + ignoreDeclined?: boolean; + ignoreErrored?: boolean; + onDeclined?: (info: HMRInfo) => void; + onUnaccepted?: (info: HMRInfo) => void; + onAccepted?: (info: HMRInfo) => void; + onDisposed?: (info: HMRInfo) => void; + onErrored?: (info: HMRInfo) => void; + } + declare interface HotApi { - status(): - | 'idle' - | 'check' - | 'prepare' - | 'ready' - | 'dispose' - | 'apply' - | 'abort' - | 'fail'; - check(autoPlay: boolean): Promise>; - apply(options: { - ignoreUnaccepted?: boolean; - ignoreDeclined?: boolean; - ignoreErrored?: boolean; - onDeclined?: (info: HMRInfo) => void; - onUnaccepted?: (info: HMRInfo) => void; - onAccepted?: (info: HMRInfo) => void; - onDisposed?: (info: HMRInfo) => void; - onErrored?: (info: HMRInfo) => void; - }): Promise>; + status(): HMRStatus; + check(autoPlay: boolean | HMRApplyOptions): Promise>; + apply(options: HMRApplyOptions): Promise>; } declare interface LoadScriptEvent {