diff --git a/.eslintrc.js b/.eslintrc.js index 3dd5fc2d..e91e9f27 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -50,6 +50,17 @@ module.exports = { 'no-console': 'off', }, }, + { + files: ['packages/hmr/lib/runtime/*.ts'], + rules: { + 'no-console': 'off', + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-unsafe-call': 'off', + '@typescript-eslint/no-unsafe-member-access': 'off', + '@typescript-eslint/no-unsafe-argument': 'off', + '@typescript-eslint/no-unsafe-assignment': 'off', + }, + }, { files: ['packages/jest/lib/**/*.ts'], rules: { diff --git a/.yarn/cache/@types-react-refresh-npm-0.14.3-d201a15407-ff48c18f92.zip b/.yarn/cache/@types-react-refresh-npm-0.14.3-d201a15407-ff48c18f92.zip new file mode 100644 index 00000000..6bf799ac Binary files /dev/null and b/.yarn/cache/@types-react-refresh-npm-0.14.3-d201a15407-ff48c18f92.zip differ diff --git a/.yarn/cache/swc-plugin-react-refresh-npm-0.1.0-alpha.5-d015a5c2f7-7eacb193e6.zip b/.yarn/cache/swc-plugin-react-refresh-npm-0.1.0-alpha.5-d015a5c2f7-7eacb193e6.zip new file mode 100644 index 00000000..591a096c Binary files /dev/null and b/.yarn/cache/swc-plugin-react-refresh-npm-0.1.0-alpha.5-d015a5c2f7-7eacb193e6.zip differ diff --git a/example/.gitignore b/example/.gitignore index f4c3ff65..89fdaefa 100644 --- a/example/.gitignore +++ b/example/.gitignore @@ -73,6 +73,9 @@ yarn-error.log !.yarn/sdks !.yarn/versions +# @swc +.swc + # @react-native-esbuild .rne .swc diff --git a/example/.swc/plugins/v7_macos_aarch64_0.104.4/90b69c1e7164e0dc62ebf083a345c7d5b7b0450c10b6dada84547644da60c783 b/example/.swc/plugins/v7_macos_aarch64_0.104.4/90b69c1e7164e0dc62ebf083a345c7d5b7b0450c10b6dada84547644da60c783 deleted file mode 100644 index d5283fd2..00000000 Binary files a/example/.swc/plugins/v7_macos_aarch64_0.104.4/90b69c1e7164e0dc62ebf083a345c7d5b7b0450c10b6dada84547644da60c783 and /dev/null differ diff --git a/packages/core/lib/bundler/bundler.ts b/packages/core/lib/bundler/bundler.ts index 5146daa3..5f313f6d 100644 --- a/packages/core/lib/bundler/bundler.ts +++ b/packages/core/lib/bundler/bundler.ts @@ -27,6 +27,7 @@ import type { BundleResult, BundleRequestOptions, PluginContext, + UpdatedModule, ReportableEvent, ReactNativeEsbuildPluginCreator, } from '../types'; @@ -35,18 +36,19 @@ import { createBuildStatusPlugin, createMetafilePlugin } from './plugins'; import { BundlerEventEmitter } from './events'; import { loadConfig, - getConfigFromGlobal, createPromiseHandler, + getConfigFromGlobal, getTransformedPreludeScript, getResolveExtensionsOption, getLoaderOption, getEsbuildWebConfig, + getHmrUpdatedModule, } from './helpers'; import { printLogo, printVersion } from './logo'; export class ReactNativeEsbuildBundler extends BundlerEventEmitter { - public static caches = new CacheStorage(); - public static shared = new SharedStorage(); + public static caches = CacheStorage.getInstance(); + public static shared = SharedStorage.getInstance(); private appLogger = new Logger('app', LogLevel.Trace); private buildTasks = new Map(); private plugins: ReactNativeEsbuildPluginCreator[] = []; @@ -133,10 +135,11 @@ export class ReactNativeEsbuildBundler extends BundlerEventEmitter { return FileSystemWatcher.getInstance() .setHandler((event, changedFile, stats) => { const hasTask = this.buildTasks.size > 0; + const isChanged = event === 'change'; ReactNativeEsbuildBundler.shared.setValue({ watcher: { - changed: hasTask && event === 'change' ? changedFile : null, - stats, + changed: hasTask && isChanged ? changedFile : null, + stats: stats ?? null, }, }); @@ -153,12 +156,12 @@ export class ReactNativeEsbuildBundler extends BundlerEventEmitter { additionalData?: BundlerAdditionalData, ): Promise { const config = this.config; + const enableHmr = bundleOptions.dev && !bundleOptions.minify; invariant(config.resolver, 'invalid resolver configuration'); invariant(config.resolver.mainFields, 'invalid mainFields'); invariant(config.transformer, 'invalid transformer configuration'); invariant(config.resolver.assetExtensions, 'invalid assetExtension'); invariant(config.resolver.sourceExtensions, 'invalid sourceExtensions'); - setEnvironment(bundleOptions.dev); const webSpecifiedOptions = @@ -207,7 +210,7 @@ export class ReactNativeEsbuildBundler extends BundlerEventEmitter { // Additional plugins in configuration. ...(config.plugins ?? []), ], - legalComments: bundleOptions.dev ? 'inline' : 'none', + legalComments: enableHmr ? 'inline' : 'none', target: 'es6', format: 'esm', supported: { @@ -267,13 +270,17 @@ export class ReactNativeEsbuildBundler extends BundlerEventEmitter { process.exit(1); } + const hmrSharedValue = ReactNativeEsbuildBundler.shared.get(context.id); const currentTask = this.buildTasks.get(context.id); + invariant(hmrSharedValue, 'invalid hmr shared value'); invariant(currentTask, 'no task'); + const bundleEndedAt = new Date(); const bundleFilename = context.outfile; const bundleSourcemapFilename = `${bundleFilename}.map`; const revisionId = bundleEndedAt.getTime().toString(); const { outputFiles } = data.result; + let updatedModule: UpdatedModule | null = null; const findFromOutputFile = ( filename: string, @@ -293,6 +300,12 @@ export class ReactNativeEsbuildBundler extends BundlerEventEmitter { invariant(bundleOutput, 'empty bundle output'); invariant(bundleSourcemapOutput, 'empty sourcemap output'); + updatedModule = getHmrUpdatedModule( + hmrSharedValue.hmr.id, + hmrSharedValue.hmr.path, + bundleOutput.text, + ); + currentTask.handler?.resolver?.({ result: { source: bundleOutput.contents, @@ -310,6 +323,7 @@ export class ReactNativeEsbuildBundler extends BundlerEventEmitter { revisionId, id: context.id, additionalData: context.additionalData, + updatedModule, }); } } diff --git a/packages/core/lib/bundler/events/index.ts b/packages/core/lib/bundler/events/index.ts index cd3718b7..5c10747e 100644 --- a/packages/core/lib/bundler/events/index.ts +++ b/packages/core/lib/bundler/events/index.ts @@ -3,6 +3,7 @@ import type { BundlerAdditionalData, BuildStatus, ReportableEvent, + UpdatedModule, } from '../../types'; export class BundlerEventEmitter extends EventEmitter { @@ -46,6 +47,7 @@ export interface BundlerEventPayload { 'build-end': { id: number; revisionId: string; + updatedModule: UpdatedModule | null; additionalData?: BundlerAdditionalData; }; 'build-status-change': BuildStatus & { diff --git a/packages/core/lib/bundler/helpers/hmr.ts b/packages/core/lib/bundler/helpers/hmr.ts new file mode 100644 index 00000000..5459f90d --- /dev/null +++ b/packages/core/lib/bundler/helpers/hmr.ts @@ -0,0 +1,25 @@ +import { + getModuleCodeFromBundle, + isReactRefreshRegistered, +} from '@react-native-esbuild/hmr'; +import type { UpdatedModule } from '../../types'; + +export const getHmrUpdatedModule = ( + id: string | null, + path: string | null, + bundleCode: string, +): UpdatedModule | null => { + const updatedCode = + id && path ? getModuleCodeFromBundle(bundleCode, id) : null; + + return updatedCode + ? { + code: updatedCode, + id: id ?? '', + path: path ?? '', + mode: isReactRefreshRegistered(updatedCode) + ? 'hot-reload' + : 'full-reload', + } + : null; +}; diff --git a/packages/core/lib/bundler/helpers/index.ts b/packages/core/lib/bundler/helpers/index.ts index dd49165c..227d4a90 100644 --- a/packages/core/lib/bundler/helpers/index.ts +++ b/packages/core/lib/bundler/helpers/index.ts @@ -1,3 +1,4 @@ export * from './async'; export * from './config'; +export * from './hmr'; export * from './internal'; diff --git a/packages/core/lib/bundler/plugins/statusPlugin/StatusLogger.ts b/packages/core/lib/bundler/plugins/statusPlugin/StatusLogger.ts index c4eaaab6..0ea9461e 100644 --- a/packages/core/lib/bundler/plugins/statusPlugin/StatusLogger.ts +++ b/packages/core/lib/bundler/plugins/statusPlugin/StatusLogger.ts @@ -6,10 +6,16 @@ import { getBuildStatusCachePath } from '@react-native-esbuild/config'; import { colors, isTTY } from '@react-native-esbuild/utils'; import { logger } from '../../../shared'; import { ESBUILD_LABEL } from '../../logo'; -import type { BuildStatus, PluginContext } from '../../../types'; +import type { + BuildStatus, + BundlerSharedData, + PluginContext, +} from '../../../types'; +import { SharedStorage } from '../../storages'; import { fromTemplate, getSummaryTemplate } from './templates'; export class StatusLogger { + private bundlerSharedData: BundlerSharedData; private platformText: string; private spinner: Ora; private totalModuleCount = 0; @@ -19,6 +25,7 @@ export class StatusLogger { private previousPercent = 0; constructor(private context: PluginContext) { + this.bundlerSharedData = SharedStorage.getInstance().get(context.id); this.platformText = colors.gray( `[${[context.platform, context.dev ? 'dev' : null] .filter(Boolean) @@ -102,6 +109,7 @@ export class StatusLogger { this.previousPercent = 0; this.statusUpdate(); + process.stdout.write('\n'); isTTY() ? this.spinner.start() : this.print(`${this.platformText} build in progress...`); @@ -110,12 +118,20 @@ export class StatusLogger { async summary({ warnings, errors }: BuildResult): Promise { const duration = (new Date().getTime() - this.buildStartedAt) / 1000; const isSuccess = errors.length === 0; + const changedFileText = + isSuccess && this.bundlerSharedData.hmr.path + ? colors.gray( + `(${this.bundlerSharedData.hmr.path + .replace(this.context.root, '') + .substring(1)})`, + ) + : ''; await this.printMessages(warnings, 'warning'); await this.printMessages(errors, 'error'); const resultText = isSuccess - ? `${this.platformText} done!` + ? `${this.platformText} done! ${changedFileText}` : `${this.platformText} failed!`; if (isTTY()) { diff --git a/packages/core/lib/bundler/storages/CacheStorage.ts b/packages/core/lib/bundler/storages/CacheStorage.ts index c62a94c4..b0811218 100644 --- a/packages/core/lib/bundler/storages/CacheStorage.ts +++ b/packages/core/lib/bundler/storages/CacheStorage.ts @@ -9,7 +9,16 @@ import { Storage } from './Storage'; const CACHE_DIRECTORY = path.join(os.tmpdir(), GLOBAL_CACHE_DIR); export class CacheStorage extends Storage { - constructor() { + private static instance: CacheStorage | null = null; + + public static getInstance(): CacheStorage { + if (CacheStorage.instance === null) { + CacheStorage.instance = new CacheStorage(); + } + return CacheStorage.instance; + } + + private constructor() { super(); try { fs.accessSync(CACHE_DIRECTORY, fs.constants.R_OK | fs.constants.W_OK); diff --git a/packages/core/lib/bundler/storages/SharedStorage.ts b/packages/core/lib/bundler/storages/SharedStorage.ts index 05c2389e..27f1bfaa 100644 --- a/packages/core/lib/bundler/storages/SharedStorage.ts +++ b/packages/core/lib/bundler/storages/SharedStorage.ts @@ -2,12 +2,23 @@ import type { BundlerSharedData } from '../../types'; import { Storage } from './Storage'; export class SharedStorage extends Storage { + private static instance: SharedStorage | null = null; + + public static getInstance(): SharedStorage { + if (SharedStorage.instance === null) { + SharedStorage.instance = new SharedStorage(); + } + return SharedStorage.instance; + } + + private constructor() { + super(); + } + private getDefaultSharedData(): BundlerSharedData { return { - watcher: { - changed: null, - stats: undefined, - }, + watcher: { changed: null, stats: null }, + hmr: { id: null, path: null }, }; } @@ -34,8 +45,12 @@ export class SharedStorage extends Storage { public clearAll(): Promise { for (const sharedData of this.data.values()) { + // watcher sharedData.watcher.changed = null; - sharedData.watcher.stats = undefined; + sharedData.watcher.stats = null; + // hmr + sharedData.hmr.id = null; + sharedData.hmr.path = null; } return Promise.resolve(); } diff --git a/packages/core/lib/types.ts b/packages/core/lib/types.ts index 89c98ef6..04be84d3 100644 --- a/packages/core/lib/types.ts +++ b/packages/core/lib/types.ts @@ -164,11 +164,21 @@ export type ReactNativeEsbuildPluginCreator = ( export interface BundlerSharedData { watcher: { changed: string | null; - stats?: Stats; + stats: Stats | null; + }; + hmr: { + id: string | null; + path: string | null; }; } export type BundlerAdditionalData = Record; +export interface UpdatedModule { + id: string; + path: string; + code: string; + mode: 'hot-reload' | 'full-reload'; +} export interface PluginContext extends BundleOptions { id: number; diff --git a/packages/core/package.json b/packages/core/package.json index 7005cc38..d048a328 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -41,6 +41,7 @@ }, "dependencies": { "@react-native-esbuild/config": "workspace:*", + "@react-native-esbuild/hmr": "workspace:*", "@react-native-esbuild/internal": "workspace:*", "@react-native-esbuild/transformer": "workspace:*", "@react-native-esbuild/utils": "workspace:*", diff --git a/packages/dev-server/lib/middlewares/hmr.ts b/packages/dev-server/lib/middlewares/hmr.ts new file mode 100644 index 00000000..6abb387f --- /dev/null +++ b/packages/dev-server/lib/middlewares/hmr.ts @@ -0,0 +1,126 @@ +import { + HmrAppServer, + HmrWebServer, + type HmrClientMessage, +} from '@react-native-esbuild/hmr'; +import type { HmrMiddleware } from '../types'; +import { logger } from '../shared'; + +export const createHmrMiddlewareForApp = ({ + onMessage, +}: { + onMessage?: (event: HmrClientMessage) => void; +}): HmrMiddleware => { + const server = new HmrAppServer(); + + server.setMessageHandler((event) => onMessage?.(event)); + + const updateStart = (): void => { + logger.debug('send update-start message'); + server.send('update-start', { isInitialUpdate: false }); + }; + + const updateDone = (): void => { + logger.debug('send update-done message'); + server.send('update-done', undefined); + }; + + const hotReload = (revisionId: string, code: string): void => { + logger.debug('send update message (hmr)'); + server.send('update', { + added: [ + { + module: [-1, code], + sourceMappingURL: null, + sourceURL: null, + }, + ], + deleted: [], + modified: [], + isInitialUpdate: false, + revisionId, + }); + }; + + const liveReload = (revisionId: string): void => { + logger.debug('send update message (live reload)'); + server.send('update', { + added: [ + { + /** + * ```ts + * // it works the same as the code below + * import { DevSettings } from 'react-native'; + * + * DevSettings.reload(); + * ``` + */ + module: [ + -1, + `(function () { + var moduleName = "DevSettings"; + (global.__turboModuleProxy + ? global.__turboModuleProxy(moduleName) + : global.nativeModuleProxy[moduleName]).reload(); + })();`, + ], + sourceMappingURL: null, + sourceURL: null, + }, + ], + deleted: [], + modified: [], + isInitialUpdate: false, + revisionId, + }); + }; + + return { server, updateStart, updateDone, hotReload, liveReload }; +}; + +export const createHmrMiddlewareForWeb = (): HmrMiddleware => { + const server = new HmrWebServer(); + + // eslint-disable-next-line @typescript-eslint/no-empty-function -- noop + const noop = (): void => {}; + + return { + server, + updateStart: noop, + updateDone: noop, + hotReload: (_revisionId: string, _code: string): void => { + logger.debug('send update message (hmr)'); + // TODO + // server.send('update', { + // added: [ + // { + // module: [-1, code], + // sourceMappingURL: null, + // sourceURL: null, + // }, + // ], + // deleted: [], + // modified: [], + // isInitialUpdate: false, + // revisionId, + // }); + }, + liveReload: (_revisionId: string): void => { + logger.debug('send update message (live reload)'); + // TODO + // server.send('update', { + // added: [ + // { + // module: [-1, 'window.location.reload();'], + // sourceMappingURL: null, + // sourceURL: null, + // }, + // ], + // deleted: [], + // modified: [], + // isInitialUpdate: false, + // revisionId, + // }); + }, + }; +}; diff --git a/packages/dev-server/lib/middlewares/hotReload.ts b/packages/dev-server/lib/middlewares/hotReload.ts deleted file mode 100644 index 8a0143cd..00000000 --- a/packages/dev-server/lib/middlewares/hotReload.ts +++ /dev/null @@ -1,141 +0,0 @@ -import { Server, type WebSocket, type MessageEvent, type Data } from 'ws'; -import type { ClientLogEvent } from '@react-native-esbuild/core'; -import { logger } from '../shared'; -import type { - HotReloadMiddleware, - HmrClientMessage, - HmrUpdateDoneMessage, - HmrUpdateMessage, - HmrUpdateStartMessage, -} from '../types'; - -const getMessage = (data: Data): HmrClientMessage | null => { - try { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- Web socket data. - const parsedData = JSON.parse(String(data)); - return 'type' in parsedData ? (parsedData as HmrClientMessage) : null; - } catch (error) { - return null; - } -}; - -export const createHotReloadMiddleware = ({ - onLog, -}: { - onLog?: (event: ClientLogEvent) => void; -}): HotReloadMiddleware => { - const server = new Server({ noServer: true }); - let connectedSocket: WebSocket | null = null; - - const handleClose = (): void => { - connectedSocket = null; - logger.debug('HMR web socket was closed'); - }; - - const handleMessage = (event: MessageEvent): void => { - const message = getMessage(event.data); - if (!message) return; - - /** - * @see {@link https://github.com/facebook/metro/blob/v0.77.0/packages/metro/src/HmrServer.js#L200-L239} - */ - switch (message.type) { - case 'log': { - onLog?.({ - type: 'client_log', - level: message.level, - data: message.data, - mode: 'BRIDGE', - }); - break; - } - - // Not supported - case 'register-entrypoints': - case 'log-opt-in': - break; - } - }; - - const handleError = (error?: Error): void => { - if (error) { - logger.error('unable to send HMR update message', error); - } - }; - - /** - * Send reload code to client. - * - * @see {@link https://github.com/facebook/metro/blob/v0.77.0/packages/metro-runtime/src/modules/HMRClient.js#L91-L99} - * @see [turboModuleProxy]{@link https://github.com/facebook/react-native/blob/v0.72.0/packages/react-native/Libraries/TurboModule/TurboModuleRegistry.js#L17} - * @see [nativeModuleProxy]{@link https://github.com/facebook/react-native/blob/v0.72.0/packages/react-native/Libraries/BatchedBridge/NativeModules.js#L179} - */ - const hotReload = (revisionId: string): void => { - const hmrUpdateMessage: HmrUpdateMessage = { - type: 'update', - body: { - added: [ - { - /** - * ```ts - * // It works the same as the code below. - * import { DevSettings } from 'react-native'; - * - * DevSettings.reload(); - * ``` - */ - module: [ - -1, - `(function () { - var moduleName = "DevSettings"; - (global.__turboModuleProxy - ? global.__turboModuleProxy(moduleName) - : global.nativeModuleProxy[moduleName]).reload(); - })();`, - ], - sourceMappingURL: null, - sourceURL: null, - }, - ], - deleted: [], - modified: [], - isInitialUpdate: false, - revisionId, - }, - }; - - logger.debug('sending update message with reload code'); - connectedSocket?.send(JSON.stringify(hmrUpdateMessage), handleError); - }; - - const updateStart = (): void => { - logger.debug('sending update-start'); - const hmrUpdateStartMessage: HmrUpdateStartMessage = { - type: 'update-start', - body: { - isInitialUpdate: false, - }, - }; - connectedSocket?.send(JSON.stringify(hmrUpdateStartMessage), handleError); - }; - - const updateDone = (): void => { - logger.debug('sending update-done'); - const hmrUpdateDoneMessage: HmrUpdateDoneMessage = { type: 'update-done' }; - connectedSocket?.send(JSON.stringify(hmrUpdateDoneMessage), handleError); - }; - - server.on('connection', (socket) => { - connectedSocket = socket; - connectedSocket.onerror = handleClose; - connectedSocket.onclose = handleClose; - connectedSocket.onmessage = handleMessage; - logger.debug('HMR web socket was connected'); - }); - - server.on('error', (error) => { - logger.error('HMR web socket server error', error); - }); - - return { server, hotReload, updateStart, updateDone }; -}; diff --git a/packages/dev-server/lib/middlewares/index.ts b/packages/dev-server/lib/middlewares/index.ts index 6e88227f..caf48ffb 100644 --- a/packages/dev-server/lib/middlewares/index.ts +++ b/packages/dev-server/lib/middlewares/index.ts @@ -1,4 +1,4 @@ -export * from './hotReload'; +export * from './hmr'; export * from './indexPage'; export * from './serveAsset'; export * from './serveBundle'; diff --git a/packages/dev-server/lib/server/ReactNativeAppServer.ts b/packages/dev-server/lib/server/ReactNativeAppServer.ts index 1a18b19c..f6a15481 100644 --- a/packages/dev-server/lib/server/ReactNativeAppServer.ts +++ b/packages/dev-server/lib/server/ReactNativeAppServer.ts @@ -7,7 +7,7 @@ import { InspectorProxy } from 'metro-inspector-proxy'; import { createDevServerMiddleware } from '@react-native-community/cli-server-api'; import { ReactNativeEsbuildBundler } from '@react-native-esbuild/core'; import { - createHotReloadMiddleware, + createHmrMiddlewareForApp, createServeAssetMiddleware, createServeBundleMiddleware, createSymbolicateMiddleware, @@ -105,10 +105,26 @@ export class ReactNativeAppServer extends DevServer { throw new Error('server is not initialized'); } - const { server: hotReloadWss, ...hr } = createHotReloadMiddleware({ - onLog: (event) => { - this.eventsSocketEndpoint.reportEvent(event); - this.bundler?.emit('report', event); + const { server: hmrServer, ...hmr } = createHmrMiddlewareForApp({ + onMessage: (message) => { + switch (message.type) { + case 'log': { + const clientLogEvent = { + type: 'client_log', + level: message.level, + data: message.data, + mode: 'BRIDGE', + } as const; + this.bundler?.emit('report', clientLogEvent); + this.eventsSocketEndpoint.reportEvent(clientLogEvent); + break; + } + + // not supported + case 'register-entrypoints': + case 'log-opt-in': + break; + } }, }); @@ -123,21 +139,26 @@ export class ReactNativeAppServer extends DevServer { ); const webSocketServer: Record = { - '/hot': hotReloadWss, + '/hot': hmrServer.getWebSocketServer(), '/debugger-proxy': this.debuggerProxyEndpoint.server, '/message': this.messageSocketEndpoint.server, '/events': this.eventsSocketEndpoint.server, ...inspectorProxyWss, }; - this.bundler.on('build-start', hr.updateStart); - this.bundler.on('build-end', ({ revisionId, additionalData }) => { - // `additionalData` can be `{ disableRefresh: true }` by `serve-asset-middleware`. - if (!additionalData?.disableRefresh) { - hr.hotReload(revisionId); - } - hr.updateDone(); - }); + this.bundler.on('build-start', hmr.updateStart); + this.bundler.on( + 'build-end', + ({ revisionId, updatedModule, additionalData }) => { + // `additionalData` can be `{ disableRefresh: true }` by `serve-asset-middleware`. + if (!additionalData?.disableRefresh) { + updatedModule?.mode === 'hot-reload' + ? hmr.hotReload(revisionId, updatedModule.code) + : hmr.liveReload(revisionId); + } + hmr.updateDone(); + }, + ); this.server.on('upgrade', (request, socket, head) => { if (!request.url) return; diff --git a/packages/dev-server/lib/server/ReactNativeWebServer.ts b/packages/dev-server/lib/server/ReactNativeWebServer.ts index c9fd5f1e..f727e3e1 100644 --- a/packages/dev-server/lib/server/ReactNativeWebServer.ts +++ b/packages/dev-server/lib/server/ReactNativeWebServer.ts @@ -3,6 +3,7 @@ import http, { type ServerResponse, type IncomingMessage, } from 'node:http'; +import { parse } from 'node:url'; import type { ServeResult } from 'esbuild'; import invariant from 'invariant'; import { ReactNativeEsbuildBundler } from '@react-native-esbuild/core'; @@ -10,7 +11,10 @@ import { combineWithDefaultBundleOptions, type BundleOptions, } from '@react-native-esbuild/config'; -import { createSymbolicateMiddleware } from '../middlewares'; +import { + createHmrMiddlewareForWeb, + createSymbolicateMiddleware, +} from '../middlewares'; import { logger } from '../shared'; import type { DevServerOptions } from '../types'; import { DevServer } from './DevServer'; @@ -96,6 +100,19 @@ export class ReactNativeWebServer extends DevServer { this.devServerOptions.root, ).initialize({ watcherEnabled: true })); + const { server: hmrServer, ...hmr } = createHmrMiddlewareForWeb(); + + this.bundler.on( + 'build-end', + ({ revisionId, updatedModule, additionalData }) => { + if (!additionalData?.disableRefresh) { + updatedModule?.mode === 'hot-reload' + ? hmr.hotReload(revisionId, updatedModule.code) + : hmr.liveReload(revisionId); + } + }, + ); + const symbolicateMiddleware = createSymbolicateMiddleware( { bundler, devServerOptions: this.devServerOptions }, { webBundleOptions: this.bundleOptions }, @@ -114,6 +131,20 @@ export class ReactNativeWebServer extends DevServer { }); }); + this.server.on('upgrade', (request, socket, head) => { + if (!request.url) return; + const { pathname } = parse(request.url); + + if (pathname === '/hot') { + const wss = hmrServer.getWebSocketServer(); + wss.handleUpgrade(request, socket, head, (client) => { + wss.emit('connection', client, request); + }); + } else { + socket.destroy(); + } + }); + await onPostSetup?.(bundler); return this; diff --git a/packages/dev-server/lib/types.ts b/packages/dev-server/lib/types.ts index 36ac5c11..0c720f9d 100644 --- a/packages/dev-server/lib/types.ts +++ b/packages/dev-server/lib/types.ts @@ -1,6 +1,6 @@ import type { IncomingMessage, ServerResponse } from 'node:http'; -import type { Server as WebSocketServer } from 'ws'; import type { ReactNativeEsbuildBundler } from '@react-native-esbuild/core'; +import type { HmrServer } from '@react-native-esbuild/hmr'; export enum BundleRequestType { Unknown, @@ -30,11 +30,12 @@ export type DevServerMiddleware = ( next: (error?: unknown) => void, ) => void; -export interface HotReloadMiddleware { - server: WebSocketServer; - hotReload: (revisionId: string) => void; +export interface HmrMiddleware { + server: HmrServer; updateStart: () => void; updateDone: () => void; + hotReload: (revisionId: string, code: string) => void; + liveReload: (revisionId: string) => void; } /** diff --git a/packages/dev-server/package.json b/packages/dev-server/package.json index 977e24d1..6b2340d8 100644 --- a/packages/dev-server/package.json +++ b/packages/dev-server/package.json @@ -49,6 +49,7 @@ "@react-native-community/cli-server-api": "^11.3.6", "@react-native-esbuild/config": "workspace:*", "@react-native-esbuild/core": "workspace:*", + "@react-native-esbuild/hmr": "workspace:*", "@react-native-esbuild/symbolicate": "workspace:*", "@react-native-esbuild/utils": "workspace:*", "invariant": "^2.2.4", diff --git a/packages/hmr/README.md b/packages/hmr/README.md new file mode 100644 index 00000000..3c587cde --- /dev/null +++ b/packages/hmr/README.md @@ -0,0 +1,52 @@ +# `@react-native-esbuild/hmr` + +> `react-refresh` based HMR implementation for @react-native-esbuild + +## Usage + +1. Add import statement to top of entry file(`import 'hmr:runtime';`) +2. Wrap React component module with `wrapWithHmrBoundary` + +```js +import { + getHmrRuntimeInitializeScript, + wrapWithHmrBoundary, + HMR_RUNTIME_IMPORT_NAME +} from '@react-native-esbuild/hmr'; + +// In esbuild plugin +build.onResolve({ filter: new RegExp(HMR_RUNTIME_IMPORT_NAME) }, (args) => { + return { + path: args.path, + namespace: 'hmr-runtime', + }; +}); + +build.onLoad({ filter: /(?:.*)/, namespace: 'hmr-runtime' }, (args) => { + return { + js: await getHmrRuntimeInitializeScript(), + loader: 'js', + }; +}); + +build.onLoad({ filter: /* some filter */ }, (args) => { + if (isEntryFile) { + code = await fs.readFile(args.path, 'utf-8'); + code = `import ${HMR_RUNTIME_IMPORT_NAME};\n\n` + code; + // ... + + return { + contents: code, + loader: 'js', + }; + } else { + // ... + + return { + // code, component name, module id + contents: wrapWithHmrBoundary(code, '', args.path), + loader: 'js', + }; + } +}); +``` diff --git a/packages/hmr/build/index.js b/packages/hmr/build/index.js new file mode 100644 index 00000000..11d3d095 --- /dev/null +++ b/packages/hmr/build/index.js @@ -0,0 +1,9 @@ +const esbuild = require('esbuild'); +const { getEsbuildBaseOptions } = require('../../../shared'); + +const buildOptions = getEsbuildBaseOptions(__dirname); + +esbuild.build(buildOptions).catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/packages/hmr/lib/constants.ts b/packages/hmr/lib/constants.ts new file mode 100644 index 00000000..a1592a7c --- /dev/null +++ b/packages/hmr/lib/constants.ts @@ -0,0 +1,9 @@ +/** + * WARNING: Property and function identifiers must match the names defined in `types.ts`. + */ +export const REACT_REFRESH_REGISTER_FUNCTION = + 'global.$RefreshRuntime$.register'; +export const REACT_REFRESH_SIGNATURE_FUNCTION = + 'global.$RefreshRuntime$.getSignature'; +export const PERFORM_REACT_REFRESH_SCRIPT = + 'global.$RefreshRuntime$.performReactRefresh()'; diff --git a/packages/hmr/lib/index.ts b/packages/hmr/lib/index.ts new file mode 100644 index 00000000..b85e05e6 --- /dev/null +++ b/packages/hmr/lib/index.ts @@ -0,0 +1,50 @@ +import { PERFORM_REACT_REFRESH_SCRIPT } from './constants'; + +const getModuleBoundary = (id: string): string => + '//! ---------- {id} ---------- !//'.replace('{id}', id); + +export const wrapModuleBoundary = ( + code: string, + id: string, + shouldPerformRefresh = false, +): string => { + /** + * To avoid strip comments + * @see {@link https://esbuild.github.io/api/#legal-comments} + */ + const __b = getModuleBoundary(id); + const performRefresh = shouldPerformRefresh + ? PERFORM_REACT_REFRESH_SCRIPT + : ''; + + return `${__b}\n${code}\n${performRefresh}\n${__b}`; +}; + +export const getModuleCodeFromBundle = ( + code: string, + id: string, +): string | null => { + const moduleBoundary = getModuleBoundary(id); + const moduleStartAt = code.indexOf(moduleBoundary); + if (moduleStartAt === -1) return null; + + let moduleEndAt = code + .slice(moduleStartAt + moduleBoundary.length) + .indexOf(moduleBoundary); + if (moduleEndAt === -1) return null; + + moduleEndAt += moduleStartAt + moduleBoundary.length; + + // something wrong + if (moduleStartAt >= moduleEndAt) return null; + + return code.slice(moduleStartAt, moduleEndAt).trim(); +}; + +export const isReactRefreshRegistered = (code: string): boolean => + code.includes('$RefreshRuntime$'); + +export * from './server'; +export * from './constants'; +export type * from './types'; +export type { HmrServer } from './server/HmrServer'; diff --git a/packages/hmr/lib/runtime/setup.ts b/packages/hmr/lib/runtime/setup.ts new file mode 100644 index 00000000..206faf3f --- /dev/null +++ b/packages/hmr/lib/runtime/setup.ts @@ -0,0 +1,67 @@ +import * as RefreshRuntime from 'react-refresh/runtime'; + +if (__DEV__) { + const HMR_DEBOUNCE_DELAY = 50; + let performReactRefreshTimeout: NodeJS.Timeout | null = null; + + const isReactRefreshBoundary = (type: unknown): boolean => { + return Boolean( + RefreshRuntime.isLikelyComponentType(type) && + // @ts-expect-error - expect a ReactElement + !type?.prototype?.isReactComponent, + ); + }; + + // `global` is defined in the prelude script. + RefreshRuntime.injectIntoGlobalHook(global); + global.$RefreshRuntime$ = { + register: (type: unknown, id: string) => { + if (!isReactRefreshBoundary(type)) return; + RefreshRuntime.register(type, id); + }, + getSignature: () => { + const signature: any = + // eslint-disable-next-line @typescript-eslint/no-confusing-void-expression -- Wrong type definition. + RefreshRuntime.createSignatureFunctionForTransform(); + return ( + type: unknown, + id: string, + forceReset?: boolean, + getCustomHooks?: () => unknown[], + ) => { + if (!isReactRefreshBoundary(type)) return; + + signature(type, id, forceReset, getCustomHooks); + }; + }, + performReactRefresh: () => { + if (performReactRefreshTimeout !== null) { + return; + } + + performReactRefreshTimeout = setTimeout(() => { + performReactRefreshTimeout = null; + if (RefreshRuntime.hasUnrecoverableErrors()) { + console.error('hot reload: has unrecoverable errors'); + return; + } + RefreshRuntime.performReactRefresh(); + }, HMR_DEBOUNCE_DELAY); + }, + }; + + // for web + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- allow + const isWeb = global?.navigator?.appName === 'Netscape'; + if (isWeb) { + const socketURL = new URL('hot', `ws://${window.location.host}`); + const socket = new window.WebSocket(socketURL.href); + socket.addEventListener('message', (event) => { + const payload = window.JSON.parse(event.data); + if (payload.type === 'update') { + const code = payload.body?.added[0]?.module?.[1]; + code && window.eval(code); + } + }); + } +} diff --git a/packages/hmr/lib/server/HmrAppServer.ts b/packages/hmr/lib/server/HmrAppServer.ts new file mode 100644 index 00000000..0ae88b50 --- /dev/null +++ b/packages/hmr/lib/server/HmrAppServer.ts @@ -0,0 +1,54 @@ +import type { Server, MessageEvent, Data } from 'ws'; +import type { HmrClientMessage, HmrMessage, HmrMessageType } from '../types'; +import { HmrServer } from './HmrServer'; + +export class HmrAppServer extends HmrServer { + private messageHandler?: (message: HmrClientMessage) => void; + + constructor() { + super(); + this.setup((socket) => { + socket.onmessage = this.handleMessage.bind(this); + }); + } + + private parseClientMessage(data: Data): HmrClientMessage | null { + try { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- from ws data + const parsedData = JSON.parse(String(data)); + return 'type' in parsedData ? (parsedData as HmrClientMessage) : null; + } catch (error) { + return null; + } + } + + private handleMessage(event: MessageEvent): void { + const message = this.parseClientMessage(event.data); + if (!message) return; + + /** + * @see {@link https://github.com/facebook/metro/blob/v0.77.0/packages/metro/src/HmrServer.js#L200-L239} + */ + this.messageHandler?.(message); + } + + public getWebSocketServer(): Server { + return this.server; + } + + public setMessageHandler(handler: (message: HmrClientMessage) => void): void { + this.messageHandler = handler; + } + + public send( + type: MessageType, + body: HmrMessage[MessageType], + ): void { + this.connectedSocket?.send( + JSON.stringify({ + type, + body, + }), + ); + } +} diff --git a/packages/hmr/lib/server/HmrServer.ts b/packages/hmr/lib/server/HmrServer.ts new file mode 100644 index 00000000..904b3160 --- /dev/null +++ b/packages/hmr/lib/server/HmrServer.ts @@ -0,0 +1,40 @@ +import { Server, type WebSocket } from 'ws'; +import { logger } from '../shared'; +import type { HmrMessage, HmrMessageType } from '../types'; + +export abstract class HmrServer { + protected server: Server; + protected connectedSocket?: WebSocket; + + constructor() { + this.server = new Server({ noServer: true }); + } + + private handleClose(): void { + this.connectedSocket = undefined; + logger.debug('HMR web socket was closed'); + } + + public setup(onConnect?: (socket: WebSocket) => void): void { + this.server.on('connection', (socket) => { + this.connectedSocket = socket; + this.connectedSocket.onclose = this.handleClose.bind(this); + this.connectedSocket.onerror = this.handleClose.bind(this); + logger.debug('HMR web socket was connected'); + onConnect?.(socket); + }); + + this.server.on('error', (error) => { + logger.error('HMR web socket server error', error); + }); + } + + public getWebSocketServer(): Server { + return this.server; + } + + public abstract send( + type: MessageType, + body: HmrMessage[MessageType], + ): void; +} diff --git a/packages/hmr/lib/server/HmrWebServer.ts b/packages/hmr/lib/server/HmrWebServer.ts new file mode 100644 index 00000000..6c95b075 --- /dev/null +++ b/packages/hmr/lib/server/HmrWebServer.ts @@ -0,0 +1,21 @@ +import type { HmrMessage, HmrMessageType } from '../types'; +import { HmrServer } from './HmrServer'; + +export class HmrWebServer extends HmrServer { + constructor() { + super(); + this.setup(); + } + + public send( + type: MessageType, + body: HmrMessage[MessageType], + ): void { + this.connectedSocket?.send( + JSON.stringify({ + type, + body, + }), + ); + } +} diff --git a/packages/hmr/lib/server/index.ts b/packages/hmr/lib/server/index.ts new file mode 100644 index 00000000..7360b435 --- /dev/null +++ b/packages/hmr/lib/server/index.ts @@ -0,0 +1,2 @@ +export * from './HmrAppServer'; +export * from './HmrWebServer'; diff --git a/packages/hmr/lib/shared.ts b/packages/hmr/lib/shared.ts new file mode 100644 index 00000000..8523c002 --- /dev/null +++ b/packages/hmr/lib/shared.ts @@ -0,0 +1,3 @@ +import { Logger } from '@react-native-esbuild/utils'; + +export const logger = new Logger('hmr'); diff --git a/packages/hmr/lib/types.ts b/packages/hmr/lib/types.ts new file mode 100644 index 00000000..cb5f83c7 --- /dev/null +++ b/packages/hmr/lib/types.ts @@ -0,0 +1,83 @@ +/* eslint-disable no-var -- allow */ +declare global { + // react-native + var __DEV__: boolean; + + // react-refresh/runtime + var $RefreshRuntime$: { + register: (type: unknown, id: string) => void; + getSignature: () => ( + type: unknown, + id: string, + forceReset?: boolean, + getCustomHooks?: () => unknown[], + ) => void; + performReactRefresh: () => void; + }; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- allow + var window: any; +} + +/** + * HMR web socket messages + * @see {@link https://github.com/facebook/metro/blob/v0.77.0/packages/metro-runtime/src/modules/types.flow.js#L68} + */ +export type HmrClientMessage = + | RegisterEntryPointsMessage + | LogMessage + | LogOptInMessage; + +export interface RegisterEntryPointsMessage { + type: 'register-entrypoints'; + entryPoints: string[]; +} + +export interface LogMessage { + type: 'log'; + level: + | 'trace' + | 'info' + | 'warn' + | 'log' + | 'group' + | 'groupCollapsed' + | 'groupEnd' + | 'debug'; + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- follow metro types + data: any[]; + mode: 'BRIDGE' | 'NOBRIDGE'; +} + +export interface LogOptInMessage { + type: 'log-opt-in'; +} + +/** + * HMR update message + * @see {@link https://github.com/facebook/metro/blob/v0.77.0/packages/metro-runtime/src/modules/types.flow.js#L44-L56} + */ +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions -- allow type +export type HmrMessage = { + update: HmrUpdate; + 'update-start': { + isInitialUpdate: boolean; + }; + 'update-done': undefined; +}; + +export type HmrMessageType = keyof HmrMessage; + +export interface HmrUpdate { + readonly added: HmrModule[]; + readonly deleted: number[]; + readonly modified: HmrModule[]; + isInitialUpdate: boolean; + revisionId: string; +} + +export interface HmrModule { + module: [number, string]; + sourceMappingURL: string | null; + sourceURL: string | null; +} diff --git a/packages/hmr/package.json b/packages/hmr/package.json new file mode 100644 index 00000000..fda733a3 --- /dev/null +++ b/packages/hmr/package.json @@ -0,0 +1,55 @@ +{ + "name": "@react-native-esbuild/hmr", + "version": "0.1.0-beta.8", + "description": "HMR implementation for @react-native-esbuild", + "keywords": [ + "react-native", + "esbuild", + "hmr", + "react-refresh" + ], + "author": "leegeunhyeok ", + "homepage": "https://github.com/leegeunhyeok/react-native-esbuild#readme", + "license": "MIT", + "type": "commonjs", + "module": "lib/index.ts", + "main": "dist/index.js", + "types": "dist/lib/index.d.ts", + "exports": { + ".": "./dist/index.js", + "./runtime": "./lib/runtime/setup.ts" + }, + "directories": { + "lib": "lib", + "test": "__tests__" + }, + "files": [ + "lib", + "dist" + ], + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/leegeunhyeok/react-native-esbuild.git" + }, + "scripts": { + "prepack": "yarn build", + "cleanup": "rimraf ./dist", + "build": "node build/index.js && tsc" + }, + "bugs": { + "url": "https://github.com/leegeunhyeok/react-native-esbuild/issues" + }, + "devDependencies": { + "@swc/helpers": "^0.5.2", + "@types/react-refresh": "^0.14.3", + "esbuild": "^0.19.3" + }, + "dependencies": { + "@react-native-esbuild/utils": "workspace:*", + "react-refresh": "^0.14.0", + "ws": "^8.14.2" + } +} diff --git a/packages/hmr/tsconfig.json b/packages/hmr/tsconfig.json new file mode 100644 index 00000000..bd42e358 --- /dev/null +++ b/packages/hmr/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "rootDir": ".", + "outDir": "./dist", + "declaration": true, + "emitDeclarationOnly": true + }, + "include": ["./lib"], + "exclude": ["./dist"] +} diff --git a/packages/plugins/lib/reactNativeRuntimeTransformPlugin/reactNativeRuntimeTransformPlugin.ts b/packages/plugins/lib/reactNativeRuntimeTransformPlugin/reactNativeRuntimeTransformPlugin.ts index 0a46be61..05e10c33 100644 --- a/packages/plugins/lib/reactNativeRuntimeTransformPlugin/reactNativeRuntimeTransformPlugin.ts +++ b/packages/plugins/lib/reactNativeRuntimeTransformPlugin/reactNativeRuntimeTransformPlugin.ts @@ -1,6 +1,5 @@ import fs from 'node:fs/promises'; import path from 'node:path'; -import type { OnLoadResult } from 'esbuild'; import { ReactNativeEsbuildBundler as Bundler, type ReactNativeEsbuildPluginCreator, @@ -11,6 +10,7 @@ import { swcPresets, type AsyncTransformStep, } from '@react-native-esbuild/transformer'; +import { wrapModuleBoundary } from '@react-native-esbuild/hmr'; import { logger } from '../shared'; import type { ReactNativeRuntimeTransformPluginConfig } from '../types'; import { @@ -39,8 +39,9 @@ export const createReactNativeRuntimeTransformPlugin: ReactNativeEsbuildPluginCr const additionalSwcRules = additionalTransformRules?.swc ?? []; const injectScriptPaths = [ getReactNativeInitializeCore(context.root), + context.dev ? '@react-native-esbuild/hmr/runtime' : null, ...(config?.injectScriptPaths ?? []), - ]; + ].filter(Boolean) as string[]; const onBeforeTransform: AsyncTransformStep = async ( code, @@ -56,6 +57,7 @@ export const createReactNativeRuntimeTransformPlugin: ReactNativeEsbuildPluginCr // 1. Force re-transform when file is changed. if (isChangedFile) { logger.debug('changed file detected', { path: args.path }); + bundlerSharedData.hmr = { id: moduleMeta.hash, path: args.path }; return { code, done: false }; } @@ -88,9 +90,10 @@ export const createReactNativeRuntimeTransformPlugin: ReactNativeEsbuildPluginCr const onAfterTransform: AsyncTransformStep = async ( code, - _args, + args, moduleMeta, ) => { + const shouldWrapModule = context.mode !== 'bundle'; const cacheConfig = { hash: moduleMeta.hash, mtimeMs: moduleMeta.stats.mtimeMs, @@ -105,7 +108,16 @@ export const createReactNativeRuntimeTransformPlugin: ReactNativeEsbuildPluginCr ); } - return { code, done: true }; + return { + code: shouldWrapModule + ? wrapModuleBoundary( + code, + moduleMeta.hash, + bundlerSharedData.watcher.changed === args.path, + ) + : code, + done: true, + }; }; let transformPipeline: AsyncTransformPipeline; @@ -128,7 +140,7 @@ export const createReactNativeRuntimeTransformPlugin: ReactNativeEsbuildPluginCr return { contents: (await transformPipeline.transform(rawCode, args)).code, loader: 'js', - } as OnLoadResult; + }; }); build.onEnd(async (args) => { diff --git a/packages/plugins/package.json b/packages/plugins/package.json index aac9060c..946fc2a5 100644 --- a/packages/plugins/package.json +++ b/packages/plugins/package.json @@ -46,6 +46,7 @@ "dependencies": { "@react-native-esbuild/config": "workspace:*", "@react-native-esbuild/core": "workspace:*", + "@react-native-esbuild/hmr": "workspace:*", "@react-native-esbuild/internal": "workspace:*", "@react-native-esbuild/transformer": "workspace:*", "@react-native-esbuild/utils": "workspace:*", diff --git a/packages/transformer/lib/transformer/swc/presets.ts b/packages/transformer/lib/transformer/swc/presets.ts index f42394f5..31da03cc 100644 --- a/packages/transformer/lib/transformer/swc/presets.ts +++ b/packages/transformer/lib/transformer/swc/presets.ts @@ -1,4 +1,8 @@ -import type { Options, TsParserConfig, EsParserConfig } from '@swc/core'; +import type { EsParserConfig, Options, TsParserConfig } from '@swc/core'; +import { + REACT_REFRESH_REGISTER_FUNCTION, + REACT_REFRESH_SIGNATURE_FUNCTION, +} from '@react-native-esbuild/hmr'; import type { TransformerOptionsPreset, SwcJestPresetOptions, @@ -38,7 +42,13 @@ const getReactNativeRuntimePreset = (): TransformerOptionsPreset => { react: { runtime: 'automatic', development: context.dev, - refresh: false, + // @ts-expect-error -- Wrong type definition. + refresh: context.path.includes('/node_modules/') + ? false + : { + refreshReg: REACT_REFRESH_REGISTER_FUNCTION, + refreshSig: REACT_REFRESH_SIGNATURE_FUNCTION, + }, }, }, }, diff --git a/packages/transformer/lib/types.ts b/packages/transformer/lib/types.ts index 52591bbe..2a3c33e2 100644 --- a/packages/transformer/lib/types.ts +++ b/packages/transformer/lib/types.ts @@ -27,11 +27,6 @@ export type TransformerOptionsPreset = ( context: TransformerContext, ) => TransformerOptions; -// swc preset options -export interface SwcReactNativeRuntimePresetOptions { - reactRefresh?: { moduleId: string }; -} - export interface SwcJestPresetOptions { module?: 'cjs' | 'esm'; experimental?: { diff --git a/packages/transformer/package.json b/packages/transformer/package.json index 4c10fcb3..75b9571c 100644 --- a/packages/transformer/package.json +++ b/packages/transformer/package.json @@ -44,11 +44,13 @@ "dependencies": { "@babel/core": "^7.23.2", "@react-native-esbuild/config": "workspace:*", + "@react-native-esbuild/hmr": "workspace:*", "@swc/core": "^1.3.95", "@swc/helpers": "^0.5.3", "md5": "^2.3.0", "sucrase": "^3.34.0", "swc-plugin-coverage-instrument": "^0.0.20", + "swc-plugin-react-refresh": "^0.1.0-alpha.5", "swc_mut_cjs_exports": "^0.85.0" } } diff --git a/shared/index.js b/shared/index.js index 931f6fc5..33c3386d 100644 --- a/shared/index.js +++ b/shared/index.js @@ -1,15 +1,24 @@ const path = require('node:path'); /** - * @param {string} entryFile filename + * @param {string} packageDir build script directory * @param {import('esbuild').BuildOptions} options additional options * @returns {import('esbuild').BuildOptions} */ -exports.getEsbuildBaseOptions = (packageDir, options = {}) => ({ - entryPoints: [path.resolve(packageDir, '../lib/index.ts')], +const getEsbuildBaseOptions = (packageDir, options = {}) => ({ + entryPoints: [path.join(getPackageRoot(packageDir), 'lib/index.ts')], outfile: 'dist/index.js', bundle: true, platform: 'node', packages: 'external', ...options, }); + +/** + * @param {string} packageDir build script directory + * @returns package root path + */ +const getPackageRoot = (packageDir) => path.resolve(packageDir, '../'); + +exports.getEsbuildBaseOptions = getEsbuildBaseOptions; +exports.getPackageRoot = getPackageRoot; diff --git a/yarn.lock b/yarn.lock index 3cca9659..6f1e630f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4335,6 +4335,7 @@ __metadata: "@babel/core": ^7.23.2 "@faker-js/faker": ^8.1.0 "@react-native-esbuild/config": "workspace:*" + "@react-native-esbuild/hmr": "workspace:*" "@react-native-esbuild/internal": "workspace:*" "@react-native-esbuild/transformer": "workspace:*" "@react-native-esbuild/utils": "workspace:*" @@ -4357,6 +4358,7 @@ __metadata: "@react-native-community/cli-server-api": ^11.3.6 "@react-native-esbuild/config": "workspace:*" "@react-native-esbuild/core": "workspace:*" + "@react-native-esbuild/hmr": "workspace:*" "@react-native-esbuild/symbolicate": "workspace:*" "@react-native-esbuild/utils": "workspace:*" "@types/connect": ^3.4.35 @@ -4372,6 +4374,20 @@ __metadata: languageName: unknown linkType: soft +"@react-native-esbuild/hmr@workspace:*, @react-native-esbuild/hmr@workspace:packages/hmr": + version: 0.0.0-use.local + resolution: "@react-native-esbuild/hmr@workspace:packages/hmr" + dependencies: + "@react-native-esbuild/transformer": "workspace:*" + "@react-native-esbuild/utils": "workspace:*" + "@swc/helpers": ^0.5.2 + "@types/react-refresh": ^0.14.3 + esbuild: ^0.19.3 + react-refresh: ^0.14.0 + ws: ^8.14.2 + languageName: unknown + linkType: soft + "@react-native-esbuild/internal@workspace:*, @react-native-esbuild/internal@workspace:packages/internal": version: 0.0.0-use.local resolution: "@react-native-esbuild/internal@workspace:packages/internal" @@ -4406,6 +4422,7 @@ __metadata: "@faker-js/faker": ^8.1.0 "@react-native-esbuild/config": "workspace:*" "@react-native-esbuild/core": "workspace:*" + "@react-native-esbuild/hmr": "workspace:*" "@react-native-esbuild/internal": "workspace:*" "@react-native-esbuild/transformer": "workspace:*" "@react-native-esbuild/utils": "workspace:*" @@ -4437,6 +4454,7 @@ __metadata: dependencies: "@babel/core": ^7.23.2 "@react-native-esbuild/config": "workspace:*" + "@react-native-esbuild/hmr": "workspace:*" "@swc/core": ^1.3.95 "@swc/helpers": ^0.5.3 "@types/md5": ^2.3.4 @@ -4444,6 +4462,7 @@ __metadata: md5: ^2.3.0 sucrase: ^3.34.0 swc-plugin-coverage-instrument: ^0.0.20 + swc-plugin-react-refresh: ^0.1.0-alpha.5 swc_mut_cjs_exports: ^0.85.0 languageName: unknown linkType: soft @@ -5231,7 +5250,7 @@ __metadata: languageName: node linkType: hard -"@swc/helpers@npm:^0.5.3": +"@swc/helpers@npm:^0.5.2, @swc/helpers@npm:^0.5.3": version: 0.5.3 resolution: "@swc/helpers@npm:0.5.3" dependencies: @@ -5350,7 +5369,7 @@ __metadata: languageName: node linkType: hard -"@types/babel__core@npm:^7.1.14": +"@types/babel__core@npm:*, @types/babel__core@npm:^7.1.14": version: 7.20.3 resolution: "@types/babel__core@npm:7.20.3" dependencies: @@ -5709,6 +5728,16 @@ __metadata: languageName: node linkType: hard +"@types/react-refresh@npm:^0.14.3": + version: 0.14.3 + resolution: "@types/react-refresh@npm:0.14.3" + dependencies: + "@types/babel__core": "*" + csstype: ^3.0.2 + checksum: ff48c18f928f213cfd1fa379f6b275e401adad87f909a83501a7026075a1d596ef2697269a9ce4fc5063841622ea2c2ffe9c332a2cc9e5ca67199f05c7994f89 + languageName: node + linkType: hard + "@types/react-router-config@npm:*, @types/react-router-config@npm:^5.0.6": version: 5.0.9 resolution: "@types/react-router-config@npm:5.0.9" @@ -10120,7 +10149,7 @@ __metadata: languageName: node linkType: hard -"esbuild@npm:^0.19.5": +"esbuild@npm:^0.19.3, esbuild@npm:^0.19.5": version: 0.19.5 resolution: "esbuild@npm:0.19.5" dependencies: @@ -20474,6 +20503,13 @@ __metadata: languageName: node linkType: hard +"swc-plugin-react-refresh@npm:^0.1.0-alpha.5": + version: 0.1.0-alpha.5 + resolution: "swc-plugin-react-refresh@npm:0.1.0-alpha.5" + checksum: 7eacb193e6097699a13415c43e981f44c6ce2eb881cf2669779c96ee1b3405bfc9ec3d3395e7343e100b1d811317bb9f1caaf7bbb2dffcc2b46e16fd5e6a70f0 + languageName: node + linkType: hard + "swc_mut_cjs_exports@npm:^0.85.0": version: 0.85.0 resolution: "swc_mut_cjs_exports@npm:0.85.0" @@ -22192,7 +22228,7 @@ __metadata: languageName: node linkType: hard -"ws@npm:^8.13.0": +"ws@npm:^8.13.0, ws@npm:^8.14.2": version: 8.14.2 resolution: "ws@npm:8.14.2" peerDependencies: