桌面应用复用 Web 服务,关键是让窗口、监听端口和数据资源具有明确的生命周期。ChatCrystal 的固定实现已经包含本地、云端和引导模式,不能再按“一启动就创建本地服务”的单一路径介绍。本文沿当前代码核对启动、加载和退出,并用独立样本观察模块加载与端口竞争。
服务工厂和桌面入口分别管理什么
server/src/index.ts导出 createServer,初始化数据库和自动保存,配置 cloud/auth、路由及存在时的前端静态目录,再 await app.listen,返回 app、port 和 shutdown。直接运行服务时使用配置端口,默认 host 是 0.0.0.0;监听所有接口和桌面本地绑定不是同一暴露范围。
electron/main.ts取得单实例锁,在运行时设置 ELECTRON、DATA_DIR,打包态还设置 ELECTRON_PACKAGED。本地生产路径加载编译后的服务,并传 host=127.0.0.1、startWatcher=false。不要写成桌面嵌入服务必定开启文件 watcher;watcherPolicy.ts还规定云模式不启动它。
| 模式或条件 | 当前路径 |
|---|---|
| 本地生产模式 | ensureLocalCoreStarted 在尚无嵌入服务时探测端口、创建服务,再加载本地窗口 |
| VITE_DEV_URL 开发模式 | 加载开发页面,API 地址取外部开发 core;此路径不创建嵌入 core |
| 有已保存云端 URL/token 的云模式 | 加载云端地址并检查窗口来源,再注入认证状态;此打开路径不创建本地 core |
| 未选模式或模式打开失败 | 打开引导页,失败路径可显示错误信息 |
“云模式这次不启动 core”不等于“此前启动的本地 core 一定已关闭”。判断资源寿命还要跟踪模式切换与退出,不能只看打开窗口的函数。
CommonJS 可以动态加载 ESM
当前 importServerModule 检查编译产物路径,用 pathToFileURL 生成地址,再通过 Function 中的 import(specifier) 加载模块。这是仓库采用的写法,不是“CommonJS 无法原生动态加载 ESM”的证明。Node 模块文档说明动态 import 可在 CommonJS 中使用;Electron ESM 文档也要求区分主进程、渲染器和 preload 的加载环境与异步时序。
用 Node 24+ 做一个不涉及 Electron 或 ChatCrystal 的练习。先保存 fixture.mjs:
export async function ready() {
return { ready: true };
}
再保存同目录的 lesson.cjs,运行 node lesson.cjs:
const assert = require('node:assert/strict');
const path = require('node:path');
const { pathToFileURL } = require('node:url');
(async () => {
const url = pathToFileURL(path.join(__dirname, 'fixture.mjs')).href;
const service = await import(url);
assert.deepEqual(await service.ready(), { ready: true });
console.log('CommonJS dynamic ESM import passes');
})().catch((error) => {
console.error(error);
process.exitCode = 1;
});
把 fixture 的 ready 值改为 false,断言失败且进程返回非零;恢复后通过。这只验证 Node 的模块与等待行为,没有运行桌面主进程、preload 或打包产物。编译设置和实际输出仍应结合TypeScript 配置验收。
探测空闲端口不等于保留端口
findFreePort 先尝试在 127.0.0.1 监听偏好端口,关闭探测 server 后返回端口;探测出错则尝试 port=0。这不是一个跨关闭和重新监听的预约:其他进程可以在两步之间占用端口,且偏好探测的任意错误都会进入回退,不只是地址已占用。
在独立目录固定安装 npm install --ignore-scripts --save-exact fastify@5.12.3,保存下面的 port-lesson.mjs,用 Node 24+ 运行。它只启动回环上的教学服务,不启动实际应用或读写用户数据。
import assert from 'node:assert/strict';
import net from 'node:net';
import { once } from 'node:events';
import Fastify from 'fastify';
async function listen(server, port) {
server.listen({ host: '127.0.0.1', port });
await once(server, 'listening');
return server.address().port;
}
function close(server) {
return new Promise((resolve, reject) =>
server.close((error) => (error ? reject(error) : resolve()))
);
}
const probe = net.createServer();
const port = await listen(probe, 0);
await close(probe);
const other = net.createServer();
const conflict = Fastify();
const retained = Fastify();
try {
await listen(other, port);
await assert.rejects(conflict.listen({ host: '127.0.0.1', port }), { code: 'EADDRINUSE' });
retained.get('/health', async () => ({ ready: true }));
const address = await retained.listen({ host: '127.0.0.1', port: 0 });
const response = await fetch(address + '/health');
assert.equal(response.status, 200);
assert.deepEqual(await response.json(), { ready: true });
console.log('released probe and retained listener boundaries pass');
} finally {
await conflict.close();
await retained.close();
await close(other);
}
这里故意让 another listener 占住已释放的探测端口,实际观察 EADDRINUSE。再由 Fastify 直接监听 port=0,并保留该监听器,读取实际地址后访问 health。把就绪响应期望改成 false 会失败;恢复后通过。直接保留监听器是教学方案,当前 ChatCrystal 没有因此改成这条路径。Fastify listen 文档说明了 port=0 和返回 Promise 的行为。
窗口和安全头必须按环境核对
createWindow 根据模式选择 preload,当前设置 contextIsolation=true、nodeIntegration=false、sandbox=true。导航限制和来源检查还各有职责;这些设置不构成所有输入都安全的证明。
configureLocalContentSecurityPolicy 跳过 VITE_DEV_URL 开发态,根据响应 URL 是否为本地 HTTP 来源决定是否覆盖 CSP,不检查窗口模式或端口。非本地 HTTP 来源的云端响应不会进入这条覆盖逻辑;若云模式配置了 http://localhost,仍可能进入。策略包含 style-src 的 unsafe-inline 及指定本地连接规则;不能把“存在 CSP”写成彻底消除 XSS。Markdown 渲染、外链、IPC 和云端来源仍有各自输入边界。
关闭窗口时保存状态,尚未退出则 preventDefault 并隐藏;window-all-closed 也保留进程。第二个实例会唤起窗口。保存坐标超出所有显示器检查范围时会清除位置;宽高与可见性规则仍需真实多屏桌面验收,不宜把这些代码检查当成已完成桌面实测。
退出有顺序,也有失败和超时出口
服务 shutdown 依次 await watcher.close、closeDatabase、await app.close,并没有 finally 保证前一步抛错后仍执行后续步骤。桌面 before-quit 会阻止第一次退出,设置退出标志,启动十秒强制退出计时器,并尝试终止远端导入 worker、关闭已有本地服务、销毁托盘。finally 清除计时器并 app.quit;超时则 app.exit(1)。
因此“有优雅退出代码”不能升级成“任何异常下都保存成功”。分别验收监听失败、服务加载失败、窗口隐藏、正常退出、关闭步骤抛错和关闭超时,并检查数据是否实际落盘。测试策略中的失败路径思路也适用于这里;本文的 Node 和回环练习不替代真实 Electron 安装包验收。