@bivy/bivy 0.20.1-staging.5 → 0.20.1-staging.6
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/bivy.mjs +120 -0
- package/bin/cli-commands.mjs +1 -0
- package/bin/tailscale.mjs +63 -0
- package/dist/auth.js +16 -3
- package/dist/pair-codes.js +41 -0
- package/dist/remote/direct.js +23 -0
- package/dist/remote/index.js +1 -0
- package/dist/server.js +91 -5
- package/dist/web-app.js +55 -0
- package/package.json +1 -1
- package/web/apple-touch-icon.png +0 -0
- package/web/assets/AutomationsView-C5FTAk5H.js +51 -0
- package/web/assets/ChangesCard-Bq3EQIem.js +4 -0
- package/web/assets/LibraryView-DrOvEcbv.js +2 -0
- package/web/assets/ProviderConnect-B5UJ6o1x.js +1 -0
- package/web/assets/ReadinessChecklist-Bbgq2GQ8.js +1 -0
- package/web/assets/Settings-BV1LvpeB.js +10 -0
- package/web/assets/Sheet-Db4Mndim.js +2 -0
- package/web/assets/StatusDot-dpbztK53.js +1 -0
- package/web/assets/Terminal-B1njb2Cr.css +1 -0
- package/web/assets/Terminal-CvIBOJzq.js +96 -0
- package/web/assets/VoiceSettings-96Bha24X.js +1 -0
- package/web/assets/abnfDiagram-VCTEODGH-D2Y5sPRp.js +1 -0
- package/web/assets/arc-Pry-7G1_.js +1 -0
- package/web/assets/architecture-7GRP2DOG-BgbAf7AC.js +1 -0
- package/web/assets/architectureDiagram-5GKGNRK7-BRYXZYo0.js +36 -0
- package/web/assets/array-BifhSqXX.js +1 -0
- package/web/assets/blockDiagram-I7D4REHJ-BbZ6-1wB.js +129 -0
- package/web/assets/c4Diagram-7LVT6UL2-CvOQdXuZ.js +38 -0
- package/web/assets/channel-Ccxgwn49.js +1 -0
- package/web/assets/chunk-2Q5K7J3B-C1jixKkw.js +1 -0
- package/web/assets/chunk-4HAMMTFA-BI825_ZV.js +62 -0
- package/web/assets/chunk-5VM5RSS4-ZNzvKenW.js +15 -0
- package/web/assets/chunk-75Z2AOVW-CxSd6Zxl.js +2 -0
- package/web/assets/chunk-DU6HZSFF-1-mK49JE.js +127 -0
- package/web/assets/chunk-F27PBJKO-jlR5FL7E.js +1 -0
- package/web/assets/chunk-FOHPRMQF-DzxwRta7.js +161 -0
- package/web/assets/chunk-GMAD6QVW-BBnwhF5K.js +72 -0
- package/web/assets/chunk-GVQU2GXP-CHUTVqWw.js +1 -0
- package/web/assets/chunk-IMKFNOWR-D0BbcDj6.js +231 -0
- package/web/assets/chunk-JWPE2WC7-DVXcaiue.js +1 -0
- package/web/assets/chunk-L3NEJ4N5-DvDZiWe-.js +1 -0
- package/web/assets/chunk-OSK3NFVY-JHuvasbP.js +10 -0
- package/web/assets/chunk-P2QGCYS3-wjuBF4Pd.js +1 -0
- package/web/assets/chunk-POPQ4Y6H-CngK4WUA.js +1 -0
- package/web/assets/chunk-PWAF6VOD-CTRtPw_P.js +1 -0
- package/web/assets/chunk-SHT3W25Y-ABe0cu9b.js +168 -0
- package/web/assets/chunk-SVP7TREG-cnVb9wRh.js +88 -0
- package/web/assets/chunk-TICWLB2K-DfKefWT6.js +206 -0
- package/web/assets/chunk-XXDRQBXY-DqAYeljN.js +1 -0
- package/web/assets/chunk-Y2CYZVJY-DsF7k-Jl.js +1 -0
- package/web/assets/classDiagram-ZZMXUADV-B_nUbchH.js +1 -0
- package/web/assets/classDiagram-v2-VYDZK3BY-B_nUbchH.js +1 -0
- package/web/assets/common-DvIV9UK2.js +5 -0
- package/web/assets/cose-bilkent-JH36ORCC-BIqBF2fY.js +1 -0
- package/web/assets/cynefin-OW5HDTMX-6Eym2I8q.js +1 -0
- package/web/assets/cynefinDiagram-5FMLGOSQ-DMcNShU2.js +62 -0
- package/web/assets/cytoscape.esm-NgwPT4ez.js +321 -0
- package/web/assets/dagre-C-rnJpZF.js +1 -0
- package/web/assets/dagre-GXQ25YYZ-jyxO30Ra.js +4 -0
- package/web/assets/defaultLocale-BFoDCU3G.js +1 -0
- package/web/assets/diagram-S7CK7UJ4-DyJSgUWQ.js +30 -0
- package/web/assets/diagram-UQ7AKVKN-Bu-awgYc.js +41 -0
- package/web/assets/diagram-VSXAHHWV-CDG8WcFV.js +3 -0
- package/web/assets/diagram-VX7I27RA-eJK6IXRm.js +24 -0
- package/web/assets/diagram-Z3DM3KII-4bfg0YVc.js +24 -0
- package/web/assets/dist-v5Q1xZ2K.js +1 -0
- package/web/assets/ebnfDiagram-PWID7BFC-DLpKX0PA.js +1 -0
- package/web/assets/erDiagram-RLTQ6QDP-C9XoVCNz.js +99 -0
- package/web/assets/eventmodeling-NTZA5JFV-DB9bp7xg.js +1 -0
- package/web/assets/flowDiagram-HODETNUW-Bt84xHsk.js +1 -0
- package/web/assets/ganttDiagram-EL5Y4UJY-6GH5Yrgv.js +292 -0
- package/web/assets/gitGraph-4MIJSDKK-DFBb9ZSe.js +1 -0
- package/web/assets/gitGraphDiagram-WWUBYQGX-DVuclljM.js +106 -0
- package/web/assets/graphlib-DS17s2tU.js +1 -0
- package/web/assets/index-Dk7VU5lG.css +1 -0
- package/web/assets/index-H1S-0dr5.js +3 -0
- package/web/assets/info-A6RAGUB7-DS--l7eH.js +1 -0
- package/web/assets/infoDiagram-27XIBGKW-A9tXXovL.js +2 -0
- package/web/assets/init-C-OQMol4.js +1 -0
- package/web/assets/ishikawaDiagram-5VMMS53U-C3bulOe4.js +70 -0
- package/web/assets/journeyDiagram-3NMN7TZE-PFpzT1t0.js +139 -0
- package/web/assets/jsx-runtime-NZYk81nU.js +1 -0
- package/web/assets/kanban-definition-UXKFOSKX-C96gUxZE.js +89 -0
- package/web/assets/katex-ZlcWpGUi.js +257 -0
- package/web/assets/line-Bq-4Ddcs.js +1 -0
- package/web/assets/linear-SzVmGSw4.js +1 -0
- package/web/assets/mermaid-parser.core-BQCW99Ok.js +7 -0
- package/web/assets/mermaid.core-B30Wokvl.js +44 -0
- package/web/assets/mindmap-definition-YA3MSWOX-ClwtCWm0.js +96 -0
- package/web/assets/mount-B_ShSdyJ.js +26 -0
- package/web/assets/mount-BjlUftih.css +1 -0
- package/web/assets/ordinal-BDEzSJ7C.js +1 -0
- package/web/assets/packet-AYTQ26CC-YkeLgzv0.js +1 -0
- package/web/assets/path-fybaL0A-.js +1 -0
- package/web/assets/pegDiagram-XKGWAZYB-CaCibFki.js +1 -0
- package/web/assets/pie-WAS4IAKB-OyMK5OfZ.js +1 -0
- package/web/assets/pieDiagram-E7YTZNPT-DfGlDcKu.js +39 -0
- package/web/assets/quadrantDiagram-AXDQQJYC-C8ugAM99.js +7 -0
- package/web/assets/radar-RG4KPBEZ-DPu0k7LD.js +1 -0
- package/web/assets/railroad-74A4TZTK-Co59jOcB.js +1 -0
- package/web/assets/railroad-abnf-HS5TGJTU-Cw5NEYkY.js +1 -0
- package/web/assets/railroad-ebnf-LZEXJU2U-nB_gqNFI.js +1 -0
- package/web/assets/railroad-peg-WCYAUIDC-2XU5RRzN.js +1 -0
- package/web/assets/railroadDiagram-O6MQD6OU-COh170ve.js +1 -0
- package/web/assets/requirementDiagram-BXWQKSXE-Dh-wxNt2.js +84 -0
- package/web/assets/rolldown-runtime-hePW80VL.js +1 -0
- package/web/assets/rough.esm-Dy-Kn_BL.js +1 -0
- package/web/assets/sankeyDiagram-P5KCCOFB-BHOSWXWw.js +40 -0
- package/web/assets/sequenceDiagram-WJ2MYXX4-ik5jL4SR.js +162 -0
- package/web/assets/sizeCapture-INFHLROL-3uHXrFqA.js +1 -0
- package/web/assets/src-B6xuSHsQ.js +1 -0
- package/web/assets/stateDiagram-D77RDMKH-Be31rRv9.js +1 -0
- package/web/assets/stateDiagram-v2-MP3YSRHH-DV2_zzd4.js +1 -0
- package/web/assets/swimlanes-42K2YHIH-DBK3hVdk.js +1 -0
- package/web/assets/swimlanesDiagram-VR7AAH4N-D_QAhSyB.js +8 -0
- package/web/assets/timeline-definition-24CTP7MA-DlW5MrNZ.js +120 -0
- package/web/assets/treeView-Q6P3EWNA-CfAW_qLZ.js +1 -0
- package/web/assets/treemap-WGGIJYW6-QxQMH5Y8.js +1 -0
- package/web/assets/useStore-BxmpBglW.js +27 -0
- package/web/assets/vennDiagram-4TSXK5OY-D91iwoPB.js +34 -0
- package/web/assets/wardley-WFR3VGLG-hq1e1BaT.js +1 -0
- package/web/assets/wardleyDiagram-VM6X3IG4-BDiSY_RF.js +78 -0
- package/web/assets/workbox-window.prod.es5-Bd17z0YL.js +2 -0
- package/web/assets/xychartDiagram-S5SC5T6Z-DX1lbV1q.js +7 -0
- package/web/fonts/OFL.txt +90 -0
- package/web/fonts/newsreader-italic.woff2 +0 -0
- package/web/fonts/newsreader-roman.woff2 +0 -0
- package/web/icon-192.png +0 -0
- package/web/icon-512.png +0 -0
- package/web/icon.svg +16 -0
- package/web/index.html +36 -0
- package/web/manifest.webmanifest +1 -0
- package/web/runtime-config.js +3 -0
- package/web/sw.js +1 -0
- package/web/tent-logo.png +0 -0
package/bin/bivy.mjs
CHANGED
|
@@ -40,6 +40,7 @@ import { nativeResumeRef, resolveSessionsLimit, truncateSavedSessions } from "./
|
|
|
40
40
|
import { renderManagedBlock, upsertManagedBlock, removeManagedBlock, rcFileForShell } from "./shim-path.mjs";
|
|
41
41
|
import { removeInstallAndState } from "./uninstall-paths.mjs";
|
|
42
42
|
import { findAvailablePort, reconcilePort } from "./port-picker.mjs";
|
|
43
|
+
import { parseTailscaleStatus, serveConflict, serveFailureHint, serveOffArgs, serveOnArgs, serveState } from "./tailscale.mjs";
|
|
43
44
|
import { resolveAttachSessionId } from "./attach-session-id.mjs";
|
|
44
45
|
import { detectInstallKind as classifyInstallKind, npmGlobalPrefix } from "./install-kind.mjs";
|
|
45
46
|
import { hasConfiguredService as configuredServiceExists } from "./service-state.mjs";
|
|
@@ -5673,6 +5674,122 @@ async function cmdLinkPhone(args = []) {
|
|
|
5673
5674
|
}
|
|
5674
5675
|
}
|
|
5675
5676
|
|
|
5677
|
+
const tailscaleConfigPath = path.join(appDir, "tailscale.json");
|
|
5678
|
+
const LOCAL_TOKEN_NAMES = ["Bivy CLI", "Local Remote UI"];
|
|
5679
|
+
|
|
5680
|
+
function runTailscale(args) {
|
|
5681
|
+
const res = spawnSync("tailscale", args, { encoding: "utf8" });
|
|
5682
|
+
if (res.error?.code === "ENOENT") return null;
|
|
5683
|
+
return { ok: res.status === 0, stdout: res.stdout ?? "", stderr: res.stderr ?? "" };
|
|
5684
|
+
}
|
|
5685
|
+
|
|
5686
|
+
async function printPairLink(config) {
|
|
5687
|
+
const data = await localApi(config, "/api/auth/pair-codes", { method: "POST", body: "{}" });
|
|
5688
|
+
if (!data?.url) throw new Error("The node did not return a pairing link.");
|
|
5689
|
+
const qr = terminalQr(data.url);
|
|
5690
|
+
if (qr) console.log(qr);
|
|
5691
|
+
console.log(`\nOpen on your phone (single use, 10 minutes):\n${c.cyan(data.url)}\n`);
|
|
5692
|
+
}
|
|
5693
|
+
|
|
5694
|
+
// `bivy tailscale` — reach this machine from anywhere on your tailnet with no
|
|
5695
|
+
// control plane or relay: `tailscale serve` puts HTTPS on the machine's ts.net
|
|
5696
|
+
// name and forwards it to the node's direct listener, which serves the web app.
|
|
5697
|
+
async function cmdTailscale(args = []) {
|
|
5698
|
+
const [verb = "on"] = args;
|
|
5699
|
+
if (args.includes("-h") || args.includes("--help") || !["on", "pair", "status", "devices", "revoke", "off"].includes(verb)) {
|
|
5700
|
+
console.log(`Usage: bivy tailscale [on|pair|status|devices|revoke <id>|off]
|
|
5701
|
+
|
|
5702
|
+
on Serve this machine's Bivy on https://<machine>.<tailnet>.ts.net (tailnet only) and show a pairing link
|
|
5703
|
+
pair Show a new one-time pairing link for another device
|
|
5704
|
+
status Show the Tailscale address and whether it is served
|
|
5705
|
+
devices List the devices holding a token for this node
|
|
5706
|
+
revoke <id> Revoke a device's token and disconnect it
|
|
5707
|
+
off Stop serving Bivy over Tailscale`);
|
|
5708
|
+
return;
|
|
5709
|
+
}
|
|
5710
|
+
const config = loadConfig();
|
|
5711
|
+
if (!(await isReachable(config))) {
|
|
5712
|
+
console.log(c.yellow(`The node is not reachable at ${url(config)}. Start it first: ${c.cyan(config.service ? "bivy restart" : "bivy start")}`));
|
|
5713
|
+
process.exitCode = EXIT.unavailable;
|
|
5714
|
+
return;
|
|
5715
|
+
}
|
|
5716
|
+
const saved = (() => { try { return JSON.parse(fs.readFileSync(tailscaleConfigPath, "utf8")); } catch { return null; } })();
|
|
5717
|
+
|
|
5718
|
+
if (verb === "pair") {
|
|
5719
|
+
if (!saved) { console.log(`Tailscale access is off. Turn it on with ${c.cyan("bivy tailscale")}.`); process.exitCode = EXIT.failed; return; }
|
|
5720
|
+
await printPairLink(config);
|
|
5721
|
+
return;
|
|
5722
|
+
}
|
|
5723
|
+
if (verb === "devices") {
|
|
5724
|
+
// The CLI and loopback web client mint their own tokens on this machine; list them as a count.
|
|
5725
|
+
const all = (await localApi(config, "/api/auth/devices")) ?? [];
|
|
5726
|
+
const devices = all.filter((device) => !LOCAL_TOKEN_NAMES.includes(device.name));
|
|
5727
|
+
for (const device of devices) console.log(`${c.cyan(device.id)} ${device.name}${device.createdAt ? c.dim(` paired ${device.createdAt}`) : ""}`);
|
|
5728
|
+
if (!devices.length) console.log("No paired devices.");
|
|
5729
|
+
if (all.length > devices.length) console.log(c.dim(`(+${all.length - devices.length} tokens used on this machine by the CLI)`));
|
|
5730
|
+
return;
|
|
5731
|
+
}
|
|
5732
|
+
if (verb === "revoke") {
|
|
5733
|
+
const id = args[1];
|
|
5734
|
+
if (!id) { console.log("Usage: bivy tailscale revoke <id> (ids from 'bivy tailscale devices')"); process.exitCode = EXIT.usage; return; }
|
|
5735
|
+
const res = await fetch(`${url(config)}/api/auth/devices/${encodeURIComponent(id)}`, { method: "DELETE", headers: { authorization: `Bearer ${await localDeviceToken(config)}` } });
|
|
5736
|
+
if (!res.ok) { console.log(c.yellow(`No device ${id}.`)); process.exitCode = EXIT.notFound; return; }
|
|
5737
|
+
console.log(`${c.green("✓")} Revoked ${id}.`);
|
|
5738
|
+
return;
|
|
5739
|
+
}
|
|
5740
|
+
if (verb === "status") {
|
|
5741
|
+
console.log(saved ? `${c.green("✓")} Served on ${c.cyan(`https://${saved.hostname}`)} (tailnet only) → 127.0.0.1:${saved.port}` : `Tailscale access is off. Turn it on with ${c.cyan("bivy tailscale")}.`);
|
|
5742
|
+
return;
|
|
5743
|
+
}
|
|
5744
|
+
if (verb === "off") {
|
|
5745
|
+
if (saved) {
|
|
5746
|
+
const state = serveState(runTailscale(["serve", "status", "--json"])?.stdout, saved.hostname);
|
|
5747
|
+
if (state.target === `http://127.0.0.1:${saved.port}`) runTailscale(serveOffArgs());
|
|
5748
|
+
}
|
|
5749
|
+
fs.rmSync(tailscaleConfigPath, { force: true });
|
|
5750
|
+
await localApi(config, "/api/direct/reload", { method: "POST", body: "{}" });
|
|
5751
|
+
console.log(`${c.green("✓")} Bivy is no longer served over Tailscale. Paired devices keep their tokens; see them with 'bivy tailscale devices' and revoke with 'bivy tailscale revoke <id>'.`);
|
|
5752
|
+
return;
|
|
5753
|
+
}
|
|
5754
|
+
|
|
5755
|
+
const status = runTailscale(["status", "--json"]);
|
|
5756
|
+
if (!status) {
|
|
5757
|
+
console.log(`${c.yellow("Tailscale isn't installed.")} Install it from ${c.cyan("https://tailscale.com/download")} (Arch: ${c.cyan("sudo pacman -S tailscale && sudo systemctl enable --now tailscaled")}), sign in with ${c.cyan("sudo tailscale up")}, then run ${c.cyan("bivy tailscale")} again.`);
|
|
5758
|
+
process.exitCode = EXIT.unavailable;
|
|
5759
|
+
return;
|
|
5760
|
+
}
|
|
5761
|
+
const { running, hostname } = parseTailscaleStatus(status.stdout);
|
|
5762
|
+
if (!running || !hostname) {
|
|
5763
|
+
console.log(`${c.yellow("Tailscale isn't connected.")} Sign in with ${c.cyan("sudo tailscale up")}, then run ${c.cyan("bivy tailscale")} again.`);
|
|
5764
|
+
process.exitCode = EXIT.unavailable;
|
|
5765
|
+
return;
|
|
5766
|
+
}
|
|
5767
|
+
const port = saved?.port ?? await findAvailablePort(config.port + 2, "127.0.0.1");
|
|
5768
|
+
const conflict = serveConflict(serveState(runTailscale(["serve", "status", "--json"])?.stdout, hostname), port);
|
|
5769
|
+
if (conflict) {
|
|
5770
|
+
console.log(c.yellow(conflict));
|
|
5771
|
+
process.exitCode = EXIT.conflict;
|
|
5772
|
+
return;
|
|
5773
|
+
}
|
|
5774
|
+
fs.writeFileSync(tailscaleConfigPath, `${JSON.stringify({ port, hostname }, null, 2)}\n`, { mode: 0o600 });
|
|
5775
|
+
const reload = await localApi(config, "/api/direct/reload", { method: "POST", body: "{}" });
|
|
5776
|
+
const served = runTailscale(serveOnArgs(port));
|
|
5777
|
+
if (!served?.ok) {
|
|
5778
|
+
fs.rmSync(tailscaleConfigPath, { force: true });
|
|
5779
|
+
await localApi(config, "/api/direct/reload", { method: "POST", body: "{}" }).catch(() => {});
|
|
5780
|
+
console.error(c.red(`tailscale serve failed: ${(served?.stderr || served?.stdout || "").trim()}`));
|
|
5781
|
+
const hint = serveFailureHint(served?.stderr);
|
|
5782
|
+
if (hint) console.log(hint);
|
|
5783
|
+
process.exitCode = EXIT.failed;
|
|
5784
|
+
return;
|
|
5785
|
+
}
|
|
5786
|
+
console.log(c.bold(c.green(`\n Bivy is on https://${hostname}\n`)));
|
|
5787
|
+
console.log(c.dim(" Only devices on your tailnet can reach it. Pair each one once with a link from 'bivy tailscale pair'."));
|
|
5788
|
+
if (!reload?.webApp) console.log(c.yellow(" This install has no built web app, so the address serves the API only. Run 'pnpm run build:web' in a source checkout."));
|
|
5789
|
+
console.log("");
|
|
5790
|
+
await printPairLink(config);
|
|
5791
|
+
}
|
|
5792
|
+
|
|
5676
5793
|
function printUninstallHelp() {
|
|
5677
5794
|
console.log(`
|
|
5678
5795
|
${c.bold("bivy uninstall")} — remove Bivy and all its data from this machine
|
|
@@ -6270,6 +6387,9 @@ Unlike 'bivy run', these commands operate on governed background Runs with check
|
|
|
6270
6387
|
case "link":
|
|
6271
6388
|
await cmdLinkPhone(args);
|
|
6272
6389
|
break;
|
|
6390
|
+
case "tailscale":
|
|
6391
|
+
await cmdTailscale(args);
|
|
6392
|
+
break;
|
|
6273
6393
|
case "relay:setup":
|
|
6274
6394
|
await cmdRelaySetup(args);
|
|
6275
6395
|
break;
|
package/bin/cli-commands.mjs
CHANGED
|
@@ -103,6 +103,7 @@ export const COMMANDS = [
|
|
|
103
103
|
{ name: "token", group: "node", scope: "node", usage: "token", summary: "Print a device token for this node" },
|
|
104
104
|
{ name: "open", group: "node", scope: "node", usage: "open", summary: "Open the remote web app" },
|
|
105
105
|
{ name: "link", group: "node", scope: "node", usage: "link", summary: "Show a web app link as a QR code in the terminal" },
|
|
106
|
+
{ name: "tailscale", group: "node", scope: "node", usage: "tailscale [on|pair|status|devices|revoke|off]", summary: "Reach this machine over Tailscale, with no server in between", subcommands: ["on", "pair", "status", "devices", "revoke", "off"] },
|
|
106
107
|
{ name: "relay:setup", group: "node", scope: "node", usage: "relay:setup", summary: "Enable secure remote web app access" },
|
|
107
108
|
{ name: "uninstall", group: "node", scope: "node", usage: "uninstall [--keep-sessions] [--dry-run]", summary: "Remove Bivy and all its data" },
|
|
108
109
|
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// Copyright (c) 2026 Petter André Sjulstad
|
|
3
|
+
// Pure helpers for `bivy tailscale`: read `tailscale status --json` and
|
|
4
|
+
// `tailscale serve status --json`, and build the `tailscale serve` arguments.
|
|
5
|
+
// No process spawning here, so each decision is testable on its own.
|
|
6
|
+
|
|
7
|
+
/** The one HTTPS port Bivy claims on the machine's tailnet name. */
|
|
8
|
+
export const SERVE_HTTPS_PORT = 443;
|
|
9
|
+
|
|
10
|
+
/** `{ running, hostname }` from `tailscale status --json` output. */
|
|
11
|
+
export function parseTailscaleStatus(text) {
|
|
12
|
+
let status;
|
|
13
|
+
try { status = JSON.parse(text); } catch { return { running: false, hostname: null }; }
|
|
14
|
+
const hostname = String(status?.Self?.DNSName ?? "").replace(/\.$/, "") || null;
|
|
15
|
+
return { running: status?.BackendState === "Running", hostname };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Where `tailscale serve` should send the machine's HTTPS traffic. */
|
|
19
|
+
export function serveTarget(port) {
|
|
20
|
+
return `http://127.0.0.1:${port}`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function serveOnArgs(port) {
|
|
24
|
+
return ["serve", "--bg", `--https=${SERVE_HTTPS_PORT}`, serveTarget(port)];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function serveOffArgs() {
|
|
28
|
+
return ["serve", `--https=${SERVE_HTTPS_PORT}`, "off"];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* What already sits on `<hostname>:443`, from `tailscale serve status --json`:
|
|
33
|
+
* `funnel` when it is public (Tailscale Funnel), and the proxy target of `/`
|
|
34
|
+
* when something is served there. Bivy refuses to share a Funnel'd name, and
|
|
35
|
+
* refuses to replace a target that isn't its own.
|
|
36
|
+
*/
|
|
37
|
+
export function serveState(text, hostname) {
|
|
38
|
+
let status;
|
|
39
|
+
try { status = JSON.parse(text || "{}"); } catch { status = {}; }
|
|
40
|
+
const key = `${hostname}:${SERVE_HTTPS_PORT}`;
|
|
41
|
+
const handlers = status?.Web?.[key]?.Handlers ?? {};
|
|
42
|
+
return {
|
|
43
|
+
funnel: status?.AllowFunnel?.[key] === true,
|
|
44
|
+
target: typeof handlers["/"]?.Proxy === "string" ? handlers["/"].Proxy : null,
|
|
45
|
+
otherPaths: Object.keys(handlers).filter((p) => p !== "/"),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Why `bivy tailscale` can't take `<hostname>:443`, or null when it can. */
|
|
50
|
+
export function serveConflict(state, port) {
|
|
51
|
+
if (state.funnel) return "Tailscale Funnel is on for this machine's HTTPS name, which would put Bivy on the public internet. Turn it off first: tailscale funnel reset";
|
|
52
|
+
if (state.target && state.target !== serveTarget(port)) return `tailscale serve already sends https:// on this machine to ${state.target}. Remove it first: tailscale serve --https=${SERVE_HTTPS_PORT} off`;
|
|
53
|
+
if (state.otherPaths.length) return `tailscale serve already has paths on this machine's HTTPS name (${state.otherPaths.join(", ")}). Remove them first: tailscale serve --https=${SERVE_HTTPS_PORT} off`;
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** A one-line hint for a failed `tailscale serve`, from its stderr. */
|
|
58
|
+
export function serveFailureHint(stderr) {
|
|
59
|
+
const text = String(stderr ?? "");
|
|
60
|
+
if (/access denied|permission denied|must be root|operator/i.test(text)) return "Let your user manage Tailscale once: sudo tailscale set --operator=$USER";
|
|
61
|
+
if (/https.*not enabled|enable https|certificates/i.test(text)) return "Turn on HTTPS certificates for your tailnet (admin console → DNS → HTTPS Certificates), then run this again.";
|
|
62
|
+
return null;
|
|
63
|
+
}
|
package/dist/auth.js
CHANGED
|
@@ -112,6 +112,18 @@ export function isLoopbackAddress(address) {
|
|
|
112
112
|
const addr = address.replace(/^::ffff:/, "");
|
|
113
113
|
return addr === "127.0.0.1" || addr === "::1" || addr === "localhost";
|
|
114
114
|
}
|
|
115
|
+
// Sockets accepted by a listener that fronts remote callers through a local
|
|
116
|
+
// proxy (`tailscale serve` forwards tailnet traffic from 127.0.0.1). Their peer
|
|
117
|
+
// address is loopback, but the caller is not, so they never get loopback trust.
|
|
118
|
+
const remoteSockets = new WeakSet();
|
|
119
|
+
/** Treat every connection `server` accepts as remote, whatever its peer address. */
|
|
120
|
+
export function markRemoteListener(server) {
|
|
121
|
+
server.on("connection", (socket) => remoteSockets.add(socket));
|
|
122
|
+
}
|
|
123
|
+
/** True when a request physically arrived over loopback from a local caller. */
|
|
124
|
+
export function isLoopbackRequest(req) {
|
|
125
|
+
return !!req.socket && !remoteSockets.has(req.socket) && isLoopbackAddress(req.socket.remoteAddress);
|
|
126
|
+
}
|
|
115
127
|
export function extractToken(headerValue) {
|
|
116
128
|
if (!headerValue)
|
|
117
129
|
return null;
|
|
@@ -141,7 +153,7 @@ export function tokenFromRequest(req) {
|
|
|
141
153
|
* `/api/auth/bootstrap`) can rely on it even when the general bypass in
|
|
142
154
|
* `isAuthorized` is off. */
|
|
143
155
|
export function resolveAuth(identity, req) {
|
|
144
|
-
const loopback =
|
|
156
|
+
const loopback = isLoopbackRequest(req);
|
|
145
157
|
const deviceId = identity.verifyToken(tokenFromRequest(req));
|
|
146
158
|
return { deviceId, loopback };
|
|
147
159
|
}
|
|
@@ -160,7 +172,8 @@ export function isAuthorized(ctx) {
|
|
|
160
172
|
* web client (?local=1), a LAN device browsing the node directly, or CLI/native
|
|
161
173
|
* clients (no Origin).
|
|
162
174
|
* Remote phones reach the node through the relay, which the node dials
|
|
163
|
-
* *outbound* — those never arrive here as inbound upgrades
|
|
175
|
+
* *outbound* — those never arrive here as inbound upgrades — or over Tailscale
|
|
176
|
+
* on the `*.ts.net` name, which counts as local here. So we allow only
|
|
164
177
|
* local/private hostnames and reject a public Host (rebinding) or public Origin
|
|
165
178
|
* (cross-site). Escape hatches: BIVY_ALLOWED_HOSTS (comma-separated extra
|
|
166
179
|
* hostnames, e.g. a reverse-proxy domain) and BIVY_ALLOW_ANY_ORIGIN=1.
|
|
@@ -272,7 +285,7 @@ export function authMiddleware(identity, sessionTokens) {
|
|
|
272
285
|
return;
|
|
273
286
|
}
|
|
274
287
|
sessionTokens.used?.(sessionId, call);
|
|
275
|
-
req.auth = { deviceId: null, loopback:
|
|
288
|
+
req.auth = { deviceId: null, loopback: isLoopbackRequest(req), sessionId };
|
|
276
289
|
next();
|
|
277
290
|
return;
|
|
278
291
|
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// Copyright (c) 2026 Petter André Sjulstad
|
|
3
|
+
//
|
|
4
|
+
// One-time pairing codes for devices that reach the node directly (over
|
|
5
|
+
// Tailscale). The owner mints a code on the machine (`bivy tailscale pair`),
|
|
6
|
+
// the phone opens `https://<machine>.ts.net/#pair=<code>` and trades the code
|
|
7
|
+
// for a device token. Codes are single-use, short-lived and held only in
|
|
8
|
+
// memory, so a restart invalidates every outstanding one.
|
|
9
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
10
|
+
export const PAIR_CODE_TTL_MS = 10 * 60 * 1000;
|
|
11
|
+
const digest = (code) => createHash("sha256").update(code).digest("hex");
|
|
12
|
+
export class PairCodes {
|
|
13
|
+
now;
|
|
14
|
+
// Keyed by the code's hash, so a lookup never compares secrets directly.
|
|
15
|
+
pending = new Map();
|
|
16
|
+
constructor(now = Date.now) {
|
|
17
|
+
this.now = now;
|
|
18
|
+
}
|
|
19
|
+
issue(ttlMs = PAIR_CODE_TTL_MS) {
|
|
20
|
+
this.sweep();
|
|
21
|
+
const code = randomBytes(24).toString("base64url");
|
|
22
|
+
const expiresAt = this.now() + ttlMs;
|
|
23
|
+
this.pending.set(digest(code), expiresAt);
|
|
24
|
+
return { code, expiresAt };
|
|
25
|
+
}
|
|
26
|
+
/** True once per valid, unexpired code; the code is spent either way. */
|
|
27
|
+
redeem(code) {
|
|
28
|
+
if (!code)
|
|
29
|
+
return false;
|
|
30
|
+
const key = digest(code);
|
|
31
|
+
const expiresAt = this.pending.get(key);
|
|
32
|
+
this.pending.delete(key);
|
|
33
|
+
return expiresAt !== undefined && expiresAt > this.now();
|
|
34
|
+
}
|
|
35
|
+
sweep() {
|
|
36
|
+
const now = this.now();
|
|
37
|
+
for (const [key, expiresAt] of this.pending)
|
|
38
|
+
if (expiresAt <= now)
|
|
39
|
+
this.pending.delete(key);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// Copyright (c) 2026 Petter André Sjulstad
|
|
3
|
+
//
|
|
4
|
+
// Direct access over Tailscale: no control plane, no relay. `bivy tailscale`
|
|
5
|
+
// writes `<data-dir>/tailscale.json` and points `tailscale serve` (HTTPS on the
|
|
6
|
+
// machine's ts.net name, tailnet only) at a loopback port; the node opens that
|
|
7
|
+
// port as its direct listener. Every connection on it is treated as remote, so
|
|
8
|
+
// callers need a device token, which a phone gets by redeeming a pairing code.
|
|
9
|
+
import fs from "node:fs";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
export const DIRECT_CONFIG_FILE = "tailscale.json";
|
|
12
|
+
export function loadDirectListenerConfig(appDir) {
|
|
13
|
+
try {
|
|
14
|
+
const raw = JSON.parse(fs.readFileSync(path.join(appDir, DIRECT_CONFIG_FILE), "utf8"));
|
|
15
|
+
const port = Number(raw.port);
|
|
16
|
+
if (!Number.isInteger(port) || port <= 0 || port > 65535)
|
|
17
|
+
return null;
|
|
18
|
+
return { port, hostname: typeof raw.hostname === "string" && raw.hostname ? raw.hostname : undefined };
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
}
|
package/dist/remote/index.js
CHANGED
package/dist/server.js
CHANGED
|
@@ -84,8 +84,11 @@ import { canOpenBrowser, openBrowser } from "./browser-open.js";
|
|
|
84
84
|
import { openOAuthLoginOnNode } from "./runtime/oauth/oauth-node-open.js";
|
|
85
85
|
import { collectNodeStats } from "./node-stats.js";
|
|
86
86
|
import { SessionEventCoalescer } from "./session-event-coalescer.js";
|
|
87
|
-
import { authMiddleware, resolveAuth, isAuthorized, requestOriginAllowed } from "./auth.js";
|
|
88
|
-
import {
|
|
87
|
+
import { authMiddleware, resolveAuth, isAuthorized, requestOriginAllowed, markRemoteListener } from "./auth.js";
|
|
88
|
+
import { PairCodes } from "./pair-codes.js";
|
|
89
|
+
import http from "node:http";
|
|
90
|
+
import { webAppDirs, webAppRouter } from "./web-app.js";
|
|
91
|
+
import { RelayConnector, loadRelayConfig, loadDirectListenerConfig, soloCredentials } from "./remote/index.js";
|
|
89
92
|
import { readEphemeralTeardownConfig, shouldSelfTeardown, snapshotsDurableForTeardown, performSelfTeardown } from "./ephemeral-teardown.js";
|
|
90
93
|
import { buildSessionSnapshot, applySessionSnapshot } from "./session/snapshot.js";
|
|
91
94
|
import { clearTurnActivity } from "./session/turn-activity.js";
|
|
@@ -1070,9 +1073,16 @@ const accessDevices = createAccessDeviceController({
|
|
|
1070
1073
|
create: (name) => identity.createDevice(name),
|
|
1071
1074
|
revoke: (id) => identity.revokeDevice(id),
|
|
1072
1075
|
onCreated: (device) => broadcast({ type: "device.created", device }),
|
|
1073
|
-
onRevoked: (id) =>
|
|
1076
|
+
onRevoked: (id) => {
|
|
1077
|
+
broadcast({ type: "device.revoked", id });
|
|
1078
|
+
// A revoked token loses its open sockets now, not at its next reconnect.
|
|
1079
|
+
for (const socket of clients)
|
|
1080
|
+
if (socketDeviceIds.get(socket) === id)
|
|
1081
|
+
socket.close(1008, "Unauthorized");
|
|
1082
|
+
},
|
|
1074
1083
|
});
|
|
1075
1084
|
const clients = new Set();
|
|
1085
|
+
const socketDeviceIds = new WeakMap();
|
|
1076
1086
|
const commandProcesses = new Map();
|
|
1077
1087
|
const oauthLogins = new Map();
|
|
1078
1088
|
// A browser-initiated subscription login parks the node on `manualCodePromise`
|
|
@@ -9337,7 +9347,8 @@ app.use((_req, res, next) => {
|
|
|
9337
9347
|
// (OAuth callback, GitHub App manifest). It does NOT host the web UI — the
|
|
9338
9348
|
// React/Vite PWA (@bivy/web) is served exclusively by the hosted or self-hosted
|
|
9339
9349
|
// control plane. `/` returns a small informational stub for anyone who points a
|
|
9340
|
-
// browser straight at the node.
|
|
9350
|
+
// browser straight at the node. The one exception is the direct listener for
|
|
9351
|
+
// `bivy tailscale` (below), which has no control plane and serves the app itself.
|
|
9341
9352
|
app.get("/", (_req, res) => {
|
|
9342
9353
|
res.setHeader("Cache-Control", "no-store, max-age=0");
|
|
9343
9354
|
res
|
|
@@ -9447,6 +9458,20 @@ app.post("/api/auth/bootstrap", sensitiveRateLimiter, (req, res) => {
|
|
|
9447
9458
|
const { device, token } = accessDevices.create(name);
|
|
9448
9459
|
res.json({ ok: true, device, token });
|
|
9449
9460
|
});
|
|
9461
|
+
// Pairing a device that reaches the node directly (Tailscale): trade a one-time
|
|
9462
|
+
// code minted on this machine for a device token. Registered before the auth
|
|
9463
|
+
// middleware because the caller has no token yet; the code is the credential.
|
|
9464
|
+
const pairCodes = new PairCodes();
|
|
9465
|
+
app.post("/api/auth/pair", sensitiveRateLimiter, (req, res) => {
|
|
9466
|
+
if (!requestOriginAllowed(req))
|
|
9467
|
+
return res.status(403).json({ error: "Forbidden origin" });
|
|
9468
|
+
if (!pairCodes.redeem(String(req.body?.code ?? ""))) {
|
|
9469
|
+
return res.status(401).json({ error: "This pairing link is used or expired. Run 'bivy tailscale pair' on your machine for a new one." });
|
|
9470
|
+
}
|
|
9471
|
+
const name = String(req.body?.name ?? "").trim().slice(0, 80) || "Paired device";
|
|
9472
|
+
const { device, token } = accessDevices.create(name);
|
|
9473
|
+
res.json({ ok: true, device, token });
|
|
9474
|
+
});
|
|
9450
9475
|
// All other /api routes require auth (loopback may bypass, per config and host
|
|
9451
9476
|
// — see loopbackAllowed()/isMultiUserHost() in src/auth.ts).
|
|
9452
9477
|
app.use("/api", authMiddleware(identity, {
|
|
@@ -9457,6 +9482,23 @@ app.use("/api", authMiddleware(identity, {
|
|
|
9457
9482
|
used: (sessionId, call) => { if (call.method !== "GET")
|
|
9458
9483
|
auditLog.record({ kind: "agent.call", session: sessionId, tool: `${call.method} ${call.path}` }); },
|
|
9459
9484
|
}));
|
|
9485
|
+
// Mint a pairing code for a direct (Tailscale) device. Only callers already
|
|
9486
|
+
// trusted by this node get one: the owner's CLI, or an already-paired device.
|
|
9487
|
+
app.post("/api/auth/pair-codes", (_req, res) => {
|
|
9488
|
+
const direct = loadDirectListenerConfig(appDir);
|
|
9489
|
+
const { code, expiresAt } = pairCodes.issue();
|
|
9490
|
+
const url = direct?.hostname ? `https://${direct.hostname}/#pair=${code}` : undefined;
|
|
9491
|
+
res.json({ ok: true, code, expiresAt, url });
|
|
9492
|
+
});
|
|
9493
|
+
// Start, move or stop the direct listener after `bivy tailscale` changes it.
|
|
9494
|
+
app.post("/api/direct/reload", async (_req, res, next) => {
|
|
9495
|
+
try {
|
|
9496
|
+
res.json({ ok: true, ...(await reloadDirectListener()) });
|
|
9497
|
+
}
|
|
9498
|
+
catch (error) {
|
|
9499
|
+
next(error);
|
|
9500
|
+
}
|
|
9501
|
+
});
|
|
9460
9502
|
// Reload relay.json without forcing the user to restart the whole node. This is
|
|
9461
9503
|
// used by `bivy relay:setup` after it enrolls the node.
|
|
9462
9504
|
app.post("/api/relay/reload", (_req, res) => {
|
|
@@ -12007,6 +12049,7 @@ const server = app.listen(port, host, async () => {
|
|
|
12007
12049
|
console.log(`Agent data dir: ${piDir}`);
|
|
12008
12050
|
console.log(`Workspace: ${defaultWorkspace}`);
|
|
12009
12051
|
startRelayIfConfigured();
|
|
12052
|
+
void reloadDirectListener().catch((error) => console.error("[direct] Could not start the direct listener:", error instanceof Error ? error.message : error));
|
|
12010
12053
|
void syncAgentBridges().catch((error) => console.warn("[bridges] Could not install agent bridges; they will install on first use:", error instanceof Error ? error.message : error));
|
|
12011
12054
|
if (appGateway) {
|
|
12012
12055
|
appGateway.server.on("error", (error) => { console.error("[apps] Preview gateway could not start:", error); shutdown("preview gateway failure"); });
|
|
@@ -12073,6 +12116,46 @@ server.on("error", (error) => {
|
|
|
12073
12116
|
process.exit(1);
|
|
12074
12117
|
});
|
|
12075
12118
|
const wss = new WebSocketServer({ server, path: "/ws" });
|
|
12119
|
+
// Direct listener (`bivy tailscale`): the same API and WebSocket, plus the web
|
|
12120
|
+
// app, on a loopback port `tailscale serve` exposes to the tailnet over HTTPS.
|
|
12121
|
+
// markRemoteListener keeps its proxied, loopback-looking connections from ever
|
|
12122
|
+
// getting loopback trust; every caller needs a device token.
|
|
12123
|
+
const directWebApp = webAppRouter(webAppDirs(repoRoot));
|
|
12124
|
+
let directListener = null;
|
|
12125
|
+
async function reloadDirectListener() {
|
|
12126
|
+
const config = loadDirectListenerConfig(appDir);
|
|
12127
|
+
if (directListener && directListener.port !== config?.port) {
|
|
12128
|
+
const previous = directListener;
|
|
12129
|
+
directListener = null;
|
|
12130
|
+
for (const client of previous.wss.clients)
|
|
12131
|
+
client.terminate();
|
|
12132
|
+
previous.wss.close();
|
|
12133
|
+
previous.server.closeAllConnections();
|
|
12134
|
+
await new Promise((resolve) => previous.server.close(() => resolve()));
|
|
12135
|
+
}
|
|
12136
|
+
if (config && !directListener) {
|
|
12137
|
+
const directApp = express();
|
|
12138
|
+
directApp.disable("x-powered-by");
|
|
12139
|
+
if (directWebApp)
|
|
12140
|
+
directApp.use(directWebApp);
|
|
12141
|
+
directApp.use(app);
|
|
12142
|
+
const listener = http.createServer(directApp);
|
|
12143
|
+
markRemoteListener(listener);
|
|
12144
|
+
const directWss = new WebSocketServer({ server: listener, path: "/ws" });
|
|
12145
|
+
directWss.on("connection", (socket, req) => wss.emit("connection", socket, req));
|
|
12146
|
+
await new Promise((resolve, reject) => {
|
|
12147
|
+
listener.once("error", reject);
|
|
12148
|
+
listener.listen(config.port, "127.0.0.1", () => {
|
|
12149
|
+
listener.off("error", reject);
|
|
12150
|
+
resolve();
|
|
12151
|
+
});
|
|
12152
|
+
});
|
|
12153
|
+
listener.on("error", (error) => console.error("[direct] Listener error:", error));
|
|
12154
|
+
directListener = { port: config.port, server: listener, wss: directWss };
|
|
12155
|
+
console.log(`[direct] Listening on 127.0.0.1:${config.port}${config.hostname ? ` for https://${config.hostname}` : ""}${directWebApp ? "" : " (web app not built; API only)"}`);
|
|
12156
|
+
}
|
|
12157
|
+
return { enabled: Boolean(directListener), port: directListener?.port, webApp: Boolean(directWebApp) };
|
|
12158
|
+
}
|
|
12076
12159
|
wss.on("connection", (socket, req) => {
|
|
12077
12160
|
// Reject cross-origin / DNS-rebinding upgrades before auth: loopback bypasses
|
|
12078
12161
|
// auth by default, so a page the user merely visited could otherwise open a
|
|
@@ -12081,11 +12164,14 @@ wss.on("connection", (socket, req) => {
|
|
|
12081
12164
|
socket.close(1008, "Forbidden origin");
|
|
12082
12165
|
return;
|
|
12083
12166
|
}
|
|
12084
|
-
|
|
12167
|
+
const socketAuth = resolveAuth(identity, req);
|
|
12168
|
+
if (!isAuthorized(socketAuth)) {
|
|
12085
12169
|
socket.close(1008, "Unauthorized");
|
|
12086
12170
|
return;
|
|
12087
12171
|
}
|
|
12088
12172
|
clients.add(socket);
|
|
12173
|
+
if (socketAuth.deviceId)
|
|
12174
|
+
socketDeviceIds.set(socket, socketAuth.deviceId);
|
|
12089
12175
|
// Terminals opened on this socket; closed when the socket disconnects so a
|
|
12090
12176
|
// dropped browser tab doesn't leak shells. Output is unicast to this socket.
|
|
12091
12177
|
const ownedTerminals = new Set();
|
package/dist/web-app.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// Copyright (c) 2026 Petter André Sjulstad
|
|
3
|
+
//
|
|
4
|
+
// The web app, served by the node itself on its direct listener (Tailscale).
|
|
5
|
+
// Elsewhere the control plane serves it and the node stays a pure data plane;
|
|
6
|
+
// here there is no control plane, so the node hands out the same build and
|
|
7
|
+
// tells it, through /runtime-config.js, to talk to this node directly.
|
|
8
|
+
import fs from "node:fs";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import express from "express";
|
|
11
|
+
/** Where a packaged install and a source checkout keep the built app and its icons. */
|
|
12
|
+
export function webAppDirs(packageRoot) {
|
|
13
|
+
return [
|
|
14
|
+
path.join(packageRoot, "web"),
|
|
15
|
+
path.join(packageRoot, "packages", "web", "dist"),
|
|
16
|
+
path.join(packageRoot, "services", "control-plane", "public"),
|
|
17
|
+
].filter((dir) => fs.existsSync(dir));
|
|
18
|
+
}
|
|
19
|
+
/** Paths the node itself answers, never the app shell. */
|
|
20
|
+
const NODE_ROUTES = ["/api", "/ws", "/healthz", "/github"];
|
|
21
|
+
function noStore(res) {
|
|
22
|
+
res.setHeader("Cache-Control", "no-store, max-age=0");
|
|
23
|
+
}
|
|
24
|
+
/** Serves the app shell, assets and a direct-mode runtime config; passes on anything else. */
|
|
25
|
+
export function webAppRouter(dirs) {
|
|
26
|
+
const indexDir = dirs.find((dir) => fs.existsSync(path.join(dir, "index.html")));
|
|
27
|
+
if (!indexDir)
|
|
28
|
+
return null;
|
|
29
|
+
// Read once: the shell is served from memory, with no file access per request.
|
|
30
|
+
const indexHtml = fs.readFileSync(path.join(indexDir, "index.html"));
|
|
31
|
+
const router = express.Router();
|
|
32
|
+
router.get("/runtime-config.js", (_req, res) => {
|
|
33
|
+
noStore(res);
|
|
34
|
+
res.type("application/javascript").send(`globalThis.__BIVY_RUNTIME_CONFIG__ = ${JSON.stringify({ directNode: true })};\n`);
|
|
35
|
+
});
|
|
36
|
+
for (const dir of dirs) {
|
|
37
|
+
router.use(express.static(dir, {
|
|
38
|
+
index: false,
|
|
39
|
+
setHeaders(res, filePath) {
|
|
40
|
+
const name = path.basename(filePath);
|
|
41
|
+
if (name === "index.html" || name === "sw.js" || name.startsWith("workbox-"))
|
|
42
|
+
noStore(res);
|
|
43
|
+
},
|
|
44
|
+
}));
|
|
45
|
+
}
|
|
46
|
+
// Client-side routes (`/sessions/…`, `/settings`, `/share`, …) load the shell;
|
|
47
|
+
// the node's own routes pass through.
|
|
48
|
+
router.get(/.*/, (req, res, next) => {
|
|
49
|
+
if (NODE_ROUTES.some((prefix) => req.path === prefix || req.path.startsWith(`${prefix}/`)) || !req.accepts("html"))
|
|
50
|
+
return next();
|
|
51
|
+
noStore(res);
|
|
52
|
+
res.type("html").send(indexHtml);
|
|
53
|
+
});
|
|
54
|
+
return router;
|
|
55
|
+
}
|
package/package.json
CHANGED
|
Binary file
|