@kinski/opencode-web-gateway 0.0.0-stage → 1.0.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/LICENSE +21 -0
- package/README.md +167 -2
- package/bin/opencode-web-gateway.js +235 -0
- package/dist/index.js +1245 -0
- package/examples/opencode.v1.json +14 -0
- package/examples/opencode.v2.jsonc +14 -0
- package/examples/web-gateway.config.json +7 -0
- package/package.json +37 -4
package/dist/index.js
ADDED
|
@@ -0,0 +1,1245 @@
|
|
|
1
|
+
// AUTO-GENERATED FILE — do not edit directly.
|
|
2
|
+
// Source: src/web-gateway.js + web/index.html
|
|
3
|
+
// Rebuild: node scripts/build.mjs
|
|
4
|
+
// opencode-web-gateway — raw HTTP-over-WebSocket bridge for opencode.
|
|
5
|
+
//
|
|
6
|
+
// The plugin exposes a local OpenAI-compatible endpoint. A browser page connects
|
|
7
|
+
// to it over WebSocket and performs the actual fetch to the upstream API. Request
|
|
8
|
+
// and response bodies are forwarded byte-for-byte; no SSE parsing happens here.
|
|
9
|
+
//
|
|
10
|
+
// This file is the source template. scripts/build.mjs embeds web/index.html and
|
|
11
|
+
// writes the runnable plugin to .opencode/plugins/web-gateway.js.
|
|
12
|
+
|
|
13
|
+
import { createHash, randomBytes, randomUUID } from "node:crypto"
|
|
14
|
+
import { existsSync, readFileSync } from "node:fs"
|
|
15
|
+
import { createServer } from "node:http"
|
|
16
|
+
import { homedir } from "node:os"
|
|
17
|
+
import path from "node:path"
|
|
18
|
+
|
|
19
|
+
const SERVICE = "opencode-web-gateway"
|
|
20
|
+
const PROTOCOL = 1
|
|
21
|
+
const VERSION = "1.0.0"
|
|
22
|
+
const PLUGIN_ID = "opencode.web-gateway"
|
|
23
|
+
const PAGE_HTML = "<!doctype html>\n<html lang=\"zh-CN\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n<meta name=\"color-scheme\" content=\"dark\">\n<meta name=\"theme-color\" content=\"#060912\">\n<meta name=\"description\" content=\"OpenCode Web Gateway — 浏览器出网桥:opencode 只访问本机,DeepSeek 请求由网页透传。\">\n<title>OpenCode Web Gateway · 浏览器出网桥</title>\n<style>\n :root {\n --bg: #060912;\n --bg-soft: #0b1020;\n --panel: rgba(15, 23, 42, 0.72);\n --panel-solid: #0e1526;\n --line: rgba(148, 163, 184, 0.14);\n --line-strong: rgba(148, 163, 184, 0.24);\n --text: #e6edf7;\n --muted: #8b9bb4;\n --faint: #5b6b84;\n --teal: #2dd4bf;\n --cyan: #38bdf8;\n --violet: #a78bfa;\n --green: #34d399;\n --amber: #fbbf24;\n --red: #fb7185;\n --mono: \"Cascadia Code\", \"JetBrains Mono\", Consolas, \"SFMono-Regular\", monospace;\n --sans: \"Segoe UI Variable Display\", \"Segoe UI\", \"Microsoft YaHei UI\", \"Microsoft YaHei\", system-ui, sans-serif;\n --radius: 18px;\n }\n\n * { box-sizing: border-box; }\n\n html, body { min-height: 100%; }\n\n body {\n margin: 0;\n background: var(--bg);\n color: var(--text);\n font-family: var(--sans);\n -webkit-font-smoothing: antialiased;\n overflow-x: hidden;\n }\n\n body::before {\n content: \"\";\n position: fixed;\n inset: 0;\n pointer-events: none;\n background:\n radial-gradient(900px 520px at 82% -12%, rgba(56, 189, 248, 0.17), transparent 62%),\n radial-gradient(760px 480px at 8% 0%, rgba(167, 139, 250, 0.14), transparent 60%),\n radial-gradient(680px 420px at 55% 108%, rgba(45, 212, 191, 0.10), transparent 64%),\n linear-gradient(180deg, #070b17 0%, #060912 52%, #05080f 100%);\n z-index: -2;\n }\n\n body::after {\n content: \"\";\n position: fixed;\n inset: 0;\n pointer-events: none;\n opacity: 0.32;\n background-image: linear-gradient(rgba(148, 163, 184, 0.045) 1px, transparent 1px),\n linear-gradient(90deg, rgba(148, 163, 184, 0.045) 1px, transparent 1px);\n background-size: 56px 56px;\n mask-image: radial-gradient(circle at 50% 8%, black, transparent 72%);\n z-index: -1;\n }\n\n ::selection { background: rgba(45, 212, 191, 0.28); }\n\n .shell {\n width: min(1180px, calc(100% - 40px));\n margin: 0 auto;\n padding: 28px 0 40px;\n }\n\n .topbar {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 20px;\n padding-bottom: 22px;\n border-bottom: 1px solid var(--line);\n }\n\n .brand { display: flex; align-items: center; gap: 13px; min-width: 0; }\n\n .brand .mark {\n width: 42px;\n height: 42px;\n display: grid;\n place-items: center;\n border-radius: 13px;\n border: 1px solid rgba(45, 212, 191, 0.35);\n background: linear-gradient(145deg, rgba(45, 212, 191, 0.18), rgba(56, 189, 248, 0.06));\n color: var(--teal);\n font-size: 22px;\n line-height: 1;\n box-shadow: 0 0 34px rgba(45, 212, 191, 0.13), inset 0 1px 0 rgba(255, 255, 255, 0.08);\n flex: none;\n }\n\n .brand-text { min-width: 0; }\n .brand-text strong {\n display: block;\n font-size: 15px;\n letter-spacing: 0.01em;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n .brand-text span {\n display: block;\n margin-top: 2px;\n color: var(--faint);\n font-size: 11.5px;\n letter-spacing: 0.16em;\n text-transform: uppercase;\n }\n\n .conn-pill {\n display: inline-flex;\n align-items: center;\n gap: 9px;\n padding: 9px 15px;\n border-radius: 999px;\n border: 1px solid var(--line-strong);\n background: rgba(8, 13, 24, 0.7);\n color: var(--muted);\n font-size: 12.5px;\n font-weight: 600;\n letter-spacing: 0.02em;\n white-space: nowrap;\n transition: color 160ms ease, border-color 160ms ease, background 160ms ease;\n }\n .conn-pill .dot {\n width: 8px; height: 8px; border-radius: 50%;\n background: var(--faint);\n box-shadow: 0 0 0 0 rgba(148, 163, 184, 0.4);\n transition: background 160ms ease;\n }\n .conn-pill[data-phase=\"ready\"] { color: #b9f6e8; border-color: rgba(45, 212, 191, 0.4); background: rgba(45, 212, 191, 0.08); }\n .conn-pill[data-phase=\"ready\"] .dot { background: var(--green); animation: pulse 2.2s infinite; }\n .conn-pill[data-phase=\"connecting\"] .dot,\n .conn-pill[data-phase=\"handshake\"] .dot { background: var(--amber); animation: blink 1s infinite; }\n .conn-pill[data-phase=\"retrying\"] .dot { background: var(--amber); animation: blink 1s infinite; }\n .conn-pill[data-phase=\"stopped\"] .dot { background: var(--faint); }\n .conn-pill[data-phase=\"failed\"] { color: #fecdd3; border-color: rgba(251, 113, 133, 0.35); background: rgba(251, 113, 133, 0.07); }\n .conn-pill[data-phase=\"failed\"] .dot { background: var(--red); }\n\n @keyframes pulse {\n 0% { box-shadow: 0 0 0 0 rgba(52, 211, 153, 0.45); }\n 70% { box-shadow: 0 0 0 9px rgba(52, 211, 153, 0); }\n 100% { box-shadow: 0 0 0 0 rgba(52, 211, 153, 0); }\n }\n @keyframes blink { 50% { opacity: 0.35; } }\n\n .hero {\n display: grid;\n grid-template-columns: minmax(0, 1fr) auto;\n gap: 40px;\n align-items: end;\n padding: 44px 0 34px;\n }\n\n .eyebrow {\n margin: 0 0 14px;\n color: var(--teal);\n font-family: var(--mono);\n font-size: 11px;\n font-weight: 600;\n letter-spacing: 0.22em;\n }\n .mode-badge {\n display: inline-block;\n margin-left: 10px;\n padding: 2px 7px;\n border: 1px solid rgba(56, 189, 248, 0.32);\n border-radius: 999px;\n background: rgba(56, 189, 248, 0.08);\n color: #7dd3fc;\n font-size: 9px;\n letter-spacing: 0.06em;\n vertical-align: 1px;\n }\n\n h1 {\n margin: 0;\n font-size: clamp(32px, 4.4vw, 52px);\n font-weight: 680;\n line-height: 1.08;\n letter-spacing: -0.035em;\n text-wrap: balance;\n }\n h1 em {\n font-style: normal;\n background: linear-gradient(100deg, #5eead4 4%, #38bdf8 48%, #a78bfa 96%);\n -webkit-background-clip: text;\n background-clip: text;\n color: transparent;\n }\n\n .lede {\n max-width: 620px;\n margin: 18px 0 0;\n color: var(--muted);\n font-size: 14.5px;\n line-height: 1.75;\n }\n\n .flow {\n display: flex;\n align-items: center;\n gap: 7px;\n padding: 13px;\n border: 1px solid var(--line);\n border-radius: 15px;\n background: rgba(9, 14, 27, 0.62);\n box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.03);\n }\n .flow .node {\n padding: 8px 10px;\n border-radius: 9px;\n border: 1px solid var(--line);\n background: rgba(15, 23, 42, 0.8);\n color: #c7d2e4;\n font-family: var(--mono);\n font-size: 10.5px;\n white-space: nowrap;\n }\n .flow .node.accent {\n border-color: rgba(45, 212, 191, 0.38);\n color: #9df2e3;\n background: rgba(45, 212, 191, 0.08);\n }\n .flow .line {\n position: relative;\n width: 26px;\n height: 1px;\n background: linear-gradient(90deg, rgba(148, 163, 184, 0.18), rgba(148, 163, 184, 0.45));\n }\n .flow .line.pulse::after {\n content: \"\";\n position: absolute;\n top: -2px; left: 0;\n width: 5px; height: 5px;\n border-radius: 50%;\n background: var(--teal);\n box-shadow: 0 0 9px var(--teal);\n animation: travel 2.4s linear infinite;\n }\n @keyframes travel {\n 0% { transform: translateX(0); opacity: 0; }\n 15% { opacity: 1; }\n 85% { opacity: 1; }\n 100% { transform: translateX(24px); opacity: 0; }\n }\n\n .tiles {\n display: grid;\n grid-template-columns: repeat(4, minmax(0, 1fr));\n gap: 12px;\n }\n\n .tile {\n position: relative;\n overflow: hidden;\n padding: 17px 18px 16px;\n border: 1px solid var(--line);\n border-radius: 15px;\n background: linear-gradient(180deg, rgba(17, 26, 45, 0.8), rgba(10, 16, 30, 0.66));\n box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.03);\n }\n .tile::after {\n content: \"\";\n position: absolute;\n inset: 0 auto auto 0;\n width: 100%;\n height: 1px;\n background: linear-gradient(90deg, transparent, rgba(45, 212, 191, 0.34), transparent);\n opacity: 0;\n transition: opacity 180ms ease;\n }\n .tile:hover::after { opacity: 1; }\n .tile .label {\n color: var(--faint);\n font-family: var(--mono);\n font-size: 10px;\n letter-spacing: 0.16em;\n text-transform: uppercase;\n }\n .tile .value {\n margin-top: 8px;\n font-size: 18px;\n font-weight: 650;\n letter-spacing: -0.015em;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n .tile .sub {\n margin-top: 5px;\n color: var(--muted);\n font-size: 11.5px;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n .tile.good .value { color: #a7f3d0; }\n .tile.warn .value { color: #fde68a; }\n .tile.bad .value { color: #fecdd3; }\n\n .banner {\n display: flex;\n gap: 11px;\n align-items: flex-start;\n margin-top: 14px;\n padding: 13px 16px;\n border: 1px solid rgba(251, 191, 36, 0.28);\n border-radius: 13px;\n background: rgba(251, 191, 36, 0.065);\n color: #fde68a;\n font-size: 12.5px;\n line-height: 1.6;\n }\n .banner .icon { flex: none; font-family: var(--mono); font-size: 13px; line-height: 1.5; }\n .banner[data-tone=\"error\"] { border-color: rgba(251, 113, 133, 0.3); background: rgba(251, 113, 133, 0.07); color: #fecdd3; }\n .hidden { display: none !important; }\n\n .grid {\n display: grid;\n grid-template-columns: minmax(0, 1.35fr) minmax(0, 0.65fr);\n gap: 14px;\n margin-top: 14px;\n }\n\n .panel {\n border: 1px solid var(--line);\n border-radius: var(--radius);\n background: var(--panel);\n box-shadow: 0 24px 70px -48px rgba(0, 0, 0, 0.9), inset 0 1px 0 rgba(255, 255, 255, 0.035);\n backdrop-filter: blur(14px);\n }\n\n .panel-head {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 16px;\n padding: 16px 18px;\n border-bottom: 1px solid var(--line);\n }\n .panel-title { display: flex; align-items: center; gap: 9px; font-size: 13px; font-weight: 650; }\n .panel-title .glyph {\n width: 24px; height: 24px;\n display: grid; place-items: center;\n border-radius: 7px;\n border: 1px solid var(--line);\n background: rgba(148, 163, 184, 0.06);\n color: var(--teal);\n font-family: var(--mono);\n font-size: 11px;\n }\n .panel-note { color: var(--faint); font-size: 11.5px; }\n\n .controls-body { padding: 17px 18px 18px; display: grid; gap: 13px; }\n\n .field label {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 10px;\n margin-bottom: 7px;\n color: var(--muted);\n font-size: 11.5px;\n font-weight: 600;\n letter-spacing: 0.02em;\n }\n .field label .hint { color: var(--faint); font-weight: 500; }\n\n .input-row { display: flex; gap: 9px; }\n .input {\n flex: 1;\n min-width: 0;\n height: 40px;\n padding: 0 12px;\n border: 1px solid var(--line-strong);\n border-radius: 10px;\n background: rgba(5, 9, 18, 0.66);\n color: var(--text);\n font-family: var(--mono);\n font-size: 12px;\n outline: none;\n transition: border-color 150ms ease, box-shadow 150ms ease;\n }\n .input:focus { border-color: rgba(45, 212, 191, 0.5); box-shadow: 0 0 0 3px rgba(45, 212, 191, 0.09); }\n .input::placeholder { color: var(--faint); }\n\n .btn {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n gap: 7px;\n height: 40px;\n padding: 0 16px;\n border: 1px solid var(--line-strong);\n border-radius: 10px;\n background: rgba(148, 163, 184, 0.07);\n color: #dbe6f5;\n font-family: var(--sans);\n font-size: 12.5px;\n font-weight: 650;\n cursor: pointer;\n white-space: nowrap;\n transition: background 150ms ease, border-color 150ms ease, transform 120ms ease;\n }\n .btn:hover { background: rgba(148, 163, 184, 0.12); border-color: rgba(148, 163, 184, 0.4); }\n .btn:active { transform: translateY(1px); }\n .btn.primary {\n border-color: rgba(45, 212, 191, 0.4);\n background: linear-gradient(135deg, rgba(45, 212, 191, 0.19), rgba(56, 189, 248, 0.12));\n color: #c9fff6;\n }\n .btn.primary:hover { border-color: rgba(45, 212, 191, 0.7); background: linear-gradient(135deg, rgba(45, 212, 191, 0.28), rgba(56, 189, 248, 0.17)); }\n .btn.ghost { height: 30px; padding: 0 10px; font-size: 11px; font-weight: 600; color: var(--muted); }\n .btn:disabled { opacity: 0.45; cursor: not-allowed; }\n\n .meta {\n display: grid;\n grid-template-columns: repeat(2, minmax(0, 1fr));\n gap: 9px 18px;\n padding-top: 2px;\n }\n .meta div { min-width: 0; }\n .meta dt { color: var(--faint); font-size: 10.5px; letter-spacing: 0.04em; }\n .meta dd {\n margin: 3px 0 0;\n color: #b8c6d9;\n font-family: var(--mono);\n font-size: 11px;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n\n details.advanced {\n border-top: 1px solid var(--line);\n padding-top: 11px;\n }\n details.advanced summary {\n color: var(--faint);\n font-size: 11.5px;\n cursor: pointer;\n user-select: none;\n list-style: none;\n }\n details.advanced summary::-webkit-details-marker { display: none; }\n details.advanced summary::before { content: \"+ \"; color: var(--teal); }\n details.advanced[open] summary::before { content: \"− \"; }\n details.advanced .field { margin-top: 11px; }\n\n .stats-body { padding: 8px 18px 16px; }\n .stat-row {\n display: flex;\n align-items: baseline;\n justify-content: space-between;\n gap: 16px;\n padding: 12px 0;\n border-bottom: 1px solid rgba(148, 163, 184, 0.09);\n }\n .stat-row:last-child { border-bottom: 0; }\n .stat-row .k { color: var(--muted); font-size: 12px; }\n .stat-row .v { color: #dbe6f5; font-family: var(--mono); font-size: 15px; font-weight: 600; }\n .stat-row .v small { color: var(--faint); font-size: 10px; font-weight: 500; margin-left: 4px; }\n\n .log-panel { margin-top: 14px; overflow: hidden; }\n .log-scroll { max-height: 430px; overflow: auto; }\n table.log { width: 100%; border-collapse: collapse; font-size: 12px; }\n table.log th {\n position: sticky;\n top: 0;\n z-index: 1;\n padding: 10px 14px;\n background: #0b1221;\n border-bottom: 1px solid var(--line);\n color: var(--faint);\n font-family: var(--mono);\n font-size: 9.5px;\n font-weight: 600;\n letter-spacing: 0.13em;\n text-align: left;\n text-transform: uppercase;\n white-space: nowrap;\n }\n table.log td {\n padding: 11px 14px;\n border-bottom: 1px solid rgba(148, 163, 184, 0.075);\n color: #b8c6d9;\n vertical-align: middle;\n white-space: nowrap;\n }\n table.log tr:last-child td { border-bottom: 0; }\n table.log tbody tr { transition: background 120ms ease; }\n table.log tbody tr:hover { background: rgba(148, 163, 184, 0.045); }\n .cell-time { color: var(--faint); font-family: var(--mono); font-size: 10.5px; }\n .method {\n display: inline-block;\n min-width: 45px;\n padding: 3px 6px;\n border-radius: 5px;\n border: 1px solid var(--line);\n background: rgba(148, 163, 184, 0.06);\n color: #9fb0c7;\n font-family: var(--mono);\n font-size: 9.5px;\n text-align: center;\n }\n .path { max-width: 520px; overflow: hidden; text-overflow: ellipsis; font-family: var(--mono); font-size: 11.5px; color: #cbd7e7; }\n .status {\n display: inline-flex;\n align-items: center;\n gap: 5px;\n font-family: var(--mono);\n font-size: 11px;\n }\n .status::before { content: \"\"; width: 5px; height: 5px; border-radius: 50%; background: var(--faint); }\n .status.pending { color: var(--amber); }\n .status.pending::before { background: var(--amber); animation: blink 1s infinite; }\n .status.ok { color: #86efac; }\n .status.ok::before { background: var(--green); }\n .status.warn { color: #fde68a; }\n .status.warn::before { background: var(--amber); }\n .status.bad { color: #fda4af; }\n .status.bad::before { background: var(--red); }\n .num { font-family: var(--mono); font-size: 10.5px; color: var(--muted); text-align: right; }\n .msg { max-width: 260px; overflow: hidden; text-overflow: ellipsis; color: var(--faint); font-size: 11px; }\n .empty {\n display: grid;\n place-items: center;\n gap: 5px;\n padding: 44px 20px;\n color: var(--faint);\n font-size: 12.5px;\n text-align: center;\n }\n .empty strong { color: #a8b6ca; font-weight: 600; font-size: 13px; }\n .empty span { font-size: 11.5px; }\n\n footer {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 18px;\n margin-top: 22px;\n padding-top: 17px;\n border-top: 1px solid var(--line);\n color: var(--faint);\n font-size: 10.5px;\n letter-spacing: 0.03em;\n }\n footer .mono { font-family: var(--mono); }\n footer .sep { color: rgba(148, 163, 184, 0.28); margin: 0 7px; }\n\n @media (max-width: 980px) {\n .hero { grid-template-columns: 1fr; gap: 24px; }\n .flow { justify-self: start; }\n .grid { grid-template-columns: 1fr; }\n .tiles { grid-template-columns: repeat(2, minmax(0, 1fr)); }\n }\n\n @media (max-width: 640px) {\n .shell { width: min(100% - 24px, 1180px); padding-top: 18px; }\n .topbar { align-items: flex-start; }\n .brand-text span { letter-spacing: 0.1em; }\n .conn-pill { padding: 8px 11px; font-size: 11px; }\n .hero { padding: 32px 0 26px; }\n .flow { max-width: 100%; overflow-x: auto; }\n .tiles { gap: 9px; }\n .tile { padding: 14px; }\n .tile .value { font-size: 15px; }\n .input-row { flex-direction: column; }\n .input-row .btn { width: 100%; }\n .panel-head .btn { width: auto; }\n .meta { grid-template-columns: 1fr; }\n .log-scroll { max-height: 360px; }\n .path { max-width: 200px; }\n table.log th:nth-child(5), table.log td:nth-child(5),\n table.log th:nth-child(6), table.log td:nth-child(6),\n table.log th:nth-child(7), table.log td:nth-child(7) { display: none; }\n .msg { display: none; }\n footer { flex-direction: column; align-items: flex-start; gap: 7px; }\n }\n\n @media (prefers-reduced-motion: reduce) {\n *, *::before, *::after { animation: none !important; transition: none !important; }\n }\n</style>\n</head>\n<body>\n<main class=\"shell\">\n <header class=\"topbar\">\n <div class=\"brand\">\n <div class=\"mark\" aria-hidden=\"true\">⇄</div>\n <div class=\"brand-text\">\n <strong>OpenCode Web Gateway</strong>\n <span>Browser egress bridge</span>\n </div>\n </div>\n <div class=\"conn-pill\" id=\"connPill\" data-phase=\"idle\" role=\"status\" aria-live=\"polite\">\n <span class=\"dot\" aria-hidden=\"true\"></span>\n <span id=\"connText\">等待连接</span>\n </div>\n </header>\n\n <section class=\"hero\">\n <div>\n <p class=\"eyebrow\">RAW HTTP / SSE RELAY · PROTOCOL 1 <span class=\"mode-badge hidden\" id=\"modeBadge\">托管页</span></p>\n <h1>让 opencode 留在本地,<br><em>让网页负责出网。</em></h1>\n <p class=\"lede\">\n 此页面主动连接 opencode 插件,把模型请求逐字节转发到 DeepSeek。\n 页面不解析 SSE,也不改写响应;API Key 由 opencode `/connect` 透传,浏览器只做传输。\n </p>\n </div>\n <div class=\"flow\" aria-label=\"数据流向\">\n <span class=\"node\">opencode</span>\n <span class=\"line\"></span>\n <span class=\"node accent\">插件 :8787</span>\n <span class=\"line pulse\"></span>\n <span class=\"node\">本页</span>\n <span class=\"line\"></span>\n <span class=\"node\">DeepSeek</span>\n </div>\n </section>\n\n <section class=\"tiles\" aria-label=\"状态概览\">\n <div class=\"tile\" id=\"tileBridge\">\n <div class=\"label\">Bridge</div>\n <div class=\"value\" id=\"tileBridgeValue\">未连接</div>\n <div class=\"sub\" id=\"tileBridgeSub\">等待插件</div>\n </div>\n <div class=\"tile\" id=\"tileUpstream\">\n <div class=\"label\">Upstream</div>\n <div class=\"value\" id=\"tileUpstreamValue\">—</div>\n <div class=\"sub\" id=\"tileUpstreamSub\">仅允许此来源</div>\n </div>\n <div class=\"tile\" id=\"tileKey\">\n <div class=\"label\">API Key</div>\n <div class=\"value\" id=\"tileKeyValue\">未知</div>\n <div class=\"sub\" id=\"tileKeySub\">由 opencode 提供</div>\n </div>\n <div class=\"tile\" id=\"tileActive\">\n <div class=\"label\">Active</div>\n <div class=\"value\" id=\"tileActiveValue\">0</div>\n <div class=\"sub\" id=\"tileActiveSub\">累计 0 次请求</div>\n </div>\n </section>\n\n <div class=\"banner hidden\" id=\"banner\" role=\"status\">\n <span class=\"icon\" aria-hidden=\"true\">!</span>\n <span id=\"bannerText\"></span>\n </div>\n\n <section class=\"grid\">\n <div class=\"panel\">\n <div class=\"panel-head\">\n <div class=\"panel-title\"><span class=\"glyph\" aria-hidden=\"true\">WS</span>连接设置</div>\n <span class=\"panel-note\">页面主动连接插件</span>\n </div>\n <div class=\"controls-body\">\n <div class=\"field\">\n <label for=\"wsUrl\">插件 WebSocket 地址 <span class=\"hint\" id=\"wsHint\">默认同源</span></label>\n <div class=\"input-row\">\n <input class=\"input\" id=\"wsUrl\" type=\"text\" spellcheck=\"false\" autocomplete=\"off\" placeholder=\"ws://127.0.0.1:8787/bridge\">\n <button class=\"btn primary\" id=\"connectBtn\" type=\"button\">连接</button>\n </div>\n </div>\n\n <dl class=\"meta\">\n <div>\n <dt>插件版本</dt>\n <dd id=\"metaVersion\">—</dd>\n </div>\n <div>\n <dt>配置文件</dt>\n <dd id=\"metaConfig\" title=\"\">—</dd>\n </div>\n <div>\n <dt>允许出网来源</dt>\n <dd id=\"metaOrigin\">—</dd>\n </div>\n <div>\n <dt>插件监听</dt>\n <dd id=\"metaListen\">—</dd>\n </div>\n </dl>\n\n <details class=\"advanced\">\n <summary>高级:直连其他机器 / 访问令牌</summary>\n <div class=\"field\">\n <label for=\"token\">Token <span class=\"hint\">可选,需与插件配置一致</span></label>\n <input class=\"input\" id=\"token\" type=\"password\" spellcheck=\"false\" autocomplete=\"off\" placeholder=\"留空表示未启用\">\n </div>\n <div class=\"field\">\n <label for=\"pageName\">本页名称 <span class=\"hint\">用于日志</span></label>\n <input class=\"input\" id=\"pageName\" type=\"text\" spellcheck=\"false\" autocomplete=\"off\" placeholder=\"browser\">\n </div>\n </details>\n </div>\n </div>\n\n <div class=\"panel\">\n <div class=\"panel-head\">\n <div class=\"panel-title\"><span class=\"glyph\" aria-hidden=\"true\">Σ</span>流量统计</div>\n <button class=\"btn ghost\" id=\"clearBtn\" type=\"button\">清空日志</button>\n </div>\n <div class=\"stats-body\">\n <div class=\"stat-row\"><span class=\"k\">请求总数</span><span class=\"v\" id=\"statRequests\">0</span></div>\n <div class=\"stat-row\"><span class=\"k\">完成 / 失败</span><span class=\"v\"><span id=\"statOk\">0</span> <small>/</small> <span id=\"statErrors\">0</span></span></div>\n <div class=\"stat-row\"><span class=\"k\">上行请求体</span><span class=\"v\" id=\"statSent\">0 B</span></div>\n <div class=\"stat-row\"><span class=\"k\">下行响应体</span><span class=\"v\" id=\"statReceived\">0 B</span></div>\n <div class=\"stat-row\"><span class=\"k\">活动请求</span><span class=\"v\" id=\"statActive\">0</span></div>\n </div>\n </div>\n </section>\n\n <section class=\"panel log-panel\">\n <div class=\"panel-head\">\n <div class=\"panel-title\"><span class=\"glyph\" aria-hidden=\"true\">≡</span>转发记录</div>\n <span class=\"panel-note\" id=\"logNote\">最近 200 条 · 不记录请求头与密钥</span>\n </div>\n <div class=\"log-scroll\" id=\"logScroll\">\n <table class=\"log\">\n <thead>\n <tr>\n <th>时间</th>\n <th>方法</th>\n <th>路径</th>\n <th>状态</th>\n <th>耗时</th>\n <th>下行</th>\n <th>说明</th>\n </tr>\n </thead>\n <tbody id=\"logBody\"></tbody>\n </table>\n <div class=\"empty\" id=\"logEmpty\">\n <strong>还没有转发记录</strong>\n <span>在 opencode 里发起一次对话,这里会出现请求。</span>\n </div>\n </div>\n </section>\n\n <footer>\n <span><span class=\"mono\">opencode-web-gateway</span><span class=\"sep\">·</span>字节级透传,不解析 SSE</span>\n <span>API Key 永不出现在本页界面中<span class=\"sep\">·</span><span class=\"mono\" id=\"footVersion\">v—</span></span>\n </footer>\n</main>\n\n<script id=\"gateway-bootstrap\" type=\"application/json\">__GATEWAY_BOOTSTRAP__</script>\n<script>\n(() => {\n \"use strict\";\n\n const MAX_LOGS = 200;\n const MAX_BUFFERED = 8 * 1024 * 1024;\n const FORBIDDEN_HEADERS = new Set([\n \"host\", \"content-length\", \"connection\", \"accept-encoding\", \"user-agent\",\n \"origin\", \"referer\", \"cookie\", \"upgrade\", \"transfer-encoding\", \"keep-alive\",\n \"te\", \"trailer\", \"proxy-authorization\", \"proxy-authenticate\", \"sec-fetch-mode\",\n \"sec-fetch-site\", \"sec-fetch-dest\", \"sec-ch-ua\", \"sec-ch-ua-mobile\",\n \"sec-ch-ua-platform\", \"sec-websocket-key\", \"sec-websocket-version\",\n \"sec-websocket-extensions\",\n ]);\n const PHASE_TEXT = {\n idle: \"等待连接\",\n connecting: \"正在连接\",\n handshake: \"等待插件握手\",\n ready: \"已连接\",\n retrying: \"等待重连\",\n stopped: \"已断开\",\n failed: \"连接失败\",\n };\n\n const $ = (id) => document.getElementById(id);\n const el = {\n pill: $(\"connPill\"), connText: $(\"connText\"),\n bridgeValue: $(\"tileBridgeValue\"), bridgeSub: $(\"tileBridgeSub\"), bridgeTile: $(\"tileBridge\"),\n upstreamValue: $(\"tileUpstreamValue\"), upstreamSub: $(\"tileUpstreamSub\"), upstreamTile: $(\"tileUpstream\"),\n keyValue: $(\"tileKeyValue\"), keySub: $(\"tileKeySub\"), keyTile: $(\"tileKey\"),\n activeValue: $(\"tileActiveValue\"), activeSub: $(\"tileActiveSub\"), activeTile: $(\"tileActive\"),\n banner: $(\"banner\"), bannerText: $(\"bannerText\"),\n wsUrl: $(\"wsUrl\"), token: $(\"token\"), pageName: $(\"pageName\"), connectBtn: $(\"connectBtn\"),\n metaVersion: $(\"metaVersion\"), metaConfig: $(\"metaConfig\"), metaOrigin: $(\"metaOrigin\"), metaListen: $(\"metaListen\"),\n statRequests: $(\"statRequests\"), statOk: $(\"statOk\"), statErrors: $(\"statErrors\"),\n statSent: $(\"statSent\"), statReceived: $(\"statReceived\"), statActive: $(\"statActive\"),\n logBody: $(\"logBody\"), logEmpty: $(\"logEmpty\"), logScroll: $(\"logScroll\"), logNote: $(\"logNote\"),\n footVersion: $(\"footVersion\"), clearBtn: $(\"clearBtn\"),\n };\n\n function readBootstrap() {\n try {\n const node = document.getElementById(\"gateway-bootstrap\");\n const parsed = JSON.parse(node.textContent.trim());\n return parsed && typeof parsed === \"object\" ? parsed : {};\n } catch {\n return {};\n }\n }\n\n const boot = readBootstrap();\n const query = new URLSearchParams(location.search);\n const pageHost = location.hostname;\n const loopbackPage = pageHost === \"127.0.0.1\" || pageHost === \"localhost\" || pageHost === \"::1\" || pageHost === \"[::1]\";\n const wsProtocol = location.protocol === \"https:\" ? \"wss:\" : \"ws:\";\n const defaultWsUrl = boot.wsUrl || (\n boot.mode === \"static\"\n ? \"ws://127.0.0.1:8787/bridge\"\n : boot.service === \"opencode-web-gateway\" || loopbackPage\n ? `${wsProtocol}//${location.host}/bridge`\n : \"ws://127.0.0.1:8787/bridge\"\n );\n\n const state = {\n phase: \"idle\",\n ws: null,\n hello: null,\n manual: false,\n auto: true,\n attempts: 0,\n retryTimer: 0,\n handshakeTimer: 0,\n retryAt: 0,\n token: boot.token || query.get(\"token\") || \"\",\n name: \"\",\n allowedOrigin: \"\",\n active: new Map(),\n entries: new Map(),\n order: [],\n stats: { requests: 0, ok: 0, errors: 0, sent: 0, received: 0 },\n startedAt: Date.now(),\n lastError: \"\",\n lastClose: null,\n pageId: randomId(8),\n };\n window.__gateway = state;\n\n el.wsUrl.value = query.get(\"ws\") || defaultWsUrl;\n el.token.value = state.token;\n state.name = \"browser-\" + state.pageId;\n if (boot.mode === \"static\") {\n $(\"modeBadge\").classList.remove(\"hidden\");\n $(\"modeBadge\").textContent = \"托管页 · 连接本机\";\n $(\"wsHint\").textContent = \"默认本机插件\";\n }\n\n // ---------- utilities ----------\n\n function randomId(n) {\n const chars = \"abcdefghijklmnopqrstuvwxyz0123456789\";\n let out = \"\";\n const bytes = new Uint8Array(n);\n crypto.getRandomValues(bytes);\n for (const b of bytes) out += chars[b % chars.length];\n return out;\n }\n\n function formatBytes(n) {\n if (!Number.isFinite(n) || n <= 0) return \"0 B\";\n const units = [\"B\", \"KB\", \"MB\", \"GB\"];\n let i = 0;\n let v = n;\n while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; }\n return (v >= 100 || i === 0 ? v.toFixed(0) : v.toFixed(v >= 10 ? 1 : 2)) + \" \" + units[i];\n }\n\n function formatTime(ts) {\n const d = new Date(ts);\n return String(d.getHours()).padStart(2, \"0\") + \":\" +\n String(d.getMinutes()).padStart(2, \"0\") + \":\" +\n String(d.getSeconds()).padStart(2, \"0\");\n }\n\n function formatDuration(ms) {\n if (ms == null || !Number.isFinite(ms)) return \"—\";\n if (ms < 1000) return Math.round(ms) + \" ms\";\n return (ms / 1000).toFixed(ms < 10000 ? 1 : 0) + \" s\";\n }\n\n function bytesToBase64(bytes) {\n let binary = \"\";\n const step = 0x8000;\n for (let i = 0; i < bytes.length; i += step) {\n binary += String.fromCharCode.apply(null, bytes.subarray(i, i + step));\n }\n return btoa(binary);\n }\n\n function base64ToBytes(text) {\n const binary = atob(text);\n const out = new Uint8Array(binary.length);\n for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i);\n return out;\n }\n\n function originOf(url) {\n try { return new URL(url).origin; } catch { return \"\"; }\n }\n\n function safePath(url) {\n try {\n const parsed = new URL(url);\n return parsed.pathname + parsed.search;\n } catch {\n return String(url || \"—\");\n }\n }\n\n function send(message) {\n const ws = state.ws;\n if (!ws || ws.readyState !== WebSocket.OPEN) return false;\n try {\n ws.send(JSON.stringify(message));\n return true;\n } catch (error) {\n state.lastError = String(error && error.message || error);\n return false;\n }\n }\n\n function setPhase(phase) {\n state.phase = phase;\n el.pill.dataset.phase = phase;\n el.connText.textContent = PHASE_TEXT[phase] || phase;\n el.connectBtn.textContent = phase === \"ready\" || phase === \"connecting\" || phase === \"handshake\" ? \"断开\" : \"连接\";\n render();\n }\n\n // ---------- log ----------\n\n function addEntry(request) {\n const entry = {\n id: request.id,\n method: request.method || \"POST\",\n path: safePath(request.url),\n url: request.url,\n started: performance.now(),\n time: Date.now(),\n status: \"pending\",\n httpStatus: null,\n sent: request.bodyBytes || 0,\n received: 0,\n error: \"\",\n done: false,\n row: null,\n cells: {},\n };\n state.entries.set(entry.id, entry);\n state.order.push(entry);\n if (state.order.length > MAX_LOGS) {\n const old = state.order.shift();\n if (old && old.row) old.row.remove();\n if (old) state.entries.delete(old.id);\n }\n const row = document.createElement(\"tr\");\n const cells = {};\n for (const key of [\"time\", \"method\", \"path\", \"status\", \"duration\", \"received\", \"message\"]) {\n const td = document.createElement(\"td\");\n if (key === \"time\") td.className = \"cell-time\";\n if (key === \"path\") { td.className = \"path\"; td.title = entry.url; }\n if (key === \"received\" || key === \"duration\") td.className = \"num\";\n if (key === \"message\") td.className = \"msg\";\n cells[key] = td;\n row.appendChild(td);\n }\n cells.time.textContent = formatTime(entry.time);\n const method = document.createElement(\"span\");\n method.className = \"method\";\n method.textContent = entry.method;\n cells.method.appendChild(method);\n cells.path.textContent = entry.path;\n entry.row = row;\n entry.cells = cells;\n el.logBody.appendChild(row);\n el.logEmpty.classList.add(\"hidden\");\n updateEntry(entry);\n scheduleRender();\n return entry;\n }\n\n function updateEntry(entry) {\n if (!entry || !entry.cells) return;\n const c = entry.cells;\n if (entry.status === \"pending\") {\n c.status.innerHTML = \"\";\n const s = document.createElement(\"span\");\n s.className = \"status pending\";\n s.textContent = \"等待响应\";\n c.status.appendChild(s);\n c.duration.textContent = formatDuration(performance.now() - entry.started);\n c.received.textContent = formatBytes(entry.received);\n c.message.textContent = \"上游请求中\";\n } else if (entry.status === \"aborted\") {\n c.status.innerHTML = \"\";\n const s = document.createElement(\"span\");\n s.className = \"status warn\";\n s.textContent = \"ABORT\";\n c.status.appendChild(s);\n c.duration.textContent = formatDuration(entry.duration);\n c.received.textContent = formatBytes(entry.received);\n c.message.textContent = \"已中止\";\n } else if (entry.status === \"error\") {\n c.status.innerHTML = \"\";\n const s = document.createElement(\"span\");\n s.className = \"status bad\";\n s.textContent = \"ERR\";\n c.status.appendChild(s);\n c.duration.textContent = formatDuration(entry.duration);\n c.received.textContent = formatBytes(entry.received);\n c.message.textContent = entry.error || \"转发失败\";\n c.message.title = entry.error || \"\";\n } else {\n const code = Number(entry.httpStatus) || 0;\n const status = document.createElement(\"span\");\n status.className = \"status \" + (code >= 200 && code < 300 ? \"ok\" : code >= 400 ? \"bad\" : \"warn\");\n status.textContent = code ? String(code) : \"OK\";\n c.status.innerHTML = \"\";\n c.status.appendChild(status);\n c.duration.textContent = formatDuration(entry.duration);\n c.received.textContent = formatBytes(entry.received);\n c.message.textContent = entry.message || (code >= 200 && code < 300 ? \"完成\" : \"上游返回\");\n }\n entry.row.dataset.status = entry.status;\n scheduleRender();\n }\n\n function clearLogs() {\n for (const entry of state.order) if (entry.row) entry.row.remove();\n state.order = [];\n state.entries.clear();\n el.logEmpty.classList.remove(\"hidden\");\n render();\n }\n\n // ---------- rendering ----------\n\n let renderQueued = false;\n function scheduleRender() {\n if (renderQueued) return;\n renderQueued = true;\n requestAnimationFrame(() => { renderQueued = false; render(); });\n }\n\n function render() {\n const connected = state.phase === \"ready\";\n const active = state.active.size;\n\n el.bridgeValue.textContent = connected ? \"已连接\" : PHASE_TEXT[state.phase] || \"未连接\";\n el.bridgeSub.textContent = connected\n ? \"opencode 插件就绪\"\n : (state.phase === \"retrying\" && state.retryAt\n ? Math.max(0, Math.ceil((state.retryAt - Date.now()) / 1000)) + \"s 后重试\"\n : \"等待 opencode 插件\");\n el.bridgeTile.className = \"tile \" + (connected ? \"good\" : state.phase === \"failed\" ? \"bad\" : \"\");\n\n const upstream = state.hello && state.hello.upstream ? state.hello.upstream : \"\";\n el.upstreamValue.textContent = upstream ? upstream.replace(/^https?:\\/\\//, \"\") : \"—\";\n el.upstreamSub.textContent = state.allowedOrigin ? \"仅允许 \" + state.allowedOrigin : \"仅允许插件指定来源\";\n el.upstreamValue.title = upstream;\n\n el.keyValue.textContent = state.hello ? \"透传\" : \"未知\";\n el.keyTile.className = \"tile \" + (state.hello ? \"good\" : \"\");\n el.keySub.textContent = \"使用 opencode /connect 的 Key\";\n\n el.activeValue.textContent = String(active);\n el.activeSub.textContent = \"累计 \" + state.stats.requests + \" 次请求\";\n el.activeTile.className = \"tile\" + (active > 0 ? \" good\" : \"\");\n\n el.statRequests.textContent = String(state.stats.requests);\n el.statOk.textContent = String(state.stats.ok);\n el.statErrors.textContent = String(state.stats.errors);\n el.statSent.textContent = formatBytes(state.stats.sent);\n el.statReceived.textContent = formatBytes(state.stats.received);\n el.statActive.textContent = String(active);\n\n el.metaVersion.textContent = state.hello ? \"v\" + state.hello.version : \"—\";\n el.metaConfig.textContent = state.hello && state.hello.configPath ? state.hello.configPath : \"—\";\n el.metaConfig.title = state.hello && state.hello.configPath ? state.hello.configPath : \"\";\n el.metaOrigin.textContent = state.allowedOrigin || \"—\";\n el.metaListen.textContent = state.hello ? state.hello.listen || \"—\" : \"—\";\n el.footVersion.textContent = state.hello ? \"v\" + state.hello.version : \"v—\";\n\n if (connected && state.lastError) {\n showBanner(state.lastError, \"error\");\n } else if (!connected && state.phase === \"failed\") {\n showBanner(\"无法连接插件。请确认 opencode 已启动,且页面地址中的端口与插件配置一致。\", \"error\");\n } else if (!connected && state.phase === \"retrying\") {\n showBanner(\"连接已断开,正在自动重连。若刚启动 opencode,请稍候。\", \"warn\");\n } else {\n hideBanner();\n }\n\n if (state.phase === \"retrying\" && state.retryAt) {\n const seconds = Math.max(0, Math.ceil((state.retryAt - Date.now()) / 1000));\n el.connText.textContent = seconds + \"s 后重连\";\n }\n }\n\n function showBanner(text, tone) {\n el.banner.classList.remove(\"hidden\");\n el.banner.dataset.tone = tone === \"error\" ? \"error\" : \"warn\";\n el.bannerText.textContent = text;\n }\n\n function hideBanner() {\n el.banner.classList.add(\"hidden\");\n }\n\n // ---------- websocket ----------\n\n function wsTarget() {\n let raw = el.wsUrl.value.trim();\n if (!raw) raw = defaultWsUrl;\n let url;\n try { url = new URL(raw, location.href); } catch { throw new Error(\"WebSocket 地址无效\"); }\n if (url.protocol === \"http:\") url.protocol = \"ws:\";\n if (url.protocol === \"https:\") url.protocol = \"wss:\";\n if (url.protocol !== \"ws:\" && url.protocol !== \"wss:\") throw new Error(\"需要 ws:// 或 wss:// 地址\");\n const token = el.token.value.trim();\n if (token && !url.searchParams.has(\"token\")) url.searchParams.set(\"token\", token);\n return url.toString();\n }\n\n function clearTimers() {\n if (state.retryTimer) { clearTimeout(state.retryTimer); state.retryTimer = 0; }\n if (state.handshakeTimer) { clearTimeout(state.handshakeTimer); state.handshakeTimer = 0; }\n }\n\n function connect() {\n clearTimers();\n state.manual = false;\n state.lastError = \"\";\n state.allowedOrigin = \"\";\n state.hello = null;\n state.retryAt = 0;\n let target;\n try { target = wsTarget(); } catch (error) {\n state.lastError = String(error.message || error);\n setPhase(\"failed\");\n return;\n }\n if (state.ws) {\n try { state.ws.close(1000, \"reconnect\"); } catch {}\n state.ws = null;\n }\n setPhase(state.attempts > 0 ? \"retrying\" : \"connecting\");\n let ws;\n try { ws = new WebSocket(target); } catch (error) {\n state.lastError = String(error.message || error);\n scheduleReconnect();\n return;\n }\n state.ws = ws;\n\n state.handshakeTimer = setTimeout(() => {\n if (state.ws === ws && state.phase === \"handshake\") {\n state.lastError = \"插件握手超时\";\n try { ws.close(4000, \"handshake timeout\"); } catch {}\n }\n }, 8000);\n\n ws.onopen = () => {\n if (state.ws !== ws) return;\n setPhase(\"handshake\");\n render();\n };\n\n ws.onmessage = (event) => {\n if (state.ws !== ws) return;\n if (typeof event.data !== \"string\") {\n state.lastError = \"收到非文本 WebSocket 帧\";\n return;\n }\n let message;\n try { message = JSON.parse(event.data); } catch { return; }\n handleMessage(message);\n };\n\n ws.onerror = () => {\n if (state.ws !== ws) return;\n state.lastError = \"WebSocket 连接出错(插件未启动或端口不对)\";\n render();\n };\n\n ws.onclose = (event) => {\n if (state.ws !== ws) return;\n state.ws = null;\n clearTimers();\n failAllActive(\"插件连接已断开\");\n state.lastClose = { code: event.code, reason: event.reason, at: Date.now() };\n if (state.manual) {\n setPhase(\"stopped\");\n return;\n }\n if (event.code === 4001) {\n state.lastError = \"访问令牌错误(401)\";\n setPhase(\"failed\");\n return;\n }\n if (event.code === 4003) {\n state.lastError = \"插件未就绪\";\n setPhase(\"failed\");\n return;\n }\n if (event.reason && /token|401/i.test(event.reason)) {\n state.lastError = \"访问令牌错误:\" + event.reason;\n setPhase(\"failed\");\n return;\n }\n state.lastError = \"连接已断开\" + (event.code ? \"(\" + event.code + \")\" : \"\");\n scheduleReconnect();\n };\n }\n\n function disconnect() {\n state.manual = true;\n clearTimers();\n state.retryAt = 0;\n if (state.ws) {\n try { state.ws.close(1000, \"manual\"); } catch {}\n state.ws = null;\n }\n state.hello = null;\n state.allowedOrigin = \"\";\n failAllActive(\"手动断开\");\n setPhase(\"stopped\");\n }\n\n function scheduleReconnect() {\n if (state.manual || !state.auto) { setPhase(\"failed\"); return; }\n state.attempts += 1;\n if (state.attempts >= 3 && !state.lastError) {\n state.lastError = \"多次连接失败,请确认 opencode 已启动、端口正确、Token 匹配\";\n }\n const delay = Math.min(8000, 450 * Math.pow(1.7, Math.min(state.attempts, 8)));\n state.retryAt = Date.now() + delay;\n setPhase(\"retrying\");\n state.retryTimer = setTimeout(() => {\n state.retryTimer = 0;\n if (state.manual) return;\n connect();\n }, delay);\n }\n\n function handleMessage(message) {\n if (!message || typeof message.type !== \"string\") return;\n switch (message.type) {\n case \"hello\":\n onHello(message);\n break;\n case \"request\":\n onRequest(message);\n break;\n case \"abort\":\n onAbort(message);\n break;\n case \"ping\":\n send({ type: \"pong\", t: message.t });\n break;\n case \"pong\":\n break;\n default:\n break;\n }\n }\n\n function onHello(hello) {\n if (state.hello) return;\n if (hello.protocol !== 1) {\n state.lastError = \"协议不兼容:插件协议 v\" + hello.protocol + \",页面只支持 v1\";\n setPhase(\"failed\");\n try { state.ws.close(4002, \"protocol mismatch\"); } catch {}\n return;\n }\n clearTimers();\n state.hello = hello;\n state.allowedOrigin = originOf(hello.upstream);\n state.attempts = 0;\n state.lastError = \"\";\n send({\n type: \"ready\",\n protocol: 1,\n client: {\n id: state.pageId,\n name: el.pageName.value.trim() || state.name,\n href: location.href,\n userAgent: navigator.userAgent,\n },\n });\n setPhase(\"ready\");\n render();\n }\n\n async function onRequest(message) {\n const id = message.id;\n if (typeof id !== \"string\" || !id) return;\n if (state.phase !== \"ready\") {\n send({ type: \"response.error\", id, message: \"页面尚未就绪\" });\n return;\n }\n if (typeof message.url !== \"string\" || !/^https?:\\/\\//i.test(message.url)) {\n send({ type: \"response.error\", id, message: \"非法的上游 URL\" });\n return;\n }\n const origin = originOf(message.url);\n if (!state.allowedOrigin || origin !== state.allowedOrigin) {\n send({ type: \"response.error\", id, message: \"拒绝访问未授权来源: \" + origin });\n return;\n }\n if (state.active.has(id)) return;\n\n let bodyBytes = 0;\n let body;\n if (typeof message.body === \"string\" && message.body.length > 0) {\n try {\n body = base64ToBytes(message.body);\n bodyBytes = body.byteLength;\n } catch {\n send({ type: \"response.error\", id, message: \"请求体 base64 无效\" });\n return;\n }\n }\n\n const entry = addEntry({ ...message, bodyBytes });\n const controller = new AbortController();\n state.active.set(id, { controller, entry });\n state.stats.requests += 1;\n state.stats.sent += bodyBytes;\n scheduleRender();\n\n const method = String(message.method || \"POST\").toUpperCase();\n const headers = new Headers();\n for (const [key, value] of Object.entries(message.headers || {})) {\n if (FORBIDDEN_HEADERS.has(key.toLowerCase())) continue;\n if (!value) continue;\n try { headers.set(key, String(value)); } catch {}\n }\n\n const init = {\n method,\n headers,\n signal: controller.signal,\n mode: \"cors\",\n credentials: \"omit\",\n cache: \"no-store\",\n redirect: \"follow\",\n referrerPolicy: \"no-referrer\",\n };\n if (body && method !== \"GET\" && method !== \"HEAD\") init.body = body;\n\n try {\n const response = await fetch(message.url, init);\n if (!state.active.has(id)) return;\n entry.httpStatus = response.status;\n entry.status = \"streaming\";\n entry.message = response.ok ? \"接收中\" : \"上游返回\";\n const responseHeaders = {};\n response.headers.forEach((value, key) => { responseHeaders[key] = value; });\n send({\n type: \"response.start\",\n id,\n status: response.status,\n statusText: response.statusText || \"\",\n headers: responseHeaders,\n });\n updateEntry(entry);\n\n if (response.body) {\n const reader = response.body.getReader();\n for (;;) {\n const { done, value } = await reader.read();\n if (done) break;\n if (!state.active.has(id)) { try { await reader.cancel(); } catch {} return; }\n if (value && value.byteLength) {\n await waitForSocket();\n const ok = send({ type: \"response.chunk\", id, data: bytesToBase64(value) });\n if (!ok) throw new Error(\"WebSocket 已关闭\");\n entry.received += value.byteLength;\n state.stats.received += value.byteLength;\n if (entry.received % (128 * 1024) < 1024) scheduleRender();\n }\n }\n }\n\n entry.status = \"done\";\n entry.done = true;\n entry.duration = performance.now() - entry.started;\n entry.message = response.ok ? \"完成\" : \"上游返回\";\n state.stats.ok += 1;\n send({ type: \"response.end\", id });\n } catch (error) {\n const aborted = error && (error.name === \"AbortError\" || /aborted/i.test(String(error.message)));\n const text = aborted ? \"请求已中止\" : describeFetchError(error);\n entry.duration = performance.now() - entry.started;\n entry.status = aborted ? \"aborted\" : \"error\";\n entry.error = text;\n if (!aborted) state.stats.errors += 1;\n if (state.active.has(id)) send({ type: \"response.error\", id, message: text, name: error && error.name });\n } finally {\n state.active.delete(id);\n if (entry && entry.status === \"pending\") {\n entry.status = \"error\";\n entry.error = entry.error || \"请求未完成\";\n entry.duration = performance.now() - entry.started;\n }\n updateEntry(entry);\n scheduleRender();\n }\n }\n\n function describeFetchError(error) {\n const message = String(error && error.message || error || \"未知错误\");\n if (/failed to fetch|networkerror|load failed/i.test(message)) {\n return \"浏览器 fetch 失败:上游可能不允许跨域(CORS)或网络不可达\";\n }\n return message;\n }\n\n async function waitForSocket() {\n const ws = state.ws;\n if (!ws) return;\n while (ws === state.ws && ws.readyState === WebSocket.OPEN && ws.bufferedAmount > MAX_BUFFERED) {\n await new Promise((resolve) => setTimeout(resolve, 12));\n }\n }\n\n function onAbort(message) {\n const item = state.active.get(message.id);\n if (!item) return;\n item.entry.status = \"aborted\";\n item.entry.error = \"插件已中止\";\n item.entry.duration = performance.now() - item.entry.started;\n try { item.controller.abort(); } catch {}\n updateEntry(item.entry);\n }\n\n function failAllActive(reason) {\n for (const [id, item] of state.active) {\n item.entry.status = \"error\";\n item.entry.error = reason;\n item.entry.duration = performance.now() - item.entry.started;\n try { item.controller.abort(); } catch {}\n updateEntry(item.entry);\n state.active.delete(id);\n }\n scheduleRender();\n }\n\n // ---------- events ----------\n\n el.connectBtn.addEventListener(\"click\", () => {\n if (state.phase === \"ready\" || state.phase === \"connecting\" || state.phase === \"handshake\") disconnect();\n else connect();\n });\n\n el.clearBtn.addEventListener(\"click\", clearLogs);\n\n el.wsUrl.addEventListener(\"keydown\", (event) => {\n if (event.key === \"Enter\" && state.phase !== \"ready\") connect();\n });\n\n el.pageName.addEventListener(\"change\", () => {\n state.name = el.pageName.value.trim() || state.name;\n });\n\n setInterval(() => {\n if (state.phase === \"retrying\") render();\n for (const entry of state.order) {\n if (entry.status === \"pending\") updateEntry(entry);\n }\n }, 500);\n\n window.addEventListener(\"online\", () => { if (state.phase !== \"ready\" && !state.manual) connect(); });\n\n setPhase(\"idle\");\n if (boot.autoConnect !== false) connect();\n\n // A tiny read-only hook for automated browser tests.\n window.__gatewayTest = {\n state,\n stats: () => ({ ...state.stats, active: state.active.size }),\n ready: () => state.phase === \"ready\",\n };\n})();\n</script>\n</body>\n</html>\n"
|
|
24
|
+
const REGISTRY = Symbol.for("opencode.web-gateway.registry.v1")
|
|
25
|
+
|
|
26
|
+
const MAX_WS_MESSAGE = 128 * 1024 * 1024
|
|
27
|
+
const WS_MAGIC = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
|
|
28
|
+
|
|
29
|
+
const HOP_BY_HOP = new Set([
|
|
30
|
+
"connection",
|
|
31
|
+
"keep-alive",
|
|
32
|
+
"proxy-authenticate",
|
|
33
|
+
"proxy-authorization",
|
|
34
|
+
"te",
|
|
35
|
+
"trailer",
|
|
36
|
+
"transfer-encoding",
|
|
37
|
+
"upgrade",
|
|
38
|
+
"content-length",
|
|
39
|
+
"content-encoding",
|
|
40
|
+
"set-cookie",
|
|
41
|
+
"access-control-allow-origin",
|
|
42
|
+
"access-control-allow-credentials",
|
|
43
|
+
"access-control-allow-headers",
|
|
44
|
+
"access-control-allow-methods",
|
|
45
|
+
"access-control-expose-headers",
|
|
46
|
+
])
|
|
47
|
+
|
|
48
|
+
const DEFAULT_CONFIG = {
|
|
49
|
+
upstream: "https://api.deepseek.com",
|
|
50
|
+
host: "127.0.0.1",
|
|
51
|
+
port: 8787,
|
|
52
|
+
token: "",
|
|
53
|
+
allowedOrigins: [],
|
|
54
|
+
waitForBridgeMs: 15_000,
|
|
55
|
+
requestTimeoutMs: 60 * 60 * 1000,
|
|
56
|
+
maxBodyBytes: 64 * 1024 * 1024,
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function log(...args) {
|
|
60
|
+
console.log("[web-gateway]", ...args)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function warn(...args) {
|
|
64
|
+
console.error("[web-gateway]", ...args)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function readJson(file) {
|
|
68
|
+
try {
|
|
69
|
+
const text = readFileSync(file, "utf8").replace(/^\uFEFF/, "")
|
|
70
|
+
return JSON.parse(text)
|
|
71
|
+
} catch (error) {
|
|
72
|
+
warn("cannot read config", file, "-", error.message)
|
|
73
|
+
return null
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function isRecord(value) {
|
|
78
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function asString(value) {
|
|
82
|
+
return typeof value === "string" ? value.trim() : ""
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function asInt(value, fallback, min, max) {
|
|
86
|
+
const n = typeof value === "number" ? value : Number.parseInt(String(value ?? ""), 10)
|
|
87
|
+
if (!Number.isFinite(n)) return fallback
|
|
88
|
+
const int = Math.trunc(n)
|
|
89
|
+
if (int < min || int > max) return fallback
|
|
90
|
+
return int
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function truthy(value) {
|
|
94
|
+
if (typeof value === "boolean") return value
|
|
95
|
+
return /^(1|true|yes|on)$/i.test(String(value ?? ""))
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function normalizeUpstream(value) {
|
|
99
|
+
const raw = asString(value) || DEFAULT_CONFIG.upstream
|
|
100
|
+
let url
|
|
101
|
+
try {
|
|
102
|
+
url = new URL(raw)
|
|
103
|
+
} catch {
|
|
104
|
+
warn("invalid upstream URL", JSON.stringify(raw), "- using", DEFAULT_CONFIG.upstream)
|
|
105
|
+
return DEFAULT_CONFIG.upstream
|
|
106
|
+
}
|
|
107
|
+
if (url.protocol !== "https:" && url.protocol !== "http:") {
|
|
108
|
+
warn("upstream must be http(s), got", url.protocol, "- using", DEFAULT_CONFIG.upstream)
|
|
109
|
+
return DEFAULT_CONFIG.upstream
|
|
110
|
+
}
|
|
111
|
+
url.hash = ""
|
|
112
|
+
url.search = ""
|
|
113
|
+
return url.toString().replace(/\/+$/, "")
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function normalizeOrigins(value) {
|
|
117
|
+
if (!Array.isArray(value)) return []
|
|
118
|
+
const out = []
|
|
119
|
+
for (const item of value) {
|
|
120
|
+
if (typeof item !== "string") continue
|
|
121
|
+
const text = item.trim()
|
|
122
|
+
if (!text) continue
|
|
123
|
+
if (text === "null") {
|
|
124
|
+
out.push("null")
|
|
125
|
+
continue
|
|
126
|
+
}
|
|
127
|
+
try {
|
|
128
|
+
const url = new URL(text)
|
|
129
|
+
if (url.protocol === "http:" || url.protocol === "https:") out.push(url.origin)
|
|
130
|
+
} catch {
|
|
131
|
+
warn("ignoring invalid allowedOrigins entry", JSON.stringify(text))
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return [...new Set(out)]
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function loadConfig(directory, options) {
|
|
138
|
+
const dir = directory || process.cwd()
|
|
139
|
+
const opts = isRecord(options) ? options : {}
|
|
140
|
+
const env = process.env
|
|
141
|
+
const candidates = [
|
|
142
|
+
asString(opts.configFile),
|
|
143
|
+
asString(env.OPENCODE_WEB_GATEWAY_CONFIG),
|
|
144
|
+
path.join(dir, "web-gateway.config.json"),
|
|
145
|
+
path.join(dir, ".opencode", "web-gateway.config.json"),
|
|
146
|
+
path.join(env.XDG_CONFIG_HOME || path.join(homedir(), ".config"), "opencode", "web-gateway.json"),
|
|
147
|
+
].filter(Boolean)
|
|
148
|
+
|
|
149
|
+
let fileConfig = null
|
|
150
|
+
let configPath = ""
|
|
151
|
+
for (const candidate of candidates) {
|
|
152
|
+
if (!existsSync(candidate)) continue
|
|
153
|
+
const parsed = readJson(candidate)
|
|
154
|
+
if (parsed === null) continue
|
|
155
|
+
if (!isRecord(parsed)) {
|
|
156
|
+
warn("config is not a JSON object", candidate)
|
|
157
|
+
continue
|
|
158
|
+
}
|
|
159
|
+
fileConfig = parsed
|
|
160
|
+
configPath = path.resolve(candidate)
|
|
161
|
+
break
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const merged = {
|
|
165
|
+
...DEFAULT_CONFIG,
|
|
166
|
+
...(fileConfig || {}),
|
|
167
|
+
...(isRecord(opts.config) ? opts.config : {}),
|
|
168
|
+
}
|
|
169
|
+
// Explicit plugin options win over the config file, but not over env vars.
|
|
170
|
+
for (const [key, value] of Object.entries(opts)) {
|
|
171
|
+
if (key === "config" || key === "configFile" || key === "onReady" || typeof value === "function") continue
|
|
172
|
+
if (value !== undefined) merged[key] = value
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const envUpstream = asString(env.OPENCODE_WEB_GATEWAY_UPSTREAM)
|
|
176
|
+
if (envUpstream) merged.upstream = envUpstream
|
|
177
|
+
const envHost = asString(env.OPENCODE_WEB_GATEWAY_HOST)
|
|
178
|
+
if (envHost) merged.host = envHost
|
|
179
|
+
if (env.OPENCODE_WEB_GATEWAY_PORT) merged.port = env.OPENCODE_WEB_GATEWAY_PORT
|
|
180
|
+
if (env.OPENCODE_WEB_GATEWAY_TOKEN !== undefined) merged.token = env.OPENCODE_WEB_GATEWAY_TOKEN
|
|
181
|
+
if (env.OPENCODE_WEB_GATEWAY_WAIT_MS) merged.waitForBridgeMs = env.OPENCODE_WEB_GATEWAY_WAIT_MS
|
|
182
|
+
|
|
183
|
+
const host = asString(merged.host) || DEFAULT_CONFIG.host
|
|
184
|
+
let token = asString(merged.token)
|
|
185
|
+
const loopback = host === "127.0.0.1" || host === "localhost" || host === "::1"
|
|
186
|
+
if (!token && !loopback) {
|
|
187
|
+
token = randomBytes(18).toString("hex")
|
|
188
|
+
log("non-loopback host without token; generated a session token")
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return {
|
|
192
|
+
upstream: normalizeUpstream(merged.upstream),
|
|
193
|
+
host,
|
|
194
|
+
port: asInt(merged.port, DEFAULT_CONFIG.port, 0, 65535),
|
|
195
|
+
token,
|
|
196
|
+
allowedOrigins: normalizeOrigins(merged.allowedOrigins),
|
|
197
|
+
waitForBridgeMs: asInt(merged.waitForBridgeMs, DEFAULT_CONFIG.waitForBridgeMs, 0, 10 * 60 * 1000),
|
|
198
|
+
requestTimeoutMs: asInt(merged.requestTimeoutMs, DEFAULT_CONFIG.requestTimeoutMs, 0, 24 * 60 * 60 * 1000),
|
|
199
|
+
maxBodyBytes: asInt(merged.maxBodyBytes, DEFAULT_CONFIG.maxBodyBytes, 1024, MAX_WS_MESSAGE),
|
|
200
|
+
configPath,
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function registry() {
|
|
205
|
+
if (!globalThis[REGISTRY]) globalThis[REGISTRY] = new Map()
|
|
206
|
+
return globalThis[REGISTRY]
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function displayHost(host) {
|
|
210
|
+
if (host === "0.0.0.0") return "127.0.0.1"
|
|
211
|
+
if (host === "::" || host === "0:0:0:0:0:0:0:0") return "[::1]"
|
|
212
|
+
return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function pageUrl(config, port) {
|
|
216
|
+
return `http://${displayHost(config.host)}:${port}/`
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function upstreamUrl(config, pathname, search) {
|
|
220
|
+
const base = new URL(config.upstream)
|
|
221
|
+
const basePath = base.pathname.replace(/\/+$/, "")
|
|
222
|
+
let requestPath = pathname.startsWith("/") ? pathname : `/${pathname}`
|
|
223
|
+
if (basePath && (requestPath === basePath || requestPath.startsWith(`${basePath}/`))) {
|
|
224
|
+
requestPath = requestPath.slice(basePath.length) || "/"
|
|
225
|
+
}
|
|
226
|
+
base.pathname = `${basePath}${requestPath}`.replace(/\/{2,}/g, "/")
|
|
227
|
+
base.search = search || ""
|
|
228
|
+
return base.toString()
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function json(res, status, value, extraHeaders) {
|
|
232
|
+
const body = Buffer.from(JSON.stringify(value))
|
|
233
|
+
res.writeHead(status, {
|
|
234
|
+
"content-type": "application/json; charset=utf-8",
|
|
235
|
+
"cache-control": "no-store",
|
|
236
|
+
"content-length": String(body.length),
|
|
237
|
+
...(extraHeaders || {}),
|
|
238
|
+
})
|
|
239
|
+
res.end(body)
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function gatewayError(res, status, message, code, hint) {
|
|
243
|
+
json(res, status, {
|
|
244
|
+
error: {
|
|
245
|
+
message,
|
|
246
|
+
type: "gateway_error",
|
|
247
|
+
code,
|
|
248
|
+
...(hint ? { hint } : {}),
|
|
249
|
+
},
|
|
250
|
+
})
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function escapeJsonForHtml(value) {
|
|
254
|
+
return JSON.stringify(value).replace(/</g, "\\u003c").replace(/>/g, "\\u003e").replace(/&/g, "\\u0026")
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function servePage(runtime, res) {
|
|
258
|
+
const bootstrap = {
|
|
259
|
+
service: SERVICE,
|
|
260
|
+
version: VERSION,
|
|
261
|
+
protocol: PROTOCOL,
|
|
262
|
+
token: runtime.config.token,
|
|
263
|
+
upstream: runtime.config.upstream,
|
|
264
|
+
configPath: runtime.config.configPath || null,
|
|
265
|
+
host: runtime.config.host,
|
|
266
|
+
port: runtime.port,
|
|
267
|
+
autoConnect: true,
|
|
268
|
+
}
|
|
269
|
+
const html = PAGE_HTML.split("__GATEWAY_BOOTSTRAP__").join(escapeJsonForHtml(bootstrap))
|
|
270
|
+
const body = Buffer.from(html, "utf8")
|
|
271
|
+
res.writeHead(200, {
|
|
272
|
+
"content-type": "text/html; charset=utf-8",
|
|
273
|
+
"cache-control": "no-store",
|
|
274
|
+
"content-length": String(body.length),
|
|
275
|
+
"x-content-type-options": "nosniff",
|
|
276
|
+
"referrer-policy": "no-referrer",
|
|
277
|
+
"content-security-policy":
|
|
278
|
+
"default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src ws: wss: http: https:; img-src data:; font-src data:; base-uri 'none'; form-action 'none'; frame-ancestors 'none'",
|
|
279
|
+
})
|
|
280
|
+
res.end(body)
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function health(runtime) {
|
|
284
|
+
const peers = [...runtime.peers]
|
|
285
|
+
const ready = peers.filter((peer) => peer.ready && !peer.closed).length
|
|
286
|
+
return {
|
|
287
|
+
ok: true,
|
|
288
|
+
service: SERVICE,
|
|
289
|
+
version: VERSION,
|
|
290
|
+
protocol: PROTOCOL,
|
|
291
|
+
upstream: runtime.config.upstream,
|
|
292
|
+
host: runtime.config.host,
|
|
293
|
+
port: runtime.port,
|
|
294
|
+
allowedOrigins: runtime.config.allowedOrigins,
|
|
295
|
+
configPath: runtime.config.configPath || null,
|
|
296
|
+
bridges: peers.length,
|
|
297
|
+
readyBridges: ready,
|
|
298
|
+
pending: runtime.pending.size,
|
|
299
|
+
stats: { ...runtime.stats },
|
|
300
|
+
startedAt: new Date(runtime.startedAt).toISOString(),
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function readBody(req, limit) {
|
|
305
|
+
return new Promise((resolve, reject) => {
|
|
306
|
+
const chunks = []
|
|
307
|
+
let size = 0
|
|
308
|
+
let settled = false
|
|
309
|
+
req.on("data", (chunk) => {
|
|
310
|
+
if (settled) return
|
|
311
|
+
size += chunk.length
|
|
312
|
+
if (size > limit) {
|
|
313
|
+
settled = true
|
|
314
|
+
const error = new Error("request body too large")
|
|
315
|
+
error.statusCode = 413
|
|
316
|
+
reject(error)
|
|
317
|
+
req.destroy()
|
|
318
|
+
return
|
|
319
|
+
}
|
|
320
|
+
chunks.push(chunk)
|
|
321
|
+
})
|
|
322
|
+
req.on("end", () => {
|
|
323
|
+
if (settled) return
|
|
324
|
+
settled = true
|
|
325
|
+
resolve(Buffer.concat(chunks))
|
|
326
|
+
})
|
|
327
|
+
req.on("error", (error) => {
|
|
328
|
+
if (settled) return
|
|
329
|
+
settled = true
|
|
330
|
+
reject(error)
|
|
331
|
+
})
|
|
332
|
+
})
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function pickBridge(runtime) {
|
|
336
|
+
let best = null
|
|
337
|
+
for (const peer of runtime.peers) {
|
|
338
|
+
if (peer.closed || !peer.ready) continue
|
|
339
|
+
if (!best || peer.active < best.active || (peer.active === best.active && peer.lastUsed < best.lastUsed)) best = peer
|
|
340
|
+
}
|
|
341
|
+
return best
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function acquireBridge(runtime, waitMs) {
|
|
345
|
+
const immediate = pickBridge(runtime)
|
|
346
|
+
if (immediate) return Promise.resolve(immediate)
|
|
347
|
+
if (!waitMs) return Promise.resolve(null)
|
|
348
|
+
return new Promise((resolve) => {
|
|
349
|
+
const waiter = {
|
|
350
|
+
resolve,
|
|
351
|
+
timer: setTimeout(() => {
|
|
352
|
+
runtime.waiters.delete(waiter)
|
|
353
|
+
resolve(null)
|
|
354
|
+
}, waitMs),
|
|
355
|
+
}
|
|
356
|
+
waiter.timer.unref?.()
|
|
357
|
+
runtime.waiters.add(waiter)
|
|
358
|
+
})
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function notifyWaiters(runtime) {
|
|
362
|
+
if (!runtime.waiters.size) return
|
|
363
|
+
const peer = pickBridge(runtime)
|
|
364
|
+
if (!peer) return
|
|
365
|
+
for (const waiter of [...runtime.waiters]) {
|
|
366
|
+
runtime.waiters.delete(waiter)
|
|
367
|
+
clearTimeout(waiter.timer)
|
|
368
|
+
waiter.resolve(peer)
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function removePending(runtime, id, outcome) {
|
|
373
|
+
const pending = runtime.pending.get(id)
|
|
374
|
+
if (!pending) return null
|
|
375
|
+
runtime.pending.delete(id)
|
|
376
|
+
if (pending.timer) clearTimeout(pending.timer)
|
|
377
|
+
if (pending.bridge && pending.bridge.pending) {
|
|
378
|
+
pending.bridge.pending.delete(id)
|
|
379
|
+
pending.bridge.active = Math.max(0, pending.bridge.active - 1)
|
|
380
|
+
pending.bridge.lastUsed = Date.now()
|
|
381
|
+
}
|
|
382
|
+
return pending
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function finishPending(runtime, id, error) {
|
|
386
|
+
const pending = removePending(runtime, id)
|
|
387
|
+
if (!pending) return
|
|
388
|
+
if (pending.finished) return
|
|
389
|
+
pending.finished = true
|
|
390
|
+
if (error) {
|
|
391
|
+
runtime.stats.errors += 1
|
|
392
|
+
if (!pending.headersSent) {
|
|
393
|
+
gatewayError(pending.res, error.status || 502, error.message || "upstream request failed", error.code || "upstream_error", error.hint)
|
|
394
|
+
} else {
|
|
395
|
+
pending.res.destroy(error)
|
|
396
|
+
}
|
|
397
|
+
return
|
|
398
|
+
}
|
|
399
|
+
runtime.stats.responses += 1
|
|
400
|
+
pending.res.end()
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function onBridgeStart(runtime, peer, message) {
|
|
404
|
+
const id = typeof message.id === "string" ? message.id : ""
|
|
405
|
+
const pending = runtime.pending.get(id)
|
|
406
|
+
if (!pending || pending.bridge !== peer || pending.headersSent) return
|
|
407
|
+
const status = asInt(message.status, 502, 100, 599)
|
|
408
|
+
pending.res.statusCode = status
|
|
409
|
+
if (typeof message.statusText === "string" && message.statusText && status !== 204 && status !== 304) {
|
|
410
|
+
try {
|
|
411
|
+
pending.res.statusMessage = message.statusText.slice(0, 120)
|
|
412
|
+
} catch {
|
|
413
|
+
// Some status codes have fixed reason phrases; ignore invalid custom values.
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
if (isRecord(message.headers)) {
|
|
417
|
+
for (const [key, value] of Object.entries(message.headers)) {
|
|
418
|
+
const name = String(key).toLowerCase()
|
|
419
|
+
if (!name || HOP_BY_HOP.has(name)) continue
|
|
420
|
+
if (typeof value !== "string" && typeof value !== "number") continue
|
|
421
|
+
try {
|
|
422
|
+
pending.res.setHeader(name, String(value))
|
|
423
|
+
} catch {
|
|
424
|
+
// Ignore headers Node refuses; the body still passes through unchanged.
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
try {
|
|
429
|
+
pending.res.flushHeaders()
|
|
430
|
+
} catch {
|
|
431
|
+
// Response may already be closed.
|
|
432
|
+
}
|
|
433
|
+
pending.headersSent = true
|
|
434
|
+
pending.status = status
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function onBridgeChunk(runtime, peer, message) {
|
|
438
|
+
const id = typeof message.id === "string" ? message.id : ""
|
|
439
|
+
const pending = runtime.pending.get(id)
|
|
440
|
+
if (!pending || pending.bridge !== peer || pending.finished) return
|
|
441
|
+
if (typeof message.data !== "string" || !message.data) return
|
|
442
|
+
let chunk
|
|
443
|
+
try {
|
|
444
|
+
chunk = Buffer.from(message.data, "base64")
|
|
445
|
+
} catch {
|
|
446
|
+
return
|
|
447
|
+
}
|
|
448
|
+
if (!chunk.length) return
|
|
449
|
+
if (!pending.headersSent) {
|
|
450
|
+
// Some upstreams stream without an explicit start message; keep the wire honest.
|
|
451
|
+
pending.res.statusCode = 200
|
|
452
|
+
pending.res.flushHeaders()
|
|
453
|
+
pending.headersSent = true
|
|
454
|
+
}
|
|
455
|
+
pending.res.write(chunk)
|
|
456
|
+
pending.bytes += chunk.length
|
|
457
|
+
runtime.stats.bytesIn += chunk.length
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function onBridgeEnd(runtime, peer, message) {
|
|
461
|
+
const id = typeof message.id === "string" ? message.id : ""
|
|
462
|
+
const pending = runtime.pending.get(id)
|
|
463
|
+
if (!pending || pending.bridge !== peer) return
|
|
464
|
+
finishPending(runtime, id)
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function onBridgeError(runtime, peer, message) {
|
|
468
|
+
const id = typeof message.id === "string" ? message.id : ""
|
|
469
|
+
const pending = runtime.pending.get(id)
|
|
470
|
+
if (!pending || pending.bridge !== peer) return
|
|
471
|
+
const text = typeof message.message === "string" && message.message ? message.message : "browser fetch failed"
|
|
472
|
+
finishPending(runtime, id, {
|
|
473
|
+
status: 502,
|
|
474
|
+
code: "browser_fetch_failed",
|
|
475
|
+
message: text,
|
|
476
|
+
hint: "The browser bridge could not reach the upstream. Check CORS, network access, and the upstream URL.",
|
|
477
|
+
})
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function failPeerPending(runtime, peer, reason) {
|
|
481
|
+
for (const [id, pending] of [...runtime.pending]) {
|
|
482
|
+
if (pending.bridge !== peer) continue
|
|
483
|
+
finishPending(runtime, id, {
|
|
484
|
+
status: 502,
|
|
485
|
+
code: "bridge_disconnected",
|
|
486
|
+
message: reason,
|
|
487
|
+
hint: "Reload the bridge page; opencode requests are forwarded only while it is connected.",
|
|
488
|
+
})
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
class WebSocketPeer {
|
|
493
|
+
constructor(runtime, socket, info, silent) {
|
|
494
|
+
this.runtime = runtime
|
|
495
|
+
this.socket = socket
|
|
496
|
+
this.info = info || {}
|
|
497
|
+
this.id = randomUUID()
|
|
498
|
+
this.ready = false
|
|
499
|
+
this.closed = false
|
|
500
|
+
this.buffer = Buffer.alloc(0)
|
|
501
|
+
this.fragment = null
|
|
502
|
+
this.pending = new Set()
|
|
503
|
+
this.active = 0
|
|
504
|
+
this.lastSeen = Date.now()
|
|
505
|
+
this.lastUsed = 0
|
|
506
|
+
this.name = this.info.name || "browser"
|
|
507
|
+
this.closeSent = false
|
|
508
|
+
socket.setNoDelay(true)
|
|
509
|
+
socket.setTimeout(0)
|
|
510
|
+
socket.on("data", (chunk) => this.onData(chunk))
|
|
511
|
+
socket.on("error", () => this.terminate())
|
|
512
|
+
socket.on("end", () => this.terminate())
|
|
513
|
+
socket.on("close", () => this.terminate())
|
|
514
|
+
if (silent) return
|
|
515
|
+
this.send({
|
|
516
|
+
type: "hello",
|
|
517
|
+
protocol: PROTOCOL,
|
|
518
|
+
service: SERVICE,
|
|
519
|
+
version: VERSION,
|
|
520
|
+
upstream: runtime.config.upstream,
|
|
521
|
+
configPath: runtime.config.configPath || null,
|
|
522
|
+
listen: `${runtime.config.host}:${runtime.port}`,
|
|
523
|
+
waitForBridgeMs: runtime.config.waitForBridgeMs,
|
|
524
|
+
startedAt: runtime.startedAt,
|
|
525
|
+
})
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
send(message) {
|
|
529
|
+
if (this.closed) return false
|
|
530
|
+
const payload = Buffer.from(JSON.stringify(message), "utf8")
|
|
531
|
+
return this.writeFrame(0x1, payload)
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
writeFrame(opcode, payload) {
|
|
535
|
+
if (this.closed || this.socket.destroyed || this.socket.writableEnded) return false
|
|
536
|
+
const length = payload.length
|
|
537
|
+
let header
|
|
538
|
+
if (length < 126) {
|
|
539
|
+
header = Buffer.allocUnsafe(2)
|
|
540
|
+
header[1] = length
|
|
541
|
+
} else if (length < 65536) {
|
|
542
|
+
header = Buffer.allocUnsafe(4)
|
|
543
|
+
header[1] = 126
|
|
544
|
+
header.writeUInt16BE(length, 2)
|
|
545
|
+
} else {
|
|
546
|
+
header = Buffer.allocUnsafe(10)
|
|
547
|
+
header[1] = 127
|
|
548
|
+
header.writeBigUInt64BE(BigInt(length), 2)
|
|
549
|
+
}
|
|
550
|
+
header[0] = 0x80 | opcode
|
|
551
|
+
try {
|
|
552
|
+
this.socket.write(Buffer.concat([header, payload]))
|
|
553
|
+
this.socket.uncork?.()
|
|
554
|
+
return true
|
|
555
|
+
} catch {
|
|
556
|
+
this.terminate()
|
|
557
|
+
return false
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
ping() {
|
|
562
|
+
this.writeFrame(0x9, Buffer.from(String(Date.now())))
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
close(code, reason) {
|
|
566
|
+
if (this.closed) return
|
|
567
|
+
const text = Buffer.from(String(reason || "").slice(0, 120), "utf8")
|
|
568
|
+
const payload = Buffer.allocUnsafe(2 + text.length)
|
|
569
|
+
payload.writeUInt16BE(code || 1000, 0)
|
|
570
|
+
text.copy(payload, 2)
|
|
571
|
+
this.writeFrame(0x8, payload)
|
|
572
|
+
this.closeSent = true
|
|
573
|
+
this.closed = true
|
|
574
|
+
try {
|
|
575
|
+
this.socket.end()
|
|
576
|
+
} catch {
|
|
577
|
+
// already closed
|
|
578
|
+
}
|
|
579
|
+
setTimeout(() => this.terminate(), 30).unref?.()
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
terminate() {
|
|
583
|
+
if (this.closed && !this.runtime.peers.has(this)) return
|
|
584
|
+
this.closed = true
|
|
585
|
+
if (this.runtime.peers.delete(this)) {
|
|
586
|
+
failPeerPending(this.runtime, this, "browser bridge disconnected")
|
|
587
|
+
notifyWaiters(this.runtime)
|
|
588
|
+
this.runtime.stats.disconnects += 1
|
|
589
|
+
}
|
|
590
|
+
try {
|
|
591
|
+
this.socket.destroy()
|
|
592
|
+
} catch {
|
|
593
|
+
// already closed
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
onData(chunk) {
|
|
598
|
+
if (this.closed) return
|
|
599
|
+
this.lastSeen = Date.now()
|
|
600
|
+
this.buffer = this.buffer.length ? Buffer.concat([this.buffer, chunk]) : chunk
|
|
601
|
+
if (this.buffer.length > MAX_WS_MESSAGE) {
|
|
602
|
+
this.close(1009, "message too big")
|
|
603
|
+
return
|
|
604
|
+
}
|
|
605
|
+
for (;;) {
|
|
606
|
+
const buf = this.buffer
|
|
607
|
+
if (buf.length < 2) return
|
|
608
|
+
const b0 = buf[0]
|
|
609
|
+
const b1 = buf[1]
|
|
610
|
+
const fin = (b0 & 0x80) !== 0
|
|
611
|
+
const rsv = b0 & 0x70
|
|
612
|
+
const opcode = b0 & 0x0f
|
|
613
|
+
const masked = (b1 & 0x80) !== 0
|
|
614
|
+
let length = b1 & 0x7f
|
|
615
|
+
let offset = 2
|
|
616
|
+
if (rsv) {
|
|
617
|
+
this.close(1002, "reserved bits set")
|
|
618
|
+
return
|
|
619
|
+
}
|
|
620
|
+
if (length === 126) {
|
|
621
|
+
if (buf.length < 4) return
|
|
622
|
+
length = buf.readUInt16BE(2)
|
|
623
|
+
offset = 4
|
|
624
|
+
} else if (length === 127) {
|
|
625
|
+
if (buf.length < 10) return
|
|
626
|
+
const big = buf.readBigUInt64BE(2)
|
|
627
|
+
if (big > BigInt(MAX_WS_MESSAGE)) {
|
|
628
|
+
this.close(1009, "message too big")
|
|
629
|
+
return
|
|
630
|
+
}
|
|
631
|
+
length = Number(big)
|
|
632
|
+
offset = 10
|
|
633
|
+
}
|
|
634
|
+
if (!masked) {
|
|
635
|
+
this.close(1002, "client frames must be masked")
|
|
636
|
+
return
|
|
637
|
+
}
|
|
638
|
+
if (buf.length < offset + 4 + length) return
|
|
639
|
+
const mask = buf.subarray(offset, offset + 4)
|
|
640
|
+
offset += 4
|
|
641
|
+
const payload = Buffer.from(buf.subarray(offset, offset + length))
|
|
642
|
+
for (let i = 0; i < payload.length; i++) payload[i] ^= mask[i & 3]
|
|
643
|
+
this.buffer = buf.subarray(offset + length)
|
|
644
|
+
if (!this.handleFrame(fin, opcode, payload)) return
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
handleFrame(fin, opcode, payload) {
|
|
649
|
+
if (opcode >= 0x8) {
|
|
650
|
+
if (!fin || payload.length > 125) {
|
|
651
|
+
this.close(1002, "invalid control frame")
|
|
652
|
+
return false
|
|
653
|
+
}
|
|
654
|
+
if (opcode === 0x8) {
|
|
655
|
+
this.close(1000, "")
|
|
656
|
+
return false
|
|
657
|
+
}
|
|
658
|
+
if (opcode === 0x9) {
|
|
659
|
+
this.writeFrame(0xa, payload)
|
|
660
|
+
return true
|
|
661
|
+
}
|
|
662
|
+
if (opcode === 0xa) {
|
|
663
|
+
this.lastSeen = Date.now()
|
|
664
|
+
return true
|
|
665
|
+
}
|
|
666
|
+
this.close(1002, "unknown control frame")
|
|
667
|
+
return false
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
if (opcode === 0x0) {
|
|
671
|
+
if (!this.fragment) {
|
|
672
|
+
this.close(1002, "unexpected continuation")
|
|
673
|
+
return false
|
|
674
|
+
}
|
|
675
|
+
this.fragment.chunks.push(payload)
|
|
676
|
+
this.fragment.size += payload.length
|
|
677
|
+
if (this.fragment.size > MAX_WS_MESSAGE) {
|
|
678
|
+
this.close(1009, "message too big")
|
|
679
|
+
return false
|
|
680
|
+
}
|
|
681
|
+
if (!fin) return true
|
|
682
|
+
const complete = Buffer.concat(this.fragment.chunks)
|
|
683
|
+
const type = this.fragment.opcode
|
|
684
|
+
this.fragment = null
|
|
685
|
+
this.dispatch(type, complete)
|
|
686
|
+
return true
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
if (opcode !== 0x1 && opcode !== 0x2) {
|
|
690
|
+
this.close(1003, "unsupported frame")
|
|
691
|
+
return false
|
|
692
|
+
}
|
|
693
|
+
if (this.fragment) {
|
|
694
|
+
this.close(1002, "new message before continuation finished")
|
|
695
|
+
return false
|
|
696
|
+
}
|
|
697
|
+
if (!fin) {
|
|
698
|
+
this.fragment = { opcode, chunks: [payload], size: payload.length }
|
|
699
|
+
return true
|
|
700
|
+
}
|
|
701
|
+
this.dispatch(opcode, payload)
|
|
702
|
+
return true
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
dispatch(opcode, payload) {
|
|
706
|
+
if (opcode !== 0x1) {
|
|
707
|
+
this.close(1003, "binary frames are not supported")
|
|
708
|
+
return
|
|
709
|
+
}
|
|
710
|
+
let message
|
|
711
|
+
try {
|
|
712
|
+
message = JSON.parse(payload.toString("utf8"))
|
|
713
|
+
} catch {
|
|
714
|
+
return
|
|
715
|
+
}
|
|
716
|
+
if (!isRecord(message) || typeof message.type !== "string") return
|
|
717
|
+
const runtime = this.runtime
|
|
718
|
+
switch (message.type) {
|
|
719
|
+
case "ready":
|
|
720
|
+
this.ready = true
|
|
721
|
+
this.name = isRecord(message.client) && asString(message.client.name) ? asString(message.client.name) : this.name
|
|
722
|
+
runtime.stats.connects += 1
|
|
723
|
+
log("browser bridge ready", this.name)
|
|
724
|
+
notifyWaiters(runtime)
|
|
725
|
+
break
|
|
726
|
+
case "response.start":
|
|
727
|
+
onBridgeStart(runtime, this, message)
|
|
728
|
+
break
|
|
729
|
+
case "response.chunk":
|
|
730
|
+
onBridgeChunk(runtime, this, message)
|
|
731
|
+
break
|
|
732
|
+
case "response.end":
|
|
733
|
+
onBridgeEnd(runtime, this, message)
|
|
734
|
+
break
|
|
735
|
+
case "response.error":
|
|
736
|
+
onBridgeError(runtime, this, message)
|
|
737
|
+
break
|
|
738
|
+
case "pong":
|
|
739
|
+
break
|
|
740
|
+
default:
|
|
741
|
+
break
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
function onHttp(runtime, req, res) {
|
|
747
|
+
const parsed = new URL(req.url || "/", "http://gateway.local")
|
|
748
|
+
if (parsed.pathname === "/" || parsed.pathname === "/index.html") {
|
|
749
|
+
servePage(runtime, res)
|
|
750
|
+
return
|
|
751
|
+
}
|
|
752
|
+
if (parsed.pathname === "/healthz" || parsed.pathname === "/__gateway/status") {
|
|
753
|
+
json(res, 200, health(runtime))
|
|
754
|
+
return
|
|
755
|
+
}
|
|
756
|
+
if (parsed.pathname === "/favicon.ico") {
|
|
757
|
+
res.writeHead(204, { "cache-control": "public, max-age=86400" })
|
|
758
|
+
res.end()
|
|
759
|
+
return
|
|
760
|
+
}
|
|
761
|
+
if (parsed.pathname === "/bridge") {
|
|
762
|
+
gatewayError(res, 426, "This endpoint expects a WebSocket upgrade", "upgrade_required")
|
|
763
|
+
return
|
|
764
|
+
}
|
|
765
|
+
tunnel(runtime, req, res, parsed).catch((error) => {
|
|
766
|
+
warn("request failed", error && error.stack ? error.stack : error)
|
|
767
|
+
if (!res.headersSent) {
|
|
768
|
+
gatewayError(res, 500, error && error.message ? error.message : "internal gateway error", "internal_error")
|
|
769
|
+
} else {
|
|
770
|
+
res.destroy(error)
|
|
771
|
+
}
|
|
772
|
+
})
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
async function tunnel(runtime, req, res, parsed) {
|
|
776
|
+
let body
|
|
777
|
+
try {
|
|
778
|
+
body = await readBody(req, runtime.config.maxBodyBytes)
|
|
779
|
+
} catch (error) {
|
|
780
|
+
if (error && error.statusCode === 413) {
|
|
781
|
+
gatewayError(res, 413, `Request body exceeds ${runtime.config.maxBodyBytes} bytes`, "body_too_large")
|
|
782
|
+
} else if (!res.headersSent) {
|
|
783
|
+
gatewayError(res, 400, "Could not read request body", "body_read_failed")
|
|
784
|
+
}
|
|
785
|
+
return
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
const bridge = await acquireBridge(runtime, runtime.config.waitForBridgeMs)
|
|
789
|
+
if (!bridge) {
|
|
790
|
+
const url = pageUrl(runtime.config, runtime.port)
|
|
791
|
+
gatewayError(
|
|
792
|
+
res,
|
|
793
|
+
503,
|
|
794
|
+
"No browser bridge is connected to opencode-web-gateway",
|
|
795
|
+
"bridge_unavailable",
|
|
796
|
+
`Open ${url} in a browser, keep the tab open, then retry.`,
|
|
797
|
+
)
|
|
798
|
+
return
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
const id = randomUUID()
|
|
802
|
+
const headers = {}
|
|
803
|
+
const contentType = req.headers["content-type"]
|
|
804
|
+
if (contentType) headers["content-type"] = String(contentType)
|
|
805
|
+
const accept = req.headers["accept"]
|
|
806
|
+
if (accept) headers["accept"] = String(accept)
|
|
807
|
+
if (req.headers.authorization) {
|
|
808
|
+
// Native opencode /connect (or provider env) credential, forwarded untouched.
|
|
809
|
+
headers.authorization = String(req.headers.authorization)
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
const pending = {
|
|
813
|
+
id,
|
|
814
|
+
req,
|
|
815
|
+
res,
|
|
816
|
+
bridge,
|
|
817
|
+
headersSent: false,
|
|
818
|
+
finished: false,
|
|
819
|
+
bytes: body.length,
|
|
820
|
+
startedAt: Date.now(),
|
|
821
|
+
timer: null,
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
runtime.pending.set(id, pending)
|
|
825
|
+
bridge.pending.add(id)
|
|
826
|
+
bridge.active += 1
|
|
827
|
+
bridge.lastUsed = Date.now()
|
|
828
|
+
runtime.stats.requests += 1
|
|
829
|
+
runtime.stats.bytesOut += body.length
|
|
830
|
+
|
|
831
|
+
if (runtime.config.requestTimeoutMs > 0) {
|
|
832
|
+
pending.timer = setTimeout(() => {
|
|
833
|
+
if (!runtime.pending.has(id)) return
|
|
834
|
+
bridge.send({ type: "abort", id })
|
|
835
|
+
finishPending(runtime, id, {
|
|
836
|
+
status: 504,
|
|
837
|
+
code: "upstream_timeout",
|
|
838
|
+
message: `Upstream did not complete within ${runtime.config.requestTimeoutMs} ms`,
|
|
839
|
+
})
|
|
840
|
+
}, runtime.config.requestTimeoutMs)
|
|
841
|
+
pending.timer.unref?.()
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
let finished = false
|
|
845
|
+
const cleanup = () => {
|
|
846
|
+
if (finished) return
|
|
847
|
+
finished = true
|
|
848
|
+
if (!runtime.pending.has(id)) return
|
|
849
|
+
const item = removePending(runtime, id)
|
|
850
|
+
if (item && !item.headersSent) {
|
|
851
|
+
// The downstream client went away before the upstream answered.
|
|
852
|
+
try {
|
|
853
|
+
item.res.destroy()
|
|
854
|
+
} catch {
|
|
855
|
+
// ignore
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
bridge.send({ type: "abort", id })
|
|
859
|
+
}
|
|
860
|
+
res.on("close", cleanup)
|
|
861
|
+
req.on("aborted", cleanup)
|
|
862
|
+
|
|
863
|
+
const message = {
|
|
864
|
+
type: "request",
|
|
865
|
+
id,
|
|
866
|
+
method: (req.method || "POST").toUpperCase(),
|
|
867
|
+
url: upstreamUrl(runtime.config, parsed.pathname, parsed.search),
|
|
868
|
+
headers,
|
|
869
|
+
body: body.length ? body.toString("base64") : null,
|
|
870
|
+
encoding: "base64",
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
if (!bridge.send(message)) {
|
|
874
|
+
finishPending(runtime, id, {
|
|
875
|
+
status: 502,
|
|
876
|
+
code: "bridge_send_failed",
|
|
877
|
+
message: "Could not send request to browser bridge",
|
|
878
|
+
})
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
function handleUpgrade(runtime, req, socket, head) {
|
|
883
|
+
let parsed
|
|
884
|
+
try {
|
|
885
|
+
parsed = new URL(req.url || "/", "http://gateway.local")
|
|
886
|
+
} catch {
|
|
887
|
+
socket.destroy()
|
|
888
|
+
return
|
|
889
|
+
}
|
|
890
|
+
if (parsed.pathname !== "/bridge") {
|
|
891
|
+
socket.write("HTTP/1.1 404 Not Found\r\nConnection: close\r\nContent-Length: 0\r\n\r\n")
|
|
892
|
+
socket.destroy()
|
|
893
|
+
return
|
|
894
|
+
}
|
|
895
|
+
if (runtime.config.token && parsed.searchParams.get("token") !== runtime.config.token) {
|
|
896
|
+
// Finish the upgrade so the browser can see close code 4001 instead of an opaque HTTP 401.
|
|
897
|
+
const tokenKey = req.headers["sec-websocket-key"]
|
|
898
|
+
const tokenAccept = createHash("sha1").update(`${tokenKey || ""}${WS_MAGIC}`).digest("base64")
|
|
899
|
+
socket.write(
|
|
900
|
+
"HTTP/1.1 101 Switching Protocols\r\n" +
|
|
901
|
+
"Upgrade: websocket\r\n" +
|
|
902
|
+
"Connection: Upgrade\r\n" +
|
|
903
|
+
`Sec-WebSocket-Accept: ${tokenAccept}\r\n` +
|
|
904
|
+
"\r\n",
|
|
905
|
+
)
|
|
906
|
+
const unauthorized = new WebSocketPeer(runtime, socket, { remote: String(socket.remoteAddress || "?") }, true)
|
|
907
|
+
runtime.peers.add(unauthorized)
|
|
908
|
+
unauthorized.close(4001, "invalid token")
|
|
909
|
+
return
|
|
910
|
+
}
|
|
911
|
+
if (String(req.headers.upgrade || "").toLowerCase() !== "websocket") {
|
|
912
|
+
socket.write("HTTP/1.1 400 Bad Request\r\nConnection: close\r\nContent-Length: 0\r\n\r\n")
|
|
913
|
+
socket.destroy()
|
|
914
|
+
return
|
|
915
|
+
}
|
|
916
|
+
const origin = String(req.headers.origin || "")
|
|
917
|
+
if (origin) {
|
|
918
|
+
let originHost = ""
|
|
919
|
+
let originValue = ""
|
|
920
|
+
try {
|
|
921
|
+
const parsedOrigin = new URL(origin)
|
|
922
|
+
originHost = parsedOrigin.host
|
|
923
|
+
originValue = parsedOrigin.origin
|
|
924
|
+
} catch {
|
|
925
|
+
// Opaque origins (file://, sandboxed frames) are only allowed when configured explicitly.
|
|
926
|
+
}
|
|
927
|
+
const sameHost = Boolean(originHost) && originHost === String(req.headers.host || "")
|
|
928
|
+
const allowed =
|
|
929
|
+
(originValue && runtime.config.allowedOrigins.includes(originValue)) ||
|
|
930
|
+
runtime.config.allowedOrigins.includes(origin)
|
|
931
|
+
if (!sameHost && !allowed) {
|
|
932
|
+
warn(
|
|
933
|
+
"rejected bridge origin",
|
|
934
|
+
JSON.stringify(origin),
|
|
935
|
+
"- add it to allowedOrigins if this is your hosted page",
|
|
936
|
+
)
|
|
937
|
+
socket.write("HTTP/1.1 403 Forbidden\r\nConnection: close\r\nContent-Length: 0\r\n\r\n")
|
|
938
|
+
socket.destroy()
|
|
939
|
+
return
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
if (String(req.headers["sec-websocket-version"] || "") !== "13") {
|
|
943
|
+
socket.write("HTTP/1.1 426 Upgrade Required\r\nSec-WebSocket-Version: 13\r\nConnection: close\r\nContent-Length: 0\r\n\r\n")
|
|
944
|
+
socket.destroy()
|
|
945
|
+
return
|
|
946
|
+
}
|
|
947
|
+
const key = req.headers["sec-websocket-key"]
|
|
948
|
+
if (!key) {
|
|
949
|
+
socket.write("HTTP/1.1 400 Bad Request\r\nConnection: close\r\nContent-Length: 0\r\n\r\n")
|
|
950
|
+
socket.destroy()
|
|
951
|
+
return
|
|
952
|
+
}
|
|
953
|
+
const accept = createHash("sha1").update(`${key}${WS_MAGIC}`).digest("base64")
|
|
954
|
+
const handshake =
|
|
955
|
+
"HTTP/1.1 101 Switching Protocols\r\n" +
|
|
956
|
+
"Upgrade: websocket\r\n" +
|
|
957
|
+
"Connection: Upgrade\r\n" +
|
|
958
|
+
`Sec-WebSocket-Accept: ${accept}\r\n` +
|
|
959
|
+
"\r\n"
|
|
960
|
+
try {
|
|
961
|
+
socket.write(Buffer.from(handshake, "utf8"))
|
|
962
|
+
socket.uncork?.()
|
|
963
|
+
} catch {
|
|
964
|
+
socket.destroy()
|
|
965
|
+
return
|
|
966
|
+
}
|
|
967
|
+
const peer = new WebSocketPeer(runtime, socket, {
|
|
968
|
+
remote: `${socket.remoteAddress || "?"}:${socket.remotePort || "?"}`,
|
|
969
|
+
userAgent: req.headers["user-agent"] || "",
|
|
970
|
+
origin: req.headers.origin || "",
|
|
971
|
+
})
|
|
972
|
+
socket.resume?.()
|
|
973
|
+
runtime.peers.add(peer)
|
|
974
|
+
log("browser bridge connected", peer.info.remote)
|
|
975
|
+
if (head && head.length) peer.onData(head)
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
function createRuntime(config, key, directory) {
|
|
979
|
+
const runtime = {
|
|
980
|
+
config,
|
|
981
|
+
key,
|
|
982
|
+
directory,
|
|
983
|
+
server: null,
|
|
984
|
+
port: 0,
|
|
985
|
+
peers: new Set(),
|
|
986
|
+
pending: new Map(),
|
|
987
|
+
waiters: new Set(),
|
|
988
|
+
sockets: new Set(),
|
|
989
|
+
refs: 0,
|
|
990
|
+
startedAt: Date.now(),
|
|
991
|
+
closeTimer: null,
|
|
992
|
+
heartbeat: null,
|
|
993
|
+
closed: false,
|
|
994
|
+
external: false,
|
|
995
|
+
stats: {
|
|
996
|
+
requests: 0,
|
|
997
|
+
responses: 0,
|
|
998
|
+
errors: 0,
|
|
999
|
+
bytesOut: 0,
|
|
1000
|
+
bytesIn: 0,
|
|
1001
|
+
connects: 0,
|
|
1002
|
+
disconnects: 0,
|
|
1003
|
+
},
|
|
1004
|
+
}
|
|
1005
|
+
return runtime
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
async function listen(runtime) {
|
|
1009
|
+
const server = createServer((req, res) => {
|
|
1010
|
+
try {
|
|
1011
|
+
onHttp(runtime, req, res)
|
|
1012
|
+
} catch (error) {
|
|
1013
|
+
warn("unhandled request error", error)
|
|
1014
|
+
if (!res.headersSent) gatewayError(res, 500, "internal gateway error", "internal_error")
|
|
1015
|
+
else res.destroy(error)
|
|
1016
|
+
}
|
|
1017
|
+
})
|
|
1018
|
+
runtime.server = server
|
|
1019
|
+
server.requestTimeout = 0
|
|
1020
|
+
server.headersTimeout = 65_000
|
|
1021
|
+
server.keepAliveTimeout = 72_000
|
|
1022
|
+
server.on("upgrade", (req, socket, head) => {
|
|
1023
|
+
try {
|
|
1024
|
+
handleUpgrade(runtime, req, socket, head)
|
|
1025
|
+
} catch (error) {
|
|
1026
|
+
warn("upgrade failed", error)
|
|
1027
|
+
socket.destroy()
|
|
1028
|
+
}
|
|
1029
|
+
})
|
|
1030
|
+
server.on("connection", (socket) => {
|
|
1031
|
+
runtime.sockets.add(socket)
|
|
1032
|
+
socket.on("close", () => runtime.sockets.delete(socket))
|
|
1033
|
+
})
|
|
1034
|
+
server.on("clientError", (error, socket) => {
|
|
1035
|
+
if (socket.writable) socket.end("HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n")
|
|
1036
|
+
})
|
|
1037
|
+
await new Promise((resolve, reject) => {
|
|
1038
|
+
const onError = (error) => {
|
|
1039
|
+
server.off("listening", onListening)
|
|
1040
|
+
reject(error)
|
|
1041
|
+
}
|
|
1042
|
+
const onListening = () => {
|
|
1043
|
+
server.off("error", onError)
|
|
1044
|
+
resolve()
|
|
1045
|
+
}
|
|
1046
|
+
server.once("error", onError)
|
|
1047
|
+
server.once("listening", onListening)
|
|
1048
|
+
server.listen(runtime.config.port, runtime.config.host)
|
|
1049
|
+
})
|
|
1050
|
+
const address = server.address()
|
|
1051
|
+
runtime.port = address && typeof address === "object" ? address.port : runtime.config.port
|
|
1052
|
+
runtime.heartbeat = setInterval(() => {
|
|
1053
|
+
const now = Date.now()
|
|
1054
|
+
for (const peer of [...runtime.peers]) {
|
|
1055
|
+
if (now - peer.lastSeen > 90_000) {
|
|
1056
|
+
peer.close(1001, "heartbeat timeout")
|
|
1057
|
+
continue
|
|
1058
|
+
}
|
|
1059
|
+
peer.ping()
|
|
1060
|
+
}
|
|
1061
|
+
}, 20_000)
|
|
1062
|
+
runtime.heartbeat.unref?.()
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
function shutdown(runtime) {
|
|
1066
|
+
if (runtime.closed) return
|
|
1067
|
+
runtime.closed = true
|
|
1068
|
+
if (runtime.closeTimer) {
|
|
1069
|
+
clearTimeout(runtime.closeTimer)
|
|
1070
|
+
runtime.closeTimer = null
|
|
1071
|
+
}
|
|
1072
|
+
if (runtime.heartbeat) clearInterval(runtime.heartbeat)
|
|
1073
|
+
for (const waiter of runtime.waiters) {
|
|
1074
|
+
clearTimeout(waiter.timer)
|
|
1075
|
+
waiter.resolve(null)
|
|
1076
|
+
}
|
|
1077
|
+
runtime.waiters.clear()
|
|
1078
|
+
for (const peer of [...runtime.peers]) peer.close(1001, "gateway shutting down")
|
|
1079
|
+
for (const [id, pending] of [...runtime.pending]) {
|
|
1080
|
+
finishPending(runtime, id, {
|
|
1081
|
+
status: 503,
|
|
1082
|
+
code: "gateway_shutdown",
|
|
1083
|
+
message: "opencode-web-gateway is shutting down",
|
|
1084
|
+
})
|
|
1085
|
+
}
|
|
1086
|
+
const server = runtime.server
|
|
1087
|
+
if (server) {
|
|
1088
|
+
try {
|
|
1089
|
+
server.close()
|
|
1090
|
+
} catch {
|
|
1091
|
+
// ignore
|
|
1092
|
+
}
|
|
1093
|
+
server.closeAllConnections?.()
|
|
1094
|
+
for (const socket of [...runtime.sockets]) {
|
|
1095
|
+
try {
|
|
1096
|
+
socket.destroy()
|
|
1097
|
+
} catch {
|
|
1098
|
+
// ignore
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
const map = registry()
|
|
1103
|
+
if (map.get(runtime.key) === runtime) map.delete(runtime.key)
|
|
1104
|
+
log("server stopped", `${runtime.config.host}:${runtime.port}`)
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
async function probeExisting(config) {
|
|
1108
|
+
const host = displayHost(config.host)
|
|
1109
|
+
const url = `http://${host}:${config.port}/healthz`
|
|
1110
|
+
try {
|
|
1111
|
+
const response = await fetch(url, { signal: AbortSignal.timeout(1200) })
|
|
1112
|
+
if (!response.ok) return null
|
|
1113
|
+
const value = await response.json()
|
|
1114
|
+
if (!isRecord(value) || value.service !== SERVICE) return null
|
|
1115
|
+
return value
|
|
1116
|
+
} catch {
|
|
1117
|
+
return null
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
async function startGateway(input) {
|
|
1122
|
+
const directory = input && input.directory ? input.directory : process.cwd()
|
|
1123
|
+
const options = input && isRecord(input.options) ? input.options : {}
|
|
1124
|
+
const config = loadConfig(directory, options)
|
|
1125
|
+
const key = config.port === 0 ? `ephemeral:${randomUUID()}` : `${config.host}:${config.port}`
|
|
1126
|
+
const map = registry()
|
|
1127
|
+
|
|
1128
|
+
let runtime = map.get(key)
|
|
1129
|
+
if (runtime && !runtime.closed) {
|
|
1130
|
+
await runtime.startPromise
|
|
1131
|
+
if (runtime.closed) {
|
|
1132
|
+
// A concurrent shutdown won the race; fall through and create a new one.
|
|
1133
|
+
runtime = null
|
|
1134
|
+
} else {
|
|
1135
|
+
runtime.refs += 1
|
|
1136
|
+
if (runtime.closeTimer) {
|
|
1137
|
+
clearTimeout(runtime.closeTimer)
|
|
1138
|
+
runtime.closeTimer = null
|
|
1139
|
+
}
|
|
1140
|
+
const info = {
|
|
1141
|
+
url: pageUrl(runtime.config, runtime.port),
|
|
1142
|
+
host: runtime.config.host,
|
|
1143
|
+
port: runtime.port,
|
|
1144
|
+
external: runtime.external,
|
|
1145
|
+
configPath: runtime.config.configPath,
|
|
1146
|
+
}
|
|
1147
|
+
if (typeof options.onReady === "function") options.onReady(info)
|
|
1148
|
+
log("reusing running bridge", info.url)
|
|
1149
|
+
return makeHandle(runtime)
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
runtime = createRuntime(config, key, directory)
|
|
1154
|
+
map.set(key, runtime)
|
|
1155
|
+
runtime.refs = 1
|
|
1156
|
+
runtime.startPromise = listen(runtime).catch(async (error) => {
|
|
1157
|
+
if (error && error.code === "EADDRINUSE" && config.port !== 0) {
|
|
1158
|
+
const existing = await probeExisting(config)
|
|
1159
|
+
if (existing) {
|
|
1160
|
+
runtime.external = true
|
|
1161
|
+
runtime.port = config.port
|
|
1162
|
+
runtime.server = null
|
|
1163
|
+
runtime.startPromise = Promise.resolve()
|
|
1164
|
+
log("port already served by another gateway instance; reusing", pageUrl(config, config.port))
|
|
1165
|
+
return
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
map.delete(key)
|
|
1169
|
+
runtime.closed = true
|
|
1170
|
+
throw error
|
|
1171
|
+
})
|
|
1172
|
+
try {
|
|
1173
|
+
await runtime.startPromise
|
|
1174
|
+
} catch (error) {
|
|
1175
|
+
map.delete(key)
|
|
1176
|
+
const hint =
|
|
1177
|
+
error && error.code === "EADDRINUSE"
|
|
1178
|
+
? `Port ${config.host}:${config.port} is already in use by another program. Change "port" in web-gateway.config.json or stop that program.`
|
|
1179
|
+
: error && error.message
|
|
1180
|
+
? error.message
|
|
1181
|
+
: String(error)
|
|
1182
|
+
warn("failed to start:", hint)
|
|
1183
|
+
throw new Error(`opencode-web-gateway: ${hint}`)
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
const info = {
|
|
1187
|
+
url: pageUrl(runtime.config, runtime.port),
|
|
1188
|
+
host: runtime.config.host,
|
|
1189
|
+
port: runtime.port,
|
|
1190
|
+
external: runtime.external,
|
|
1191
|
+
configPath: runtime.config.configPath,
|
|
1192
|
+
}
|
|
1193
|
+
if (typeof options.onReady === "function") options.onReady(info)
|
|
1194
|
+
if (!runtime.external) {
|
|
1195
|
+
log(`listening on ${runtime.config.host}:${runtime.port}`)
|
|
1196
|
+
log(`open ${info.url} in a browser`)
|
|
1197
|
+
log("auth: passthrough (opencode /connect or DEEPSEEK_API_KEY)")
|
|
1198
|
+
}
|
|
1199
|
+
return makeHandle(runtime)
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
function makeHandle(runtime) {
|
|
1203
|
+
let stopped = false
|
|
1204
|
+
return {
|
|
1205
|
+
url: pageUrl(runtime.config, runtime.port),
|
|
1206
|
+
host: runtime.config.host,
|
|
1207
|
+
port: runtime.port,
|
|
1208
|
+
external: runtime.external,
|
|
1209
|
+
stop: async () => {
|
|
1210
|
+
if (stopped) return
|
|
1211
|
+
stopped = true
|
|
1212
|
+
runtime.refs = Math.max(0, runtime.refs - 1)
|
|
1213
|
+
if (runtime.refs > 0) return
|
|
1214
|
+
if (runtime.closeTimer) clearTimeout(runtime.closeTimer)
|
|
1215
|
+
runtime.closeTimer = setTimeout(() => shutdown(runtime), 750)
|
|
1216
|
+
runtime.closeTimer.unref?.()
|
|
1217
|
+
},
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
// A single default export supports both plugin generations:
|
|
1222
|
+
// - OpenCode v1 (>= 1.18.29) reads the object entrypoint and calls server().
|
|
1223
|
+
// - OpenCode v2 reads the same object and calls setup().
|
|
1224
|
+
const plugin = {
|
|
1225
|
+
id: PLUGIN_ID,
|
|
1226
|
+
async setup(ctx) {
|
|
1227
|
+
const directory = ctx && ctx.location && ctx.location.directory ? ctx.location.directory : process.cwd()
|
|
1228
|
+
const options = ctx && isRecord(ctx.options) ? ctx.options : {}
|
|
1229
|
+
const handle = await startGateway({ directory, options })
|
|
1230
|
+
return async () => {
|
|
1231
|
+
await handle.stop()
|
|
1232
|
+
}
|
|
1233
|
+
},
|
|
1234
|
+
async server(input, options) {
|
|
1235
|
+
const directory = input && input.directory ? input.directory : process.cwd()
|
|
1236
|
+
const handle = await startGateway({ directory, options: isRecord(options) ? options : {} })
|
|
1237
|
+
return {
|
|
1238
|
+
async dispose() {
|
|
1239
|
+
await handle.stop()
|
|
1240
|
+
},
|
|
1241
|
+
}
|
|
1242
|
+
},
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
export default plugin
|