diff --git a/README.md b/README.md index 08c48f8..2e96dbd 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,28 @@ session transcript(JSONL),只讀取新增的位元組,轉譯成事件推播給 | 專案 15 秒沒動靜 | 待命;5 分鐘沒動靜 → 離線(Zzz) | 注意:監看伺服器會讀取本機的 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`。 ## 架構 diff --git a/ecosystem.config.cjs b/ecosystem.config.cjs new file mode 100644 index 0000000..dc60600 --- /dev/null +++ b/ecosystem.config.cjs @@ -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, + }, + ], +}; diff --git a/server/serve.mjs b/server/serve.mjs new file mode 100644 index 0000000..760fcff --- /dev/null +++ b/server/serve.mjs @@ -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/)`); +}); diff --git a/server/watch.mjs b/server/watch.mjs index 904f23b..f330800 100644 --- a/server/watch.mjs +++ b/server/watch.mjs @@ -253,7 +253,8 @@ const server = createServer((req, res) => { }); 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(` watching ${PROJECTS_ROOT}`); console.log(` projects ${projects.size} active in last ${ACTIVE_WINDOW_DAYS} days`);