pi-web-ui 0.32.0 → 0.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/pi-web-ui.mjs CHANGED
@@ -10,6 +10,8 @@
10
10
  * pi-web-ui server shortcut [选项] 在桌面创建「一键启动」图标(启动服务并打开浏览器)
11
11
  * pi-web-ui server uninstall [选项] 卸载系统服务(同时移除桌面图标)
12
12
  * pi-web-ui server start|stop|restart|status [选项]
13
+ * pi-web-ui install <源> [选项] 安装 GitHub 上的界面插件(见下方「界面插件」)
14
+ * pi-web-ui plugins / uninstall <id> 列出 / 卸载界面插件
13
15
  *
14
16
  * 系统服务:
15
17
  * - macOS → launchd 用户代理,label 默认 com.xingshuyin.pi-web-ui
@@ -28,15 +30,18 @@ import { get as httpGet } from "node:http";
28
30
  import {
29
31
  chmodSync,
30
32
  copyFileSync,
33
+ cpSync,
31
34
  existsSync,
32
35
  mkdirSync,
36
+ mkdtempSync,
33
37
  realpathSync,
34
38
  readFileSync,
39
+ readdirSync,
35
40
  rmSync,
36
41
  writeFileSync,
37
42
  } from "node:fs";
38
- import { homedir, userInfo } from "node:os";
39
- import { dirname, join, resolve } from "node:path";
43
+ import { homedir, tmpdir, userInfo } from "node:os";
44
+ import { dirname, join, relative, resolve } from "node:path";
40
45
  import { pathToFileURL } from "node:url";
41
46
  import { fileURLToPath } from "node:url";
42
47
 
@@ -77,6 +82,18 @@ server 选项:
77
82
  (Windows 任务登录后自启、无需管理员、隐藏窗口运行;stop 停止,uninstall 移除)
78
83
  快捷方式: Windows → 桌面 .lnk · macOS → 桌面 .command 启动器 · Linux → 桌面 .desktop 图标
79
84
 
85
+ 界面插件(安装到 <data-dir>/plugins/,服务运行中刷新浏览器即生效):
86
+ pi-web-ui install <源> 从 GitHub 安装界面插件
87
+ pi-web-ui uninstall <id> 卸载已安装的界面插件
88
+ pi-web-ui plugins 列出已安装的界面插件
89
+
90
+ 源写法: owner/repo · https://github.com/owner/repo · 本地目录路径
91
+ URL 带 /tree/<分支>/<子目录> 可指定分支与仓库内子目录;任意写法
92
+ 末尾加 #<分支或tag> 也可指定分支(如 owner/repo#v1.2)
93
+ install 选项: --name <id> 自定义插件目录名(默认取仓库名)
94
+ --data-dir <dir> 数据目录(默认 ~/.pi-web)
95
+ --force 目标已存在时覆盖
96
+
80
97
  环境变量(前台与系统服务均适用):
81
98
  PORT / PI_WEB_CWD / PI_WEB_DATA_DIR / PI_CODING_AGENT_DIR
