diff --git a/.changeset/better-doors-walk.md b/.changeset/better-doors-walk.md new file mode 100644 index 000000000000..1e7e26dad5c0 --- /dev/null +++ b/.changeset/better-doors-walk.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/server/src/dev.ts b/packages/server/server/src/dev.ts index 46345020b924..58ce05233a28 100644 --- a/packages/server/server/src/dev.ts +++ b/packages/server/server/src/dev.ts @@ -32,6 +32,7 @@ export const devPlugin = (options: DevPluginOptions): ServerPluginLegacy => ({ const closeCb: Array<(...args: []) => any> = []; const dev = getDevOptions(options); + let previousCleanup: (() => void) | undefined; return { async prepare() { @@ -59,11 +60,23 @@ export const devPlugin = (options: DevPluginOptions): ServerPluginLegacy => ({ // TODO: remove any const hooks = (api as any).getHooks(); + let currentClosed = false; + const currentCleanup = () => { + if (!currentClosed) { + currentClosed = true; + } + }; + + previousCleanup = currentCleanup; + builder?.onDevCompileDone(({ stats }) => { + if (currentClosed) return; + if (stats.toJson({ all: false }).name !== 'server') { onRepack(distDirectory, hooks); } }); + builder?.onCloseDevServer(currentCleanup); if (dev.watch) { const { watchOptions } = config.server; diff --git a/packages/solutions/app-tools/package.json b/packages/solutions/app-tools/package.json index d5dc717a3607..cae8218d57b7 100644 --- a/packages/solutions/app-tools/package.json +++ b/packages/solutions/app-tools/package.json @@ -53,6 +53,11 @@ "types": "./dist/types/exports/server.d.ts", "jsnext:source": "./src/exports/server.ts", "default": "./dist/cjs/exports/server.js" + }, + "./server/plugin": { + "types": "./dist/types/exports/serverPlugin.d.ts", + "jsnext:source": "./src/exports/serverPlugin.ts", + "default": "./dist/cjs/exports/serverPlugin.js" } }, "engines": { @@ -72,6 +77,9 @@ "server": [ "./dist/types/exports/server.d.ts" ], + "server/plugin": [ + "./dist/types/exports/serverPlugin.d.ts" + ], "deploy": [ "./dist/types/plugins/deploy/exports.d.ts" ] diff --git a/packages/solutions/app-tools/src/commands/dev.ts b/packages/solutions/app-tools/src/commands/dev.ts index c2e08bcf3381..8e3b12f97c41 100644 --- a/packages/solutions/app-tools/src/commands/dev.ts +++ b/packages/solutions/app-tools/src/commands/dev.ts @@ -1,7 +1,7 @@ import path from 'node:path'; import type { CLIPluginAPI } from '@modern-js/plugin-v2'; import { applyPlugins } from '@modern-js/prod-server'; -import { type ApplyPlugins, createDevServer } from '@modern-js/server'; +import type { ApplyPlugins } from '@modern-js/server'; import { DEFAULT_DEV_HOST, SERVER_DIR, @@ -10,7 +10,11 @@ import { } from '@modern-js/utils'; import type { AppNormalizedConfig, AppTools } from '../types'; import { buildServerConfig } from '../utils/config'; -import { setServer } from '../utils/createServer'; +import { + createServer, + setServer, + setServerOptions, +} from '../utils/createServer'; import { loadServerPlugins } from '../utils/loadPlugins'; import { printInstructions } from '../utils/printInstructions'; import { registerCompiler } from '../utils/register'; @@ -124,11 +128,12 @@ export const dev = async ( const host = normalizedConfig.dev?.host || DEFAULT_DEV_HOST; if (apiOnly) { - const { server } = await createDevServer( - { - ...serverOptions, - runCompile: false, - }, + const options = { + ...serverOptions, + runCompile: false, + }; + const { server } = await createServer( + options, devServerOptions?.applyPlugins || applyPlugins, ); @@ -145,13 +150,13 @@ export const dev = async ( ); }, ); - setServer(server); } else { - const { server, afterListen } = await createDevServer( - { - ...serverOptions, - builder: appContext.builder, - }, + const options = { + ...serverOptions, + builder: appContext.builder, + }; + const { server, afterListen } = await createServer( + options, devServerOptions?.applyPlugins || applyPlugins, ); @@ -170,7 +175,5 @@ export const dev = async ( await afterListen(); }, ); - - setServer(server); } }; diff --git a/packages/solutions/app-tools/src/exports/serverPlugin.ts b/packages/solutions/app-tools/src/exports/serverPlugin.ts new file mode 100644 index 000000000000..539170b97c03 --- /dev/null +++ b/packages/solutions/app-tools/src/exports/serverPlugin.ts @@ -0,0 +1,26 @@ +import path from 'path'; +import type { ServerPluginLegacy } from '@modern-js/server-core'; +import { restart } from '../utils/createServer'; + +export default (): ServerPluginLegacy => ({ + name: '@modern-js/server-hmr-plugin', + setup: api => { + return { + async reset({ event }) { + if (event.type === 'file-change') { + const { appDirectory } = api.useAppContext(); + 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 restart(); + } + } + }, + }; + }, +}); diff --git a/packages/solutions/app-tools/src/plugins/serverRuntime.ts b/packages/solutions/app-tools/src/plugins/serverRuntime.ts index 79fb7ecb0b48..b207afaef575 100644 --- a/packages/solutions/app-tools/src/plugins/serverRuntime.ts +++ b/packages/solutions/app-tools/src/plugins/serverRuntime.ts @@ -12,5 +12,11 @@ export default (): CliPluginFuture> => ({ ], }, })); + api._internalServerPlugins(({ plugins }) => { + plugins.push({ + name: '@modern-js/app-tools/server/plugin', + }); + return { plugins }; + }); }, }); diff --git a/packages/solutions/app-tools/src/utils/createServer.ts b/packages/solutions/app-tools/src/utils/createServer.ts index 3b7513407dd9..faa10fb05c8d 100644 --- a/packages/solutions/app-tools/src/utils/createServer.ts +++ b/packages/solutions/app-tools/src/utils/createServer.ts @@ -1,33 +1,158 @@ -import type { Server } from 'node:http'; +import type net from 'net'; +import type { Server as HttpServer } 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; +type AnyServer = HttpServer | Http2SecureServer; +type Socket = net.Socket; +let server: AnyServer | null = null; +let initialServerOptions: ModernDevServerOptions | null = null; +const activeSockets = new Set(); +let restartCallback: (() => Promise) | null = null; export const getServer = () => server; -export const setServer = (newServer: Server | Http2SecureServer) => { +export const setServer = (newServer: AnyServer) => { server = newServer; }; -export const closeServer = async () => { - if (server) { - server.close(); - server = null; - } +export const closeServer = (timeout = 5000): Promise => { + return new Promise((resolve, reject) => { + if (!server) { + resolve(); + return; + } + + const cleanupSockets = () => { + for (const socket of activeSockets) { + try { + socket.destroy(); + } catch (e) { + console.error('Error destroying socket:', e); + } + } + activeSockets.clear(); + }; + + let isClosed = false; + cleanupSockets(); + + const timer = setTimeout(() => { + if (isClosed) return; + isClosed = true; + + cleanupSockets(); + server?.removeAllListeners(); + reject(new Error(`Server close timed out after ${timeout}ms`)); + }, timeout); + + server.close(err => { + if (isClosed) return; + isClosed = true; + + clearTimeout(timer); + cleanupSockets(); + server?.removeAllListeners(); + server = null; + + if (err) { + reject(err); + } else { + resolve(); + } + }); + }); }; export const createServer = async ( options: ModernDevServerOptions, -): Promise => { + applyPluginsFn?: ApplyPlugins, +) => { if (server) { - server.close(); + try { + await closeServer(); + } catch (error) { + console.error('Error closing existing server:', error); + } + } + + const { server: newServer, afterListen } = await createDevServer( + options, + applyPluginsFn || applyPlugins, + ); + + server = newServer; + + server.on('connection', (socket: Socket) => { + activeSockets.add(socket); + + socket.on('close', () => { + activeSockets.delete(socket); + }); + }); + + setServerOptions(options); + setServer(newServer); + return { server, afterListen }; +}; + +export const setServerOptions = (options: ModernDevServerOptions): void => { + initialServerOptions = options; +}; + +export const restart = async (): Promise => { + if (!initialServerOptions) { + throw new Error('Cannot restart server: Initial options not available'); + } + + try { + await closeServer(); + } catch (error) { + console.error('Error closing server during restart:', error); } - server = (await createDevServer(options, applyPlugins)).server; - return server; + const { server: newServer, afterListen } = await createDevServer( + { + ...initialServerOptions, + }, + applyPlugins, + ); + + server = newServer; + restartCallback = afterListen; + + server.on('connection', (socket: Socket) => { + activeSockets.add(socket); + + socket.on('close', () => { + activeSockets.delete(socket); + }); + }); + + return new Promise((resolve, reject) => { + server!.listen( + initialServerOptions?.dev.port, + initialServerOptions?.dev.host, + async (err?: Error) => { + if (err) { + reject(err); + return; + } + + try { + if (restartCallback) { + await restartCallback(); + } + resolve(server!); + } catch (e) { + reject(e); + } + }, + ); + }); }; diff --git a/packages/solutions/app-tools/tests/utils.test.ts b/packages/solutions/app-tools/tests/utils.test.ts index 9678c982d3ac..c2b1675cbe40 100644 --- a/packages/solutions/app-tools/tests/utils.test.ts +++ b/packages/solutions/app-tools/tests/utils.test.ts @@ -1,9 +1,11 @@ import { Server } from 'http'; +import net from 'net'; import { chalk } from '@modern-js/utils'; import { closeServer, createServer, getServer, + restart, } from '../src/utils/createServer'; import { getSelectedEntries } from '../src/utils/getSelectedEntries'; @@ -78,11 +80,35 @@ describe('test app-tools utils', () => { security: {}, }, appContext: {}, - dev: {}, + dev: { + port: 3000, + host: 'localhost', + }, }); + app.server.listen(3000); + + expect(app.server instanceof Server).toBe(true); + const originalServer = getServer(); + expect(originalServer).toBe(app.server); + + await restart(); + const restartedServer = getServer(); + expect(restartedServer).not.toBeNull(); + expect(restartedServer).not.toBe(originalServer); - expect(app instanceof Server).toBe(true); - expect(getServer()).toBe(app); + const address = restartedServer!.address(); + expect(address).not.toBeNull(); + expect((address as any).port).toBe(3000); + + const isPortListening = await new Promise(resolve => { + const socket = net + .connect({ port: 3000 }, () => { + socket.end(); + resolve(true); + }) + .on('error', () => resolve(false)); + }); + expect(isPortListening).toBe(true); await closeServer(); expect(getServer()).toBeNull();