新增 PM2 部署設定與生產用靜態伺服器

摘要:
加入 ecosystem.config.cjs(web + watch 兩個 app)與零依賴靜態伺服器
server/serve.mjs,並將監看伺服器改為僅綁 127.0.0.1。

根本原因:
先前只能用 vite dev server 開發模式跑,沒有常駐部署方式;
且 watch.mjs 預設綁所有介面,transcript 資料有暴露到區網的風險。

影響:
pm2 start ecosystem.config.cjs 即可常駐:
- claude-office-web:5180,綁 0.0.0.0,區網可看(模擬模式)
- claude-office-watch:5179,僅 127.0.0.1,實況模式限本機瀏覽器

修法:
- server/serve.mjs:零依賴靜態服務 dist/,SPA fallback,
  assets 走 immutable 快取
- watch.mjs listen 加上 127.0.0.1
- README 補 PM2 部署章節(含 Windows 開機自啟說明)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-20 13:53:54 +08:00
co-authored by Claude Fable 5
parent 1dc4920b32
commit bc78457372
4 changed files with 101 additions and 2 deletions
+22 -1
View File
@@ -53,7 +53,28 @@ session transcript(JSONL),只讀取新增的位元組,轉譯成事件推播給
| 專案 15 秒沒動靜 | 待命;5 分鐘沒動靜 → 離線(Zzz) | | 專案 15 秒沒動靜 | 待命;5 分鐘沒動靜 → 離線(Zzz) |
注意:監看伺服器會讀取本機的 Claude Code 對話記錄(含程式碼與提示詞), 注意:監看伺服器會讀取本機的 Claude Code 對話記錄(含程式碼與提示詞),
**只綁 localhost、不要對外開放**;如需團隊共用請自行加上驗證。 **只綁 127.0.0.1、不要對外開放**;如需團隊共用請自行加上驗證。
## PM2 部署
```bash
npm run build # 產出 dist/
pm2 start ecosystem.config.cjs # 啟動 web(5180)+ watch(5179)
pm2 save # 記住程序清單
```
| App | Port | 綁定 | 說明 |
| --- | --- | --- | --- |
| `claude-office-web` | 5180 | 0.0.0.0 | `server/serve.mjs` 靜態服務 dist/(區網手機可看,模擬模式) |
| `claude-office-watch` | 5179 | 127.0.0.1 | transcript 監看,僅本機(實況模式只在本機瀏覽器可用) |
常用指令:`pm2 logs claude-office-web``pm2 restart claude-office-web`
`pm2 delete claude-office-web claude-office-watch`
更新版本:`git pull && npm run build && pm2 restart all`(靜態檔重讀即可,
其實只有 server/ 變動才需要 restart)。
Windows 開機自啟:PM2 原生 `pm2 startup` 不支援 Windows,可另裝
[`pm2-windows-startup`](https://www.npmjs.com/package/pm2-windows-startup) 或用工作排程器執行 `pm2 resurrect`
## 架構 ## 架構
+20
View File
@@ -0,0 +1,20 @@
module.exports = {
apps: [
{
name: "claude-office-web",
script: "server/serve.mjs",
cwd: __dirname,
env: { PORT: 5180 },
max_restarts: 5,
restart_delay: 3000,
},
{
name: "claude-office-watch",
script: "server/watch.mjs",
cwd: __dirname,
env: { PORT: 5179 },
max_restarts: 5,
restart_delay: 3000,
},
],
};
+57
View File
@@ -0,0 +1,57 @@
/**
* Minimal static server for the production build (dist/).
* Zero dependencies. SPA fallback to index.html.
* Binds 0.0.0.0 so the office is viewable from the LAN;
* the transcript watcher stays localhost-only.
*/
import { createServer } from "node:http";
import { promises as fs } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const PORT = Number(process.env.PORT || 5180);
const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "dist");
const MIME = {
".html": "text/html; charset=utf-8",
".js": "text/javascript",
".css": "text/css",
".svg": "image/svg+xml",
".png": "image/png",
".ico": "image/x-icon",
".json": "application/json",
".woff2": "font/woff2",
".map": "application/json",
};
const server = createServer(async (req, res) => {
try {
const urlPath = decodeURIComponent((req.url || "/").split("?")[0]);
let filePath = path.normalize(path.join(ROOT, urlPath));
if (!filePath.startsWith(ROOT)) {
res.writeHead(403);
res.end("forbidden");
return;
}
const stat = await fs.stat(filePath).catch(() => null);
if (!stat || stat.isDirectory()) {
filePath = path.join(ROOT, "index.html");
}
const ext = path.extname(filePath);
const data = await fs.readFile(filePath);
res.writeHead(200, {
"Content-Type": MIME[ext] || "application/octet-stream",
"Cache-Control": urlPath.startsWith("/assets/")
? "public, max-age=31536000, immutable"
: "no-cache",
});
res.end(data);
} catch {
res.writeHead(500);
res.end("server error");
}
});
server.listen(PORT, "0.0.0.0", () => {
console.log(`✳ Claude Office web http://localhost:${PORT} (serving dist/)`);
});
+2 -1
View File
@@ -253,7 +253,8 @@ const server = createServer((req, res) => {
}); });
await pollOnce(true); await pollOnce(true);
server.listen(PORT, () => { // Transcripts are sensitive — never expose beyond this machine.
server.listen(PORT, "127.0.0.1", () => {
console.log(`✳ Claude Office watcher`); console.log(`✳ Claude Office watcher`);
console.log(` watching ${PROJECTS_ROOT}`); console.log(` watching ${PROJECTS_ROOT}`);
console.log(` projects ${projects.size} active in last ${ACTIVE_WINDOW_DAYS} days`); console.log(` projects ${projects.size} active in last ${ACTIVE_WINDOW_DAYS} days`);