本文面向想把 Web 服务嵌入 Electron 桌面应用的开发者。预计阅读时间10 分钟最终效果理解 Function() 构造器绕过 CJS 限制、端口检测、CSP 安全头、生命周期管理的完整方案。同一个服务器两种运行模式ChatCrystal 的 Fastify 服务器既可以独立运行npm start也可以嵌入 Electron 桌面应用。两种模式共享同一份服务器代码区别只在启动方式和生命周期管理。独立模式下server/src/index.ts底部的自启动逻辑直接调用createServer()if(!process.env.ELECTRON!process.env.CRYSTAL_CLI){createServer().then(({shutdown}){consthandle()shutdown().then(()process.exit(0));process.on(SIGINT,handle);process.on(SIGTERM,handle);}).catch((err){console.error(Failed to start server:,err);process.exit(1);});}Electron 模式下这段代码不会执行——主进程通过ELECTRON环境变量抑制自启动改为手动调用createServer()并控制生命周期。Function() 构造器绕过 CJS 的 ESM 导入Electron 的主进程默认运行在 CommonJS 模块系统下。但 ChatCrystal 的服务器是 ESM 模块type: moduleimport.meta。直接用import()在 CJS 上下文中会被 Electron 的打包器拦截或报错。解决方案是用Function()构造器创建一个动态导入constserverEntrypathToFileURL(path.join(app.getAppPath(),server,dist,server,src,index.js),).href;constserverModuleawaitFunction(specifier,return import(specifier),)(serverEntry);Function()构造器创建的函数运行在全局作用域不受当前模块的 CJS 上下文限制。pathToFileURL()把文件路径转成file://URL这是 ESMimport()要求的格式。代码中的注释明确标注了这是一个有意为之的 workaroundC-1: Function() constructor is used intentionally to bypass Electron’s CJS bundler restrictions on dynamic import(). This is a known workaround for loading ESM server modules from a CJS main process.如果未来 Electron 主进程迁移到 ESM可以直接用import()替换。端口检测优雅降级桌面应用不能假设端口一定可用。用户可能同时运行开发服务器或者端口被其他程序占用。ChatCrystal 的端口检测逻辑functionfindFreePort(preferred:number):Promisenumber{returnnewPromise((resolve,reject){constsrvnet.createServer();srv.listen(preferred,127.0.0.1,(){srv.close(()resolve(preferred));});srv.on(error,(){constsrv2net.createServer();srv2.listen(0,127.0.0.1,(){constport(srv2.address()asnet.AddressInfo).port;srv2.close(()resolve(port));});});});}先尝试首选端口 3721。如果被占用监听端口 0 让操作系统分配随机可用端口。主进程在启动服务器前调用这个函数serverPortawaitfindFreePort(3721);if(serverPort!3721){console.log([Electron] Port 3721 occupied, using port${serverPort});}找到端口后传递给createServer({ port, host: 127.0.0.1 })。host 绑定到127.0.0.1而不是0.0.0.0确保桌面应用的服务器只监听本地回环地址不暴露到网络。CSP 安全头生产环境的 XSS 防护AI 对话内容会被渲染成 Markdown其中可能包含恶意脚本。ChatCrystal 在 Electron 的生产模式下注入严格的 Content-Security-Policy 响应头if(!process.env.VITE_DEV_URL){session.defaultSession.webRequest.onHeadersReceived((details,callback){callback({responseHeaders:{...details.responseHeaders,Content-Security-Policy:[default-src self; script-src self; style-src self unsafe-inline; img-src self data: blob:; font-src self data:; connect-src self http://localhost:* ws://localhost:*; object-src none; base-uri self,],},});});}关键限制script-src self只允许加载同源脚本阻止内联脚本和eval()。style-src unsafe-inline是为了兼容 Tailwind CSS 的运行时样式注入。connect-src限制为 localhost因为服务器只在本地运行。开发模式下跳过 CSP因为 Vite 的 HMR热模块替换依赖内联脚本注入。生命周期管理启动、运行、关闭启动流程app.whenReady()回调中按顺序执行 9 个步骤确定数据目录DATA_DIR环境变量或~/.chatcrystal/data确保数据目录存在mkdirSync设置环境变量ELECTRONtrue,DATA_DIR,ELECTRON_PACKAGED注入 CSP 安全头生产模式检测可用端口启动 Fastify 服务器开发模式跳过——服务器由tsx独立运行创建 BrowserWindow加载应用 URL开发模式加载VITE_DEV_URL生产模式加载http://localhost:{port}创建系统托盘步骤 6 的条件判断实现了开发/生产模式的无缝切换。开发时 Electron 窗口连 Vite 开发服务器带 HMR生产时连嵌入的 Fastify 服务器。运行时行为窗口关闭不退出应用——而是隐藏到系统托盘win.on(close,(e){saveWindowState(win);if(!isQuitting){e.preventDefault();win.hide();}});系统托盘提供右键菜单打开窗口、搜索知识、浏览器打开、退出。双击托盘图标也能打开窗口。优雅关闭退出时的关闭顺序是watcher 停止 → 数据库保存 → Fastify 关闭 → 托盘销毁asyncfunctiongracefulShutdown():Promisevoid{if(serverShutdown){awaitserverShutdown();serverShutdownnull;}destroyTray();}serverShutdown是createServer()返回的 shutdown 函数内部依次执行watcher.close()→closeDatabase()→app.close()。before-quit事件处理器还有一个 10 秒超时机制——如果关闭过程卡住强制退出consttimeoutsetTimeout((){console.error([Electron] Shutdown timed out, forcing exit);app.exit(1);},10000);窗口状态持久化窗口位置和大小保存到%APPDATA%/ChatCrystal/window-state.json。每次 resize/move 事件都更新内存中的状态close 事件时写入文件。恢复时会验证保存的位置是否在当前显示器范围内——如果用户之前接了外接显示器现在拔掉了窗口不会跑到屏幕外面constdisplaysscreen.getAllDisplays();constvisibledisplays.some((d){constbd.bounds;returnstate.x!b.x-50state.x!b.xb.widthstate.y!b.y-50state.y!b.yb.height;});if(!visible){state.xundefined;state.yundefined;}50 像素的容差允许窗口边缘稍微超出屏幕用户可能故意把窗口部分隐藏在屏幕边缘。单实例锁app.requestSingleInstanceLock()确保只有一个 ChatCrystal 实例运行。如果用户尝试启动第二个实例主实例会收到second-instance事件把窗口恢复并聚焦app.on(second-instance,(){if(mainWindow){if(mainWindow.isMinimized())mainWindow.restore();mainWindow.show();mainWindow.focus();}});第二个实例直接退出。总结ChatCrystal 的 Electron 集成围绕一个核心思想同一个服务器不同的启动器。Fastify 服务器通过createServer()导出独立模式和 Electron 模式共享完全相同的代码。Function() 构造器解决了 CJS/ESM 模块系统的兼容问题端口检测保证了桌面环境的健壮性CSP 安全头防止了 AI 对话内容中的 XSS 攻击。整个生命周期从启动到关闭都有完善的错误处理和超时机制。项目地址github.com/ZengLiangYi/ChatCrystal如有疑问欢迎在 GitHub Issues 或私信交流很乐意解答。
Fastify 加 Electron:把 Web 服务嵌进桌面应用
发布时间:2026/6/2 19:29:10
本文面向想把 Web 服务嵌入 Electron 桌面应用的开发者。预计阅读时间10 分钟最终效果理解 Function() 构造器绕过 CJS 限制、端口检测、CSP 安全头、生命周期管理的完整方案。同一个服务器两种运行模式ChatCrystal 的 Fastify 服务器既可以独立运行npm start也可以嵌入 Electron 桌面应用。两种模式共享同一份服务器代码区别只在启动方式和生命周期管理。独立模式下server/src/index.ts底部的自启动逻辑直接调用createServer()if(!process.env.ELECTRON!process.env.CRYSTAL_CLI){createServer().then(({shutdown}){consthandle()shutdown().then(()process.exit(0));process.on(SIGINT,handle);process.on(SIGTERM,handle);}).catch((err){console.error(Failed to start server:,err);process.exit(1);});}Electron 模式下这段代码不会执行——主进程通过ELECTRON环境变量抑制自启动改为手动调用createServer()并控制生命周期。Function() 构造器绕过 CJS 的 ESM 导入Electron 的主进程默认运行在 CommonJS 模块系统下。但 ChatCrystal 的服务器是 ESM 模块type: moduleimport.meta。直接用import()在 CJS 上下文中会被 Electron 的打包器拦截或报错。解决方案是用Function()构造器创建一个动态导入constserverEntrypathToFileURL(path.join(app.getAppPath(),server,dist,server,src,index.js),).href;constserverModuleawaitFunction(specifier,return import(specifier),)(serverEntry);Function()构造器创建的函数运行在全局作用域不受当前模块的 CJS 上下文限制。pathToFileURL()把文件路径转成file://URL这是 ESMimport()要求的格式。代码中的注释明确标注了这是一个有意为之的 workaroundC-1: Function() constructor is used intentionally to bypass Electron’s CJS bundler restrictions on dynamic import(). This is a known workaround for loading ESM server modules from a CJS main process.如果未来 Electron 主进程迁移到 ESM可以直接用import()替换。端口检测优雅降级桌面应用不能假设端口一定可用。用户可能同时运行开发服务器或者端口被其他程序占用。ChatCrystal 的端口检测逻辑functionfindFreePort(preferred:number):Promisenumber{returnnewPromise((resolve,reject){constsrvnet.createServer();srv.listen(preferred,127.0.0.1,(){srv.close(()resolve(preferred));});srv.on(error,(){constsrv2net.createServer();srv2.listen(0,127.0.0.1,(){constport(srv2.address()asnet.AddressInfo).port;srv2.close(()resolve(port));});});});}先尝试首选端口 3721。如果被占用监听端口 0 让操作系统分配随机可用端口。主进程在启动服务器前调用这个函数serverPortawaitfindFreePort(3721);if(serverPort!3721){console.log([Electron] Port 3721 occupied, using port${serverPort});}找到端口后传递给createServer({ port, host: 127.0.0.1 })。host 绑定到127.0.0.1而不是0.0.0.0确保桌面应用的服务器只监听本地回环地址不暴露到网络。CSP 安全头生产环境的 XSS 防护AI 对话内容会被渲染成 Markdown其中可能包含恶意脚本。ChatCrystal 在 Electron 的生产模式下注入严格的 Content-Security-Policy 响应头if(!process.env.VITE_DEV_URL){session.defaultSession.webRequest.onHeadersReceived((details,callback){callback({responseHeaders:{...details.responseHeaders,Content-Security-Policy:[default-src self; script-src self; style-src self unsafe-inline; img-src self data: blob:; font-src self data:; connect-src self http://localhost:* ws://localhost:*; object-src none; base-uri self,],},});});}关键限制script-src self只允许加载同源脚本阻止内联脚本和eval()。style-src unsafe-inline是为了兼容 Tailwind CSS 的运行时样式注入。connect-src限制为 localhost因为服务器只在本地运行。开发模式下跳过 CSP因为 Vite 的 HMR热模块替换依赖内联脚本注入。生命周期管理启动、运行、关闭启动流程app.whenReady()回调中按顺序执行 9 个步骤确定数据目录DATA_DIR环境变量或~/.chatcrystal/data确保数据目录存在mkdirSync设置环境变量ELECTRONtrue,DATA_DIR,ELECTRON_PACKAGED注入 CSP 安全头生产模式检测可用端口启动 Fastify 服务器开发模式跳过——服务器由tsx独立运行创建 BrowserWindow加载应用 URL开发模式加载VITE_DEV_URL生产模式加载http://localhost:{port}创建系统托盘步骤 6 的条件判断实现了开发/生产模式的无缝切换。开发时 Electron 窗口连 Vite 开发服务器带 HMR生产时连嵌入的 Fastify 服务器。运行时行为窗口关闭不退出应用——而是隐藏到系统托盘win.on(close,(e){saveWindowState(win);if(!isQuitting){e.preventDefault();win.hide();}});系统托盘提供右键菜单打开窗口、搜索知识、浏览器打开、退出。双击托盘图标也能打开窗口。优雅关闭退出时的关闭顺序是watcher 停止 → 数据库保存 → Fastify 关闭 → 托盘销毁asyncfunctiongracefulShutdown():Promisevoid{if(serverShutdown){awaitserverShutdown();serverShutdownnull;}destroyTray();}serverShutdown是createServer()返回的 shutdown 函数内部依次执行watcher.close()→closeDatabase()→app.close()。before-quit事件处理器还有一个 10 秒超时机制——如果关闭过程卡住强制退出consttimeoutsetTimeout((){console.error([Electron] Shutdown timed out, forcing exit);app.exit(1);},10000);窗口状态持久化窗口位置和大小保存到%APPDATA%/ChatCrystal/window-state.json。每次 resize/move 事件都更新内存中的状态close 事件时写入文件。恢复时会验证保存的位置是否在当前显示器范围内——如果用户之前接了外接显示器现在拔掉了窗口不会跑到屏幕外面constdisplaysscreen.getAllDisplays();constvisibledisplays.some((d){constbd.bounds;returnstate.x!b.x-50state.x!b.xb.widthstate.y!b.y-50state.y!b.yb.height;});if(!visible){state.xundefined;state.yundefined;}50 像素的容差允许窗口边缘稍微超出屏幕用户可能故意把窗口部分隐藏在屏幕边缘。单实例锁app.requestSingleInstanceLock()确保只有一个 ChatCrystal 实例运行。如果用户尝试启动第二个实例主实例会收到second-instance事件把窗口恢复并聚焦app.on(second-instance,(){if(mainWindow){if(mainWindow.isMinimized())mainWindow.restore();mainWindow.show();mainWindow.focus();}});第二个实例直接退出。总结ChatCrystal 的 Electron 集成围绕一个核心思想同一个服务器不同的启动器。Fastify 服务器通过createServer()导出独立模式和 Electron 模式共享完全相同的代码。Function() 构造器解决了 CJS/ESM 模块系统的兼容问题端口检测保证了桌面环境的健壮性CSP 安全头防止了 AI 对话内容中的 XSS 攻击。整个生命周期从启动到关闭都有完善的错误处理和超时机制。项目地址github.com/ZengLiangYi/ChatCrystal如有疑问欢迎在 GitHub Issues 或私信交流很乐意解答。