82
99
  `;
@@ -122,6 +139,7 @@ function parseFlags(argv) {
122
139
  name: undefined,
123
140
  print: false,
124
141
  noBrowser: false,
142
+ force: false,
125
143
  help: false,
126
144
  };
127
145
  const positionals = [];
@@ -157,6 +175,9 @@ function parseFlags(argv) {
157
175
  case "--no-browser":
158
176
  opts.noBrowser = true;
159
177
  break;
178
+ case "--force":
179
+ opts.force = true;
180
+ break;
160
181
  case "--help":
161
182
  case "-h":
162
183
  opts.help = true;
@@ -1311,6 +1332,262 @@ function controlService(action, opts) {
1311
1332
  );
1312
1333
  }
1313
1334
 
1335
+ // ---------------------------------------------------------------------------
1336
+ // 界面插件管理(<dataDir>/plugins/,从 GitHub 安装)
1337
+ // ---------------------------------------------------------------------------
1338
+
1339
+ /** 合法插件 id(同 server/plugins.ts 的 ID_RE)。 */
1340
+ const PLUGIN_ID_RE = /^[A-Za-z0-9_-]+$/;
1341
+
1342
+ const PLUGIN_HELP = `用法:
1343
+ pi-web-ui install <源> [选项] 安装 GitHub 上的界面插件
1344
+ pi-web-ui uninstall <id> [选项] 卸载已安装的界面插件
1345
+ pi-web-ui plugins [选项] 列出已安装的界面插件
1346
+
1347
+ 源写法(任选其一):
1348
+ owner/repo 简写
1349
+ https://github.com/owner/repo 完整 URL(.git 可省)
1350
+ https://github.com/o/r/tree/dev/sub/dir 指定分支 + 仓库内子目录
1351
+ 以上任意写法末尾加 #分支或tag 指定分支/tag(如 owner/repo#v1.2)
1352
+ /path/to/plugin-dir 本地目录直接安装(开发调试用)
1353
+
1354
+ install 选项:
1355
+ --name <id> 插件目录名/id(默认取仓库名或 manifest.id,仅限字母数字-_)
1356
+ --data-dir <dir> 数据目录(默认 ~/.pi-web 或 $PI_WEB_DATA_DIR)
1357
+ --force 目标目录已存在时覆盖
1358
+ `;
1359
+
1360
+ function pluginDataDir(opts) {
1361
+ return resolve(opts.dataDir ?? process.env.PI_WEB_DATA_DIR ?? join(homedir(), ".pi-web"));
1362
+ }
1363
+
1364
+ /** 解析安装源为 { owner, repo, ref, subpath, cloneUrl } 或本地路径;非法输入直接退出。 */
1365
+ function parsePluginSource(rawSpec) {
1366
+ let spec = rawSpec.trim();
1367
+ let ref;
1368
+ const hash = spec.indexOf("#");
1369
+ if (hash >= 0) {
1370
+ ref = spec.slice(hash + 1).trim();
1371
+ if (!ref) fail(`无效的源 "${rawSpec}":# 后缺少分支/tag 名`);
1372
+ spec = spec.slice(0, hash).replace(/\/+$/, "");
1373
+ }
1374
+ // ssh 形式转 https 拉取(不要求本机配 ssh key);URL 去掉协议前缀统一按路径段解析
1375
+ const ssh = spec.match(/^git@([^:]+):(.+?)(?:\.git)?$/);
1376
+ if (ssh) [, , spec] = ssh;
1377
+ else {
1378
+ const url = spec.match(/^https?:\/\/(?:www\.)?github\.com\/(.+?)(?:\.git)?\/?$/i);
1379
+ if (url) [, spec] = url;
1380
+ }
1381
+ const segs = spec.split("/").filter(Boolean);
1382
+ if (segs.length < 2)
1383
+ fail(`无法识别的插件源 "${rawSpec}"\n${PLUGIN_HELP}`);
1384
+ for (const s of segs) {
1385
+ if (s === "." || s === "..") fail(`无效的源 "${rawSpec}":路径段不能是 . 或 ..`);
1386
+ }
1387
+ const [owner, repo] = segs;
1388
+ let subpath;
1389
+ if (segs[2] === "tree" || segs[2] === "blob") {
1390
+ if (!ref && segs.length > 3) ref = segs[3];
1391
+ subpath = segs.slice(4).join("/") || undefined;
1392
+ } else if (segs.length > 2) {
1393
+ subpath = segs.slice(2).join("/"); // owner/repo/sub/dir —— 子目录写法
1394
+ }
1395
+ return { owner, repo, ref, subpath, cloneUrl: `https://github.com/${owner}/${repo}.git` };
1396
+ }
1397
+
1398
+ /** 把仓库拉到 tmpDir 并返回检出根目录。优先 git clone --depth 1,失败回退 codeload tarball + 系统 tar。 */
1399
+ async function acquireRepo(src, tmpDir) {
1400
+ const dst = join(tmpDir, "src");
1401
+ const hasGit = spawnSync("git", ["--version"], { stdio: "ignore", timeout: 10_000 }).status === 0;
1402
+ if (hasGit) {
1403
+ const args = ["clone", "--depth", "1", "--single-branch"];
1404
+ if (src.ref) args.push("--branch", src.ref);
1405
+ args.push(src.cloneUrl, dst);
1406
+ console.log(`· git clone --depth 1 ${src.cloneUrl}${src.ref ? ` (${src.ref})` : ""}`);
1407
+ const res = spawnSync("git", args, {
1408
+ stdio: "inherit",
1409
+ env: { ...process.env, GIT_TERMINAL_PROMPT: "0", GCM_INTERACTIVE: "never" },
1410
+ timeout: 300_000,
1411
+ });
1412
+ if (res.status === 0 && existsSync(dst)) return dst;
1413
+ console.log("· git clone 失败,回退到 tarball 直连下载…");
1414
+ }
1415
+ const url = `https://codeload.github.com/${src.owner}/${src.repo}/tar.gz/${src.ref || "HEAD"}`;
1416
+ console.log(`· 下载 ${url}`);
1417
+ // 注意:这里不用 fail()/process.exit —— async 上下文里还有未关闭的 socket 时
1418
+ // 直接退出会触发 Windows libuv "UV_HANDLE_CLOSING" 断言崩溃;改为 throw,
1419
+ // 由 pluginInstallCmd 捕获后设 exitCode 让事件循环自然排空。
1420
+ let res;
1421
+ try {
1422
+ res = await fetch(url, { redirect: "follow", signal: AbortSignal.timeout(120_000) });
1423
+ } catch (err) {
1424
+ throw new Error(`下载失败:${err?.message ?? err}\n 请检查网络/代理后重试。`);
1425
+ }
1426
+ if (!res.ok)
1427
+ throw new Error(
1428
+ `下载失败 HTTP ${res.status}:${url}` +
1429
+ (res.status === 404
1430
+ ? "\n 仓库/分支不存在,或为私有仓库(私有仓库请先在本机配置好 git 凭据再重试,会优先走 git clone)。"
1431
+ : ""),
1432
+ );
1433
+ writeFileSync(join(tmpDir, "src.tar.gz"), Buffer.from(await res.arrayBuffer()));
1434
+ const extractTo = join(tmpDir, "tar");
1435
+ mkdirSync(extractTo, { recursive: true });
1436
+ run("tar", ["-xzf", join(tmpDir, "src.tar.gz"), "-C", extractTo]);
1437
+ const entries = readdirSync(extractTo);
1438
+ if (entries.length !== 1) fail("tarball 解压结果异常(顶层应只有一个目录)");
1439
+ return join(extractTo, entries[0]);
1440
+ }
1441
+
1442
+ /** 在检出树里找包含 manifest.json 的目录(深度 ≤3,跳过 .git/node_modules)。 */
1443
+ function findManifestDirs(root) {
1444
+ const hits = [];
1445
+ const walk = (dir, depth) => {
1446
+ if (existsSync(join(dir, "manifest.json"))) {
1447
+ hits.push(dir);
1448
+ return; // 目录本身是插件就不再往下搜嵌套插件
1449
+ }
1450
+ if (depth >= 3) return;
1451
+ for (const ent of readdirSync(dir, { withFileTypes: true })) {
1452
+ if (!ent.isDirectory() || ent.name === ".git" || ent.name === "node_modules") continue;
1453
+ walk(join(dir, ent.name), depth + 1);
1454
+ }
1455
+ };
1456
+ walk(root, 0);
1457
+ return hits;
1458
+ }
1459
+
1460
+ /** 定位插件根目录:显式子路径 > 根目录 manifest > 全树搜索(唯一命中才继续)。 */
1461
+ function locatePluginRoot(checkout, subpath, repoLabel) {
1462
+ if (subpath) {
1463
+ const dir = join(checkout, ...subpath.split("/"));
1464
+ if (!existsSync(join(dir, "manifest.json")))
1465
+ fail(`子目录 "${subpath}" 里没有 manifest.json`);
1466
+ return dir;
1467
+ }
1468
+ if (existsSync(join(checkout, "manifest.json"))) return checkout;
1469
+ const hits = findManifestDirs(checkout);
1470
+ if (hits.length === 0)
1471
+ fail(`"${repoLabel}" 里没找到 manifest.json —— 不是 pi-web-ui 界面插件`);
1472
+ if (hits.length > 1)
1473
+ fail(
1474
+ `${repoLabel} 里有多个插件(多个 manifest.json),请用子目录写法指定其中一个:\n ` +
1475
+ hits.map((h) => `${repoLabel}/${relative(checkout, h).split(/[\\/]/).join("/")}`).join("\n "),
1476
+ );
1477
+ console.log(`· 插件位于子目录: ${relative(checkout, hits[0]).split(/[\\/]/).join("/")}`);
1478
+ return hits[0];
1479
+ }
1480
+
1481
+ async function pluginInstallCmd(argv) {
1482
+ const { opts, positionals } = parseFlags(argv);
1483
+ if (opts.help) {
1484
+ console.log(PLUGIN_HELP);
1485
+ return;
1486
+ }
1487
+ if (positionals.length !== 1)
1488
+ fail(`用法: pi-web-ui install <源> [--name <id>] [--data-dir <dir>] [--force]\n${PLUGIN_HELP}`);
1489
+ const rawSpec = positionals[0];
1490
+ const pluginsDir = join(pluginDataDir(opts), "plugins");
1491
+ // 本地目录直接装(离线开发调试),否则从 GitHub 拉取
1492
+ const localCandidate = resolve(rawSpec.replace(/^file:\/\//, ""));
1493
+ const isLocal = existsSync(localCandidate);
1494
+ const src = isLocal ? null : parsePluginSource(rawSpec);
1495
+ const tmp = mkdtempSync(join(tmpdir(), "pi-web-ui-plugin-"));
1496
+ try {
1497
+ let checkout;
1498
+ try {
1499
+ checkout = isLocal ? localCandidate : await acquireRepo(src, tmp);
1500
+ } catch (err) {
1501
+ console.error(`✖ ${err?.message ?? err}`);
1502
+ process.exitCode = 1;
1503
+ return;
1504
+ }
1505
+ const repoLabel = isLocal ? localCandidate : `${src.owner}/${src.repo}`;
1506
+ const pluginRoot = locatePluginRoot(checkout, src?.subpath, repoLabel);
1507
+ let manifest;
1508
+ try {
1509
+ manifest = JSON.parse(readFileSync(join(pluginRoot, "manifest.json"), "utf8"));
1510
+ } catch (err) {
1511
+ fail(`manifest.json 不是合法 JSON:${err?.message ?? err}`);
1512
+ }
1513
+ // 默认 id:子目录名 > 仓库名 > 本地目录名
1514
+ const sourceName = src?.subpath
1515
+ ? src.subpath.split("/").pop()
1516
+ : (src?.repo ?? localCandidate.split(/[\\/]/).pop());
1517
+ const fallbackId =
1518
+ String(manifest.id ?? sourceName)
1519
+ .replace(/[^A-Za-z0-9_-]/g, "-")
1520
+ .replace(/^-+|-+$/g, "") || "plugin";
1521
+ const id = opts.name ?? fallbackId;
1522
+ if (!PLUGIN_ID_RE.test(id))
1523
+ fail(`非法插件 id "${id}"(仅限字母数字-_,可用 --name <id> 自定义)`);
1524
+ const target = join(pluginsDir, id);
1525
+ if (existsSync(target)) {
1526
+ if (!opts.force)
1527
+ fail(`插件目录已存在:${target}\n 加 --force 覆盖,或用 --name <id> 换个名字。`);
1528
+ rmSync(target, { recursive: true, force: true });
1529
+ }
1530
+ mkdirSync(target, { recursive: true });
1531
+ cpSync(pluginRoot, target, {
1532
+ recursive: true,
1533
+ filter: (s) => !/(^|[\\/])(\.git|node_modules)([\\/]|$)/.test(s),
1534
+ });
1535
+ console.log(
1536
+ `✔ 已安装插件 ${id}${manifest.name && manifest.name !== id ? `(${manifest.name})` : ""}${manifest.version ? ` v${manifest.version}` : ""}`,
1537
+ );
1538
+ if (manifest.description) console.log(` ${manifest.description}`);
1539
+ console.log(` 位置: ${target}`);
1540
+ console.log(` 生效: 服务运行中刷新浏览器即可加载;未运行则下次启动生效。卸载: pi-web-ui uninstall ${id}`);
1541
+ } finally {
1542
+ rmSync(tmp, { recursive: true, force: true });
1543
+ }
1544
+ }
1545
+
1546
+ function pluginUninstallCmd(argv) {
1547
+ const { opts, positionals } = parseFlags(argv);
1548
+ if (opts.help || positionals.length !== 1) {
1549
+ console.log(PLUGIN_HELP);
1550
+ if (!opts.help) process.exit(1);
1551
+ return;
1552
+ }
1553
+ const id = positionals[0];
1554
+ if (!PLUGIN_ID_RE.test(id)) fail(`非法插件 id: ${id}`);
1555
+ const target = join(pluginDataDir(opts), "plugins", id);
1556
+ if (!existsSync(target)) fail(`未安装插件 "${id}"(pi-web-ui plugins 查看已装列表)`);
1557
+ rmSync(target, { recursive: true, force: true });
1558
+ console.log(`✔ 已卸载插件 ${id} —— 运行中的服务刷新浏览器后消失。`);
1559
+ }
1560
+
1561
+ function pluginListCmd(argv) {
1562
+ const { opts } = parseFlags(argv);
1563
+ if (opts.help) {
1564
+ console.log(PLUGIN_HELP);
1565
+ return;
1566
+ }
1567
+ const pluginsDir = join(pluginDataDir(opts), "plugins");
1568
+ const rows = [];
1569
+ let names = [];
1570
+ try {
1571
+ names = readdirSync(pluginsDir).sort();
1572
+ } catch {
1573
+ /* 目录不存在 = 未安装任何插件 */
1574
+ }
1575
+ for (const n of names) {
1576
+ if (!PLUGIN_ID_RE.test(n)) continue;
1577
+ try {
1578
+ const m = JSON.parse(readFileSync(join(pluginsDir, n, "manifest.json"), "utf8"));
1579
+ rows.push(` ${n.padEnd(24)} ${[m.name, m.version ? `v${m.version}` : "", m.description].filter(Boolean).join(" ")}`);
1580
+ } catch {
1581
+ continue; // 坏目录跳过
1582
+ }
1583
+ }
1584
+ if (rows.length === 0) {
1585
+ console.log(`尚未安装任何界面插件(目录: ${pluginsDir})\n安装示例: pi-web-ui install owner/repo`);
1586
+ return;
1587
+ }
1588
+ console.log(`已安装的界面插件(${pluginsDir}):\n${rows.join("\n")}`);
1589
+ }
1590
+
1314
1591
  async function serverCmd(argv) {
1315
1592
  const { opts, positionals } = parseFlags(argv);
1316
1593
  if (opts.help) {
@@ -1405,6 +1682,18 @@ async function main() {
1405
1682
  await serverCmd(argv.slice(1));
1406
1683
  return;
1407
1684
  }
1685
+ if (first === "install") {
1686
+ await pluginInstallCmd(argv.slice(1));
1687
+ return;
1688
+ }
1689
+ if (first === "uninstall") {
1690
+ pluginUninstallCmd(argv.slice(1));
1691
+ return;
1692
+ }
1693
+ if (first === "plugins" || first === "plugin") {
1694
+ pluginListCmd(argv.slice(1));
1695
+ return;
1696
+ }
1408
1697
  // One-shot server with optional --port/--cwd/--data-dir overrides.
1409
1698
  const { opts, positionals } = parseFlags(argv);
1410
1699
  if (opts.help) {
@@ -1,48 +1,48 @@
1
- <?xml version="1.0" encoding="UTF-8"?>
2
- <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3
- <!--
4
- pi-web-ui launchd agent — macOS auto-start at login.
5
-
6
- Install:
7
- npm i -g pi-web-ui
8
- cp deploy/com.xingshuyin.pi-web-ui.plist ~/Library/LaunchAgents/
9
- # edit ProgramArguments / WorkingDirectory / PI_WEB_CWD for your setup
10
- launchctl load ~/Library/LaunchAgents/com.xingshuyin.pi-web-ui.plist
11
- # (or: launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.xingshuyin.pi-web-ui.plist)
12
-
13
- Find the pi-web-ui binary path with: which pi-web-ui
14
- -->
15
- <plist version="1.0">
16
- <dict>
17
- <key>Label</key>
18
- <string>com.xingshuyin.pi-web-ui</string>
19
-
20
- <key>ProgramArguments</key>
21
- <array>
22
- <string>/usr/local/bin/pi-web-ui</string>
23
- </array>
24
-
25
- <key>RunAtLoad</key>
26
- <true/>
27
-
28
- <!-- Restart if it crashes -->
29
- <key>KeepAlive</key>
30
- <true/>
31
-
32
- <key>WorkingDirectory</key>
33
- <string>/Users/YOUR_USER</string>
34
-
35
- <key>EnvironmentVariables</key>
36
- <dict>
37
- <key>PORT</key>
38
- <string>8787</string>
39
- <key>PI_WEB_CWD</key>
40
- <string>/Users/YOUR_USER</string>
41
- </dict>
42
-
43
- <key>StandardOutPath</key>
44
- <string>/tmp/pi-web-ui.log</string>
45
- <key>StandardErrorPath</key>
46
- <string>/tmp/pi-web-ui.err</string>
47
- </dict>
48
- </plist>
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3
+ <!--
4
+ pi-web-ui launchd agent — macOS auto-start at login.
5
+
6
+ Install:
7
+ npm i -g pi-web-ui
8
+ cp deploy/com.xingshuyin.pi-web-ui.plist ~/Library/LaunchAgents/
9
+ # edit ProgramArguments / WorkingDirectory / PI_WEB_CWD for your setup
10
+ launchctl load ~/Library/LaunchAgents/com.xingshuyin.pi-web-ui.plist
11
+ # (or: launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.xingshuyin.pi-web-ui.plist)
12
+
13
+ Find the pi-web-ui binary path with: which pi-web-ui
14
+ -->
15
+ <plist version="1.0">
16
+ <dict>
17
+ <key>Label</key>
18
+ <string>com.xingshuyin.pi-web-ui</string>
19
+
20
+ <key>ProgramArguments</key>
21
+ <array>
22
+ <string>/usr/local/bin/pi-web-ui</string>
23
+ </array>
24
+
25
+ <key>RunAtLoad</key>
26
+ <true/>
27
+
28
+ <!-- Restart if it crashes -->
29
+ <key>KeepAlive</key>
30
+ <true/>
31
+
32
+ <key>WorkingDirectory</key>
33
+ <string>/Users/YOUR_USER</string>
34
+
35
+ <key>EnvironmentVariables</key>
36
+ <dict>
37
+ <key>PORT</key>
38
+ <string>8787</string>
39
+ <key>PI_WEB_CWD</key>
40
+ <string>/Users/YOUR_USER</string>
41
+ </dict>
42
+
43
+ <key>StandardOutPath</key>
44
+ <string>/tmp/pi-web-ui.log</string>
45
+ <key>StandardErrorPath</key>
46
+ <string>/tmp/pi-web-ui.err</string>
47
+ </dict>
48
+ </plist>
@@ -1,88 +1,88 @@
1
- # pi-web-ui behind nginx at a sub-path: http://<host>:83/pi/
2
- # Backend app: http://127.0.0.1:8787 (default PORT env)
3
- #
4
- # IMPORTANT: pi-web-ui 0.23+ checks the WebSocket Origin against the request
5
- # Host (hostname AND port). Every proxied location MUST forward the original
6
- # Host with $http_host (keeps the port). Using $host (drops the port) or
7
- # leaving Host unset (defaults to 127.0.0.1:8787) makes the upgrade fail with
8
- # 403 — the page loads but chat/terminal keep reconnecting.
9
- #
10
- # Topology (two listeners on one port: frp + LAN coexist):
11
- # frp (public) -> <PUBLIC_IP>:<PUBLIC_PORT> -> 127.0.0.1:83 (PROXY protocol v2)
12
- # LAN users -> http://<LAN_IP>:83/pi/ (plain HTTP listener)
13
- #
14
- # frpc sends PROXY v2 to nginx (transport.proxyProtocolVersion = "v2"):
15
- # * 127.0.0.1:83 proxy_protocol — only the local frp client connects
16
- # here (it speaks PROXY v2; nginx then sees the real visitor IP).
17
- # * <LAN_IP>:83 plain HTTP — LAN browsers, no PROXY header needed.
18
- #
19
- # Simpler alternative (no real client IPs): drop proxy_protocol entirely and
20
- # use a single `listen 83;` — then frpc must NOT set proxyProtocolVersion.
21
- #
22
- # The frontend uses absolute paths (/ws WebSocket, /assets/*, /favicon.svg,
23
- # /api/file…) so those get their own proxied locations next to /pi/.
24
-
25
- # Reuse for Upgrade/Connection headers (WebSocket).
26
- map $http_upgrade $connection_upgrade {
27
- default upgrade;
28
- '' close;
29
- }
30
-
31
- server {
32
- listen 127.0.0.1:83 proxy_protocol;
33
- listen <LAN_IP>:83;
34
-
35
- server_name _;
36
-
37
- # Trust PROXY-protocol headers only from the local frp client; plain
38
- # (LAN) connections keep their real $remote_addr untouched.
39
- set_real_ip_from 127.0.0.1;
40
- real_ip_header proxy_protocol;
41
-
42
- # ---- main entry: strip /pi/ and forward to the app root ----
43
- location /pi/ {
44
- proxy_pass http://127.0.0.1:8787/;
45
- proxy_http_version 1.1;
46
- # $http_host keeps the port — origin check compares hostname AND port.
47
- proxy_set_header Host $http_host;
48
- proxy_set_header X-Real-IP $remote_addr;
49
- proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
50
- proxy_set_header X-Forwarded-Proto $scheme;
51
- proxy_set_header Upgrade $http_upgrade;
52
- proxy_set_header Connection $connection_upgrade;
53
- }
54
-
55
- # ---- WebSocket (the frontend connects to ws://<host>/ws) ----
56
- location /ws {
57
- proxy_pass http://127.0.0.1:8787;
58
- proxy_http_version 1.1;
59
- proxy_set_header Host $http_host;
60
- proxy_set_header Upgrade $http_upgrade;
61
- proxy_set_header Connection $connection_upgrade;
62
- proxy_read_timeout 3600s;
63
- proxy_send_timeout 3600s;
64
- proxy_set_header X-Real-IP $remote_addr;
65
- proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
66
- }
67
-
68
- # ---- absolute asset paths baked into index.html ----
69
- location /assets/ {
70
- proxy_pass http://127.0.0.1:8787;
71
- }
72
- location = /favicon.svg {
73
- proxy_pass http://127.0.0.1:8787;
74
- }
75
- location = /favicon-streaming.svg {
76
- proxy_pass http://127.0.0.1:8787;
77
- }
78
-
79
- # ---- media preview / download / health API ----
80
- location /api/ {
81
- proxy_pass http://127.0.0.1:8787;
82
- }
83
-
84
- # bare root → app entry
85
- location = / {
86
- return 302 /pi/;
87
- }
88
- }
1
+ # pi-web-ui behind nginx at a sub-path: http://<host>:83/pi/
2
+ # Backend app: http://127.0.0.1:8787 (default PORT env)
3
+ #
4
+ # IMPORTANT: pi-web-ui 0.23+ checks the WebSocket Origin against the request
5
+ # Host (hostname AND port). Every proxied location MUST forward the original
6
+ # Host with $http_host (keeps the port). Using $host (drops the port) or
7
+ # leaving Host unset (defaults to 127.0.0.1:8787) makes the upgrade fail with
8
+ # 403 — the page loads but chat/terminal keep reconnecting.
9
+ #
10
+ # Topology (two listeners on one port: frp + LAN coexist):
11
+ # frp (public) -> <PUBLIC_IP>:<PUBLIC_PORT> -> 127.0.0.1:83 (PROXY protocol v2)
12
+ # LAN users -> http://<LAN_IP>:83/pi/ (plain HTTP listener)
13
+ #
14
+ # frpc sends PROXY v2 to nginx (transport.proxyProtocolVersion = "v2"):
15
+ # * 127.0.0.1:83 proxy_protocol — only the local frp client connects
16
+ # here (it speaks PROXY v2; nginx then sees the real visitor IP).
17
+ # * <LAN_IP>:83 plain HTTP — LAN browsers, no PROXY header needed.
18
+ #
19
+ # Simpler alternative (no real client IPs): drop proxy_protocol entirely and
20
+ # use a single `listen 83;` — then frpc must NOT set proxyProtocolVersion.
21
+ #
22
+ # The frontend uses absolute paths (/ws WebSocket, /assets/*, /favicon.svg,
23
+ # /api/file…) so those get their own proxied locations next to /pi/.
24
+
25
+ # Reuse for Upgrade/Connection headers (WebSocket).
26
+ map $http_upgrade $connection_upgrade {
27
+ default upgrade;
28
+ '' close;
29
+ }
30
+
31
+ server {
32
+ listen 127.0.0.1:83 proxy_protocol;
33
+ listen <LAN_IP>:83;
34
+
35
+ server_name _;
36
+
37
+ # Trust PROXY-protocol headers only from the local frp client; plain
38
+ # (LAN) connections keep their real $remote_addr untouched.
39
+ set_real_ip_from 127.0.0.1;
40
+ real_ip_header proxy_protocol;
41
+
42
+ # ---- main entry: strip /pi/ and forward to the app root ----
43
+ location /pi/ {
44
+ proxy_pass http://127.0.0.1:8787/;
45
+ proxy_http_version 1.1;
46
+ # $http_host keeps the port — origin check compares hostname AND port.
47
+ proxy_set_header Host $http_host;
48
+ proxy_set_header X-Real-IP $remote_addr;
49
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
50
+ proxy_set_header X-Forwarded-Proto $scheme;
51
+ proxy_set_header Upgrade $http_upgrade;
52
+ proxy_set_header Connection $connection_upgrade;
53
+ }
54
+
55
+ # ---- WebSocket (the frontend connects to ws://<host>/ws) ----
56
+ location /ws {
57
+ proxy_pass http://127.0.0.1:8787;
58
+ proxy_http_version 1.1;
59
+ proxy_set_header Host $http_host;
60
+ proxy_set_header Upgrade $http_upgrade;
61
+ proxy_set_header Connection $connection_upgrade;
62
+ proxy_read_timeout 3600s;
63
+ proxy_send_timeout 3600s;
64
+ proxy_set_header X-Real-IP $remote_addr;
65
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
66
+ }
67
+
68
+ # ---- absolute asset paths baked into index.html ----
69
+ location /assets/ {
70
+ proxy_pass http://127.0.0.1:8787;
71
+ }
72
+ location = /favicon.svg {
73
+ proxy_pass http://127.0.0.1:8787;
74
+ }
75
+ location = /favicon-streaming.svg {
76
+ proxy_pass http://127.0.0.1:8787;
77
+ }
78
+
79
+ # ---- media preview / download / health API ----
80
+ location /api/ {
81
+ proxy_pass http://127.0.0.1:8787;
82
+ }
83
+
84
+ # bare root → app entry
85
+ location = / {
86
+ return 302 /pi/;
87
+ }
88
+ }