diff --git a/.changeset/six-carpets-hope.md b/.changeset/six-carpets-hope.md new file mode 100644 index 000000000000..1e7e26dad5c0 --- /dev/null +++ b/.changeset/six-carpets-hope.md @@ -0,0 +1,7 @@ +--- +'@modern-js/app-tools': patch +'@modern-js/server': patch +--- + +feat: server config hot reload +feat: 支持自定义 web server 热更新 diff --git a/packages/server/core/src/serverBase.ts b/packages/server/core/src/serverBase.ts index 646d5089a8f2..9a2c40213c5b 100644 --- a/packages/server/core/src/serverBase.ts +++ b/packages/server/core/src/serverBase.ts @@ -180,6 +180,12 @@ export class ServerBase { get onError() { return this.app.onError.bind(this.app); } + + close() { + this.serverContext = null; + this.plugins = []; + (this.app as Hono | null) = null; + } } export function createServerBase(options: ServerBaseOptions) { diff --git a/packages/server/server/src/createDevServer.ts b/packages/server/server/src/createDevServer.ts index 104c31600288..c02babbb6e2a 100644 --- a/packages/server/server/src/createDevServer.ts +++ b/packages/server/server/src/createDevServer.ts @@ -4,51 +4,68 @@ import { createNodeServer, loadServerRuntimeConfig, } from '@modern-js/server-core/node'; -import { devPlugin } from './dev'; +import { logger } from '@modern-js/utils'; +import { devPlugin, manager } from './dev'; import { getDevAssetPrefix, getDevOptions } from './helpers'; +import { ResourceType } from './helpers/utils'; +import serverHmrPlugin from './plugins/serverReload'; import type { ApplyPlugins, ModernDevServerOptions } from './types'; -export async function createDevServer( +export let serverReload: (() => Promise) | null = null; + +async function createServerOptions( options: ModernDevServerOptions, - applyPlugins: ApplyPlugins, + serverConfigPath: string, + distDir: string, ) { - const { config, pwd, serverConfigPath, builder } = options; - const dev = getDevOptions(options.dev); - - const distDir = path.resolve(pwd, config.output.distPath?.root || 'dist'); - const serverConfig = (await loadServerRuntimeConfig(serverConfigPath)) || {}; - const prodServerOptions = { + return { ...options, - pwd: distDir, // server base pwd must distDir, + pwd: distDir, serverConfig: { ...serverConfig, ...options.serverConfig, }, - /** - * 1. server plugins from modern.server.ts - * 2. server plugins register by cli use _internalServerPlugins - * Merge plugins, the plugins from modern.server.ts will run first - */ plugins: [...(serverConfig.plugins || []), ...(options.plugins || [])], }; +} + +export async function createDevServer( + options: ModernDevServerOptions, + applyPlugins: ApplyPlugins, +) { + const { config, pwd, serverConfigPath, builder } = options; + const dev = getDevOptions(options.dev); + + const distDir = path.resolve(pwd, config.output.distPath?.root || 'dist'); - const server = createServerBase(prodServerOptions); + const prodServerOptions = await createServerOptions( + options, + serverConfigPath, + distDir, + ); + + let currentServer = createServerBase(prodServerOptions); + + let isReloading = false; const devHttpsOption = typeof dev === 'object' && dev.https; const isHttp2 = !!devHttpsOption; - let nodeServer; + + let nodeServer: Awaited>; if (devHttpsOption) { const { genHttpsOptions } = await import('./dev-tools/https'); const httpsOptions = await genHttpsOptions(devHttpsOption, pwd); nodeServer = await createNodeServer( - server.handle.bind(server), + (req, res) => currentServer.handle(req, res), httpsOptions, isHttp2, ); } else { - nodeServer = await createNodeServer(server.handle.bind(server)); + nodeServer = await createNodeServer((req, res) => + currentServer.handle(req, res), + ); } const promise = getDevAssetPrefix(builder); @@ -57,7 +74,40 @@ export async function createDevServer( compiler: options.compiler, }); - server.addPlugins([ + const reload = async () => { + if (isReloading) { + return; + } + isReloading = true; + + try { + await currentServer.close(); + + const updatedProdServerOptions = await createServerOptions( + options, + serverConfigPath, + distDir, + ); + const newServer = createServerBase(updatedProdServerOptions); + + await manager.close(ResourceType.Watcher); + + newServer.addPlugins([serverHmrPlugin(), devPlugin(options, true)]); + await applyPlugins(newServer, updatedProdServerOptions); + await newServer.init(); + + currentServer = newServer; + + logger.info(`Custom Web Server reload succeeded`); + } catch (e) { + logger.error('[Custom Web Server reload failed]:', e); + } finally { + isReloading = false; + } + }; + serverReload = reload; + currentServer.addPlugins([ + serverHmrPlugin(), devPlugin({ ...options, builderDevServer, @@ -70,9 +120,9 @@ export async function createDevServer( prodServerOptions.config.output.assetPrefix = assetPrefix; } - await applyPlugins(server, prodServerOptions, nodeServer); + await applyPlugins(currentServer, prodServerOptions, nodeServer); - await server.init(); + await currentServer.init(); const afterListen = async () => { await builderDevServer?.afterListen(); diff --git a/packages/server/server/src/dev.ts b/packages/server/server/src/dev.ts index 73c54a55c2d8..8d9bca8037ca 100644 --- a/packages/server/server/src/dev.ts +++ b/packages/server/server/src/dev.ts @@ -10,6 +10,7 @@ import { onRepack, startWatcher, } from './helpers'; +import { ResourceManager, ResourceType } from './helpers/utils'; import type { ModernDevServerOptions } from './types'; type BuilderDevServer = Awaited>; @@ -18,14 +19,17 @@ export type DevPluginOptions = ModernDevServerOptions & { builderDevServer?: BuilderDevServer; }; -export const devPlugin = (options: DevPluginOptions): ServerPlugin => ({ +export const manager = new ResourceManager(); + +export const devPlugin = ( + options: DevPluginOptions, + isReload = false, +): ServerPlugin => ({ name: '@modern-js/plugin-dev', setup(api) { const { config, pwd, builder, builderDevServer } = options; - const closeCb: Array<(...args: []) => any> = []; - const dev = getDevOptions(options.dev); api.onPrepare(async () => { @@ -36,7 +40,9 @@ export const devPlugin = (options: DevPluginOptions): ServerPlugin => ({ connectWebSocket, } = builderDevServer || {}; - close && closeCb.push(close); + if (close) { + manager.register(ResourceType.Builder, close); + } const { middlewares, @@ -53,14 +59,13 @@ export const devPlugin = (options: DevPluginOptions): ServerPlugin => ({ // TODO: remove any const hooks = (api as any).getHooks(); - // Handle webpack rebuild - builder?.onDevCompileDone(({ stats }) => { - if (stats.toJson({ all: false }).name !== 'server') { - onRepack(distDirectory!, hooks); - } - }); + !isReload && + builder?.onDevCompileDone(({ stats }) => { + if (stats.toJson({ all: false }).name !== 'server') { + onRepack(distDirectory!, hooks); + } + }); - // Handle watch const { watchOptions } = config.server; const watcher = startWatcher({ pwd, @@ -70,13 +75,11 @@ export const devPlugin = (options: DevPluginOptions): ServerPlugin => ({ watchOptions, server: serverBase!, }); - closeCb.push(watcher.close.bind(watcher)); - closeCb.length > 0 && - nodeServer?.on('close', () => { - closeCb.forEach(cb => { - cb(); - }); - }); + manager.register(ResourceType.Watcher, watcher.close.bind(watcher)); + + nodeServer?.on('close', () => { + manager.closeAll(); + }); // Handle setupMiddlewares const before: RequestHandler[] = []; diff --git a/packages/server/server/src/helpers/utils.ts b/packages/server/server/src/helpers/utils.ts index 4142e7cc52c4..ec0b039fed49 100644 --- a/packages/server/server/src/helpers/utils.ts +++ b/packages/server/server/src/helpers/utils.ts @@ -1,3 +1,33 @@ import { createDebugger } from '@modern-js/utils'; export const debug = createDebugger('server'); + +export enum ResourceType { + Builder = 'builder', + Watcher = 'watcher', +} + +export class ResourceManager { + private resources: Record Promise) | null> = { + [ResourceType.Builder]: null, + [ResourceType.Watcher]: null, + }; + + register(type: ResourceType, cb: () => Promise) { + this.resources[type] = cb; + } + + async close(type: ResourceType) { + await this.resources[type]?.(); + this.resources[type] = null; + } + + async closeAll() { + await Promise.allSettled([ + this.resources[ResourceType.Builder]?.() || Promise.resolve(), + this.resources[ResourceType.Watcher]?.() || Promise.resolve(), + ]); + this.resources[ResourceType.Builder] = null; + this.resources[ResourceType.Watcher] = null; + } +} diff --git a/packages/server/server/src/plugins/serverReload.ts b/packages/server/server/src/plugins/serverReload.ts new file mode 100644 index 000000000000..6c06b271897f --- /dev/null +++ b/packages/server/server/src/plugins/serverReload.ts @@ -0,0 +1,24 @@ +import path from 'path'; +import type { ServerPlugin } from '@modern-js/server-core'; + +import { serverReload } from '../createDevServer'; + +export default (): ServerPlugin => ({ + name: '@modern-js/server-reload-plugin', + setup: api => { + api.onReset(async ({ event }) => { + if (event.type === 'file-change') { + const { appDirectory } = api.getServerContext(); + const serverPath = path.join(appDirectory, 'server'); + const indexPath = path.join(serverPath, 'index'); + const isServerFileChanged = event.payload.some( + ({ filename }) => + filename.startsWith(serverPath) && !filename.startsWith(indexPath), + ); + if (isServerFileChanged) { + await serverReload?.(); + } + } + }); + }, +}); diff --git a/packages/server/server/tests/reload.test.ts b/packages/server/server/tests/reload.test.ts new file mode 100644 index 000000000000..1b5123c07fac --- /dev/null +++ b/packages/server/server/tests/reload.test.ts @@ -0,0 +1,223 @@ +import path from 'path'; + +jest.mock('../src/createDevServer', () => ({ + serverReload: jest.fn().mockResolvedValue(undefined), +})); + +const { serverReload: mockReload } = require('../src/createDevServer'); + +import serverReloadPlugin from '../src/plugins/serverReload'; + +describe('serverReloadPlugin', () => { + let mockApi: any; + let plugin: any; + let onResetHandler: Function; + + beforeEach(() => { + mockReload.mockClear(); + + mockApi = { + getServerContext: jest.fn().mockReturnValue({ + appDirectory: '/mock/app/directory', + }), + onReset: jest.fn((callback: Function) => { + onResetHandler = callback; + }), + }; + + plugin = serverReloadPlugin(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('plugin setup', () => { + it('should register onReset handler correctly', () => { + expect(plugin.name).toBe('@modern-js/server-reload-plugin'); + expect(typeof plugin.setup).toBe('function'); + + plugin.setup(mockApi); + + expect(mockApi.onReset).toHaveBeenCalledTimes(1); + expect(typeof mockApi.onReset.mock.calls[0][0]).toBe('function'); + }); + }); + + describe('file change handling', () => { + let onResetHandler: any; + + beforeEach(() => { + plugin.setup(mockApi); + onResetHandler = mockApi.onReset.mock.calls[0][0]; + }); + + it('should call reload when server files change except index', async () => { + const event = { + type: 'file-change', + payload: [ + { filename: path.normalize('/mock/app/directory/server/api.js') }, + { filename: path.normalize('/mock/app/directory/server/utils.ts') }, + ], + }; + + await onResetHandler({ event }); + + expect(mockReload).toHaveBeenCalledTimes(1); + }); + + it('should not call reload when server index file changes', async () => { + const event = { + type: 'file-change', + payload: [ + { filename: path.normalize('/mock/app/directory/server/index.js') }, + { filename: path.normalize('/mock/app/directory/server/index.ts') }, + ], + }; + + await onResetHandler({ event }); + + expect(mockReload).not.toHaveBeenCalled(); + }); + + it('should not call reload when non-server files change', async () => { + const event = { + type: 'file-change', + payload: [ + { filename: path.normalize('/mock/app/directory/src/App.js') }, + { filename: path.normalize('/mock/app/directory/public/index.html') }, + ], + }; + + await onResetHandler({ event }); + + expect(mockReload).not.toHaveBeenCalled(); + }); + + it('should not call reload when event type is not file-change', async () => { + const event = { + type: 'other-event', + payload: [ + { filename: path.normalize('/mock/app/directory/server/api.js') }, + ], + }; + + await onResetHandler({ event }); + + expect(mockReload).not.toHaveBeenCalled(); + }); + + it('should handle mixed file changes correctly', async () => { + const event = { + type: 'file-change', + payload: [ + { filename: path.normalize('/mock/app/directory/server/api.js') }, // should trigger reload + { filename: path.normalize('/mock/app/directory/server/index.js') }, // should not trigger reload + { filename: path.normalize('/mock/app/directory/src/App.js') }, // should not trigger reload + ], + }; + + await onResetHandler({ event }); + + expect(mockReload).toHaveBeenCalledTimes(1); + }); + + it('should handle empty payload correctly', async () => { + const event = { + type: 'file-change', + payload: [], + }; + + await onResetHandler({ event }); + + expect(mockReload).not.toHaveBeenCalled(); + }); + + it('should handle server subdirectories correctly', async () => { + const event = { + type: 'file-change', + payload: [ + { + filename: path.normalize( + '/mock/app/directory/server/middleware/auth.js', + ), + }, + { + filename: path.normalize( + '/mock/app/directory/server/routes/users.ts', + ), + }, + ], + }; + + await onResetHandler({ event }); + + expect(mockReload).toHaveBeenCalledTimes(1); + }); + + it('should handle windows style paths correctly', async () => { + mockApi.getServerContext.mockReturnValue({ + appDirectory: 'C:\\mock\\app\\directory', + }); + + const event = { + type: 'file-change', + payload: [{ filename: 'C:\\mock\\app\\directory\\server\\api.js' }], + }; + + const originalJoin = path.join; + path.join = jest.fn().mockImplementation((...args) => { + return originalJoin(...args).replace(/\//g, '\\'); + }); + + await onResetHandler({ event }); + + expect(mockReload).toHaveBeenCalledTimes(1); + + path.join = originalJoin; + mockApi.getServerContext.mockReturnValue({ + appDirectory: '/mock/app/directory', + }); + }); + }); + + describe('error handling', () => { + let onResetHandler: any; + + beforeEach(() => { + plugin.setup(mockApi); + onResetHandler = mockApi.onReset.mock.calls[0][0]; + }); + + it('should propagate errors from reload function', async () => { + const testError = new Error('Test reload error'); + mockReload.mockRejectedValue(testError); + + const event = { + type: 'file-change', + payload: [ + { filename: path.normalize('/mock/app/directory/server/api.js') }, + ], + }; + + await expect(onResetHandler({ event })).rejects.toThrow( + 'Test reload error', + ); + }); + + it('should handle getServerContext errors', async () => { + mockApi.getServerContext.mockImplementation(() => { + throw new Error('Context error'); + }); + + const event = { + type: 'file-change', + payload: [ + { filename: path.normalize('/mock/app/directory/server/api.js') }, + ], + }; + + await expect(onResetHandler({ event })).rejects.toThrow('Context error'); + }); + }); +}); diff --git a/packages/solutions/app-tools/src/commands/dev.ts b/packages/solutions/app-tools/src/commands/dev.ts index bf9596d667f7..ca0bb5f533fa 100644 --- a/packages/solutions/app-tools/src/commands/dev.ts +++ b/packages/solutions/app-tools/src/commands/dev.ts @@ -16,7 +16,7 @@ import { import type { ConfigChain } from '@rsbuild/core'; import type { AppNormalizedConfig, AppTools } from '../types'; import { buildServerConfig } from '../utils/config'; -import { setServer } from '../utils/createServer'; +import { createServer, setServer } from '../utils/createServer'; import { loadServerPlugins } from '../utils/loadPlugins'; import { printInstructions } from '../utils/printInstructions'; import { registerCompiler } from '../utils/register'; @@ -105,7 +105,7 @@ export const dev = async ( const host = normalizedConfig.dev?.host || DEFAULT_DEV_HOST; if (apiOnly) { - const { server } = await createDevServer( + const { server } = await createServer( { ...serverOptions, runCompile: false, @@ -128,7 +128,7 @@ export const dev = async ( ); setServer(server); } else { - const { server, afterListen } = await createDevServer( + const { server, afterListen } = await createServer( { ...serverOptions, builder: appContext.builder, diff --git a/packages/solutions/app-tools/src/index.ts b/packages/solutions/app-tools/src/index.ts index bbc5fc88290a..bdcdd2001c78 100644 --- a/packages/solutions/app-tools/src/index.ts +++ b/packages/solutions/app-tools/src/index.ts @@ -28,7 +28,6 @@ import serverBuildPlugin from './plugins/serverBuild'; import serverRuntimePlugin from './plugins/serverRuntime'; import type { AppTools, CliPlugin } from './types'; import type { - AddRuntimeExportsFn, AfterPrepareFn, BeforeGenerateRoutesFn, BeforePrintInstructionsFn, diff --git a/packages/solutions/app-tools/src/utils/createServer.ts b/packages/solutions/app-tools/src/utils/createServer.ts index 92bd149895de..95cedcc62e83 100644 --- a/packages/solutions/app-tools/src/utils/createServer.ts +++ b/packages/solutions/app-tools/src/utils/createServer.ts @@ -1,5 +1,11 @@ import type { Server } from 'node:http'; import type { Http2SecureServer } from 'node:http2'; +import { applyPlugins } from '@modern-js/prod-server'; +import { + type ApplyPlugins, + type ModernDevServerOptions, + createDevServer, +} from '@modern-js/server'; let server: Server | Http2SecureServer | null = null; @@ -15,3 +21,22 @@ export const closeServer = async () => { server = null; } }; + +export const createServer = async ( + options: ModernDevServerOptions, + applyPluginsFn?: ApplyPlugins, +): Promise<{ + server: Server | Http2SecureServer; + afterListen: () => Promise; +}> => { + if (server) { + server.close(); + } + const { server: newServer, afterListen } = await createDevServer( + options, + applyPluginsFn || applyPlugins, + ); + + setServer(newServer); + return { server: newServer, afterListen }; +}; diff --git a/packages/solutions/app-tools/tests/utils.test.ts b/packages/solutions/app-tools/tests/utils.test.ts index 8d53bdfc2f29..c86b54533847 100644 --- a/packages/solutions/app-tools/tests/utils.test.ts +++ b/packages/solutions/app-tools/tests/utils.test.ts @@ -1,10 +1,4 @@ -import { Server } from 'http'; import { chalk } from '@modern-js/utils'; -import { - closeServer, - createServer, - getServer, -} from '../src/utils/createServer'; import { getSelectedEntries } from '../src/utils/getSelectedEntries'; jest.mock('@modern-js/utils', () => ({