Files
k.denisov 1af3ff9d10 Панель «Токены» для веб-интерфейса OpenCode 2 как плагин
Прокси (server.mjs) вставляет panel.js в страницы веб-интерфейса и отдаёт
/__ctx/api/* из opencode.db. Теперь он поднимается плагином
context-panel.v2.ts внутри процесса OpenCode, а не отдельным Node-процессом.

- server.mjs: экспортирует startPanel(); SQLite — bun:sqlite под Bun,
  node:sqlite под Node; ручной запуск `node server.mjs …` сохранён.
- context-panel.v2.ts: один прокси на процесс (globalThis), при dispose
  не закрывается; порты из CTX_PANEL_LISTEN / CTX_PANEL_TARGET,
  выключение — OPENCODE_CONTEXT_PANEL=0, журнал — log/context-panel.log.

Плагины грузятся лениво (при первом запросе к API проекта), поэтому
обёртка должна сама дёрнуть API после старта `opencode serve`.
2026-10-29 21:24:45 -07:00

560 lines
26 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
// Прокси перед `opencode serve` (OpenCode 2): всё пропускает как есть (HTTP, SSE, WebSocket), а
// в HTML-страницы вставляет <script src="/__ctx/panel.js"> — выдвижную панель с составом
// контекста и расходом токенов. Данные панель берёт из /__ctx/api/*, которые читают opencode.db
// (только чтение).
//
// Обычно запускается внутри OpenCode: плагин context-panel.v2.ts вызывает startPanel(). Вручную:
//
// node server.mjs --listen 4196 --target 4197 [--db $XDG_DATA_HOME/opencode/opencode.db]
//
// Веб-интерфейс OpenCode точек расширения для плагинов не имеет (слоты есть только в TUI),
// поэтому так. CSP страницы (script-src 'self') пропускает скрипт: он с того же origin.
//
// OpenCode закрыт паролем (Basic auth): /__ctx/* пускаем с тем же заголовком Authorization, что
// принимает сам OpenCode, и с ним же ходим в его API (лимит модели, контекст сессии).
import http from "node:http"
import net from "node:net"
import fs from "node:fs"
import os from "node:os"
import path from "node:path"
import { fileURLToPath } from "node:url"
const HERE = path.dirname(fileURLToPath(import.meta.url))
// SQLite: внутри OpenCode — bun:sqlite, под Node — node:sqlite. API prepare().get/all у них общий
const openDatabase = globalThis.Bun
? await import("bun:sqlite").then(({ Database }) => (p) => new Database(p, { readonly: true }))
: await import("node:sqlite").then(({ DatabaseSync }) => (p) => new DatabaseSync(p, { readOnly: true }))
function arg(name, fallback) {
const i = process.argv.indexOf(`--${name}`)
return i > 0 && process.argv[i + 1] ? process.argv[i + 1] : fallback
}
const HOST = "127.0.0.1"
const DATA_HOME = process.env.XDG_DATA_HOME || path.join(os.homedir(), ".local/share")
const DEFAULTS = {
listen: Number(process.env.CTX_PANEL_LISTEN ?? 4196),
target: Number(process.env.CTX_PANEL_TARGET ?? 4197),
db: path.join(DATA_HOME, "opencode/opencode.db"),
}
// Задаются в startPanel()
let LISTEN = DEFAULTS.listen
let TARGET = DEFAULTS.target
let DB_PATH = DEFAULTS.db
const PANEL_TAG = '<script src="/__ctx/panel.js" defer></script>'
// Веб-интерфейс ставит service worker (Workbox), который кэширует index.html и потом отдаёт
// страницу из кэша мимо прокси — без панели. Вместо /sw.js отдаём «гасящий»: чистит кэши,
// снимает регистрацию и один раз перезагружает вкладки (только если было что чистить —
// иначе страница, которая регистрирует /sw.js при каждой загрузке, перезагружалась бы по кругу)
const KILL_SW = `self.addEventListener("install", () => self.skipWaiting())
self.addEventListener("activate", (e) => e.waitUntil((async () => {
const keys = await caches.keys()
await Promise.all(keys.map((k) => caches.delete(k)))
await self.registration.unregister()
if (keys.length) for (const c of await self.clients.matchAll({ type: "window" })) c.navigate(c.url)
})()))
`
// --- база --------------------------------------------------------------------------------
let db
function getDb() {
if (!db) db = openDatabase(DB_PATH)
return db
}
const parse = (s) => {
try {
return JSON.parse(s)
} catch {
return undefined
}
}
// Та же оценка, что у самого OpenCode (Token.estimate): символы / 4
const estimate = (s) => (s ? Math.round(String(s).length / 4) : 0)
function zeroTokens() {
return { input: 0, output: 0, reasoning: 0, cacheRead: 0, cacheWrite: 0, total: 0, cost: 0, steps: 0 }
}
function addTokens(acc, t, cost) {
if (!t) return acc
acc.input += t.input || 0
acc.output += t.output || 0
acc.reasoning += t.reasoning || 0
acc.cacheRead += t.cache?.read || 0
acc.cacheWrite += t.cache?.write || 0
acc.total += (t.input || 0) + (t.output || 0) + (t.reasoning || 0) + (t.cache?.read || 0) + (t.cache?.write || 0)
acc.cost += cost || 0
acc.steps += 1
return acc
}
function sessionRowTokens(r) {
const t = zeroTokens()
t.input = r.tokens_input
t.output = r.tokens_output
t.reasoning = r.tokens_reasoning
t.cacheRead = r.tokens_cache_read
t.cacheWrite = r.tokens_cache_write
t.total = t.input + t.output + t.reasoning + t.cacheRead + t.cacheWrite
t.cost = r.cost
return t
}
function toolLabel(name) {
return name?.includes("_") && !name.startsWith("todo") ? name.replace(/_/, ": ") : name
}
// Токенов в запросе к модели — так же, как считает OpenCode
const reqTotal = (t) => (t ? (t.input || 0) + (t.output || 0) + (t.reasoning || 0) + (t.cache?.read || 0) + (t.cache?.write || 0) : 0)
// --- скорость модели ---------------------------------------------------------------------
// Один шаг = одно сообщение ассистента: start — начало потока (до него — ожидание и prefill),
// end — конец потока (выполнение тулов в генерацию не входит).
function zeroSpeed() {
return { steps: 0, out: 0, decodeMs: 0, prompt: 0, waitMs: 0, modelMs: 0 }
}
// x — { start, end } шага, info — данные сообщения ассистента
function addSpeed(acc, x, info) {
const created = info.time?.created
if (!x?.start || !x.end || !created || !info.time?.completed || info.summary) return acc
const decode = x.end - x.start
const wait = x.start - created
if (decode <= 0 || wait < 0) return acc
acc.steps += 1
acc.out += (info.tokens?.output || 0) + (info.tokens?.reasoning || 0)
acc.decodeMs += decode
acc.prompt += (info.tokens?.input || 0) + (info.tokens?.cache?.write || 0)
acc.waitMs += wait
acc.modelMs += x.end - created
return acc
}
function speedOut(a) {
if (!a.steps) return undefined
return {
steps: a.steps,
decodeTps: a.out / (a.decodeMs / 1000), // генерация, ток/с
prefillTps: a.waitMs ? a.prompt / (a.waitMs / 1000) : undefined, // prompt без кэша / ожидание первого токена
firstTokenSec: a.waitMs / a.steps / 1000, // среднее ожидание первого токена
stepSec: a.modelMs / a.steps / 1000, // среднее время модели на шаг (без выполнения тулов)
}
}
// --- сессия -------------------------------------------------------------------------------
// Схема: session_v2 (суммы токенов по сессии) и session_message (type + data JSON). Один шаг
// модели = одно сообщение `assistant`: content[] — reasoning / text / tool (вход и вывод тула
// внутри), tokens, time.created / streamed / completed. Compaction — сообщение `compaction`
// (reason auto/manual, status, summary, tokens самого запроса на сводку).
// API v2 — с паролем браузера (тем же заголовком Authorization)
async function apiV2(pathname, auth) {
const res = await fetch(`http://${HOST}:${TARGET}${pathname}`, { headers: auth ? { authorization: auth } : {} })
if (!res.ok) throw new Error(`${pathname}: ${res.status}`)
return (await res.json()).data
}
// Пускаем в /__ctx/* только с паролем, который принимает сам OpenCode. Удачные — кэшируем
const authOk = new Map() // заголовок → до какого времени верим
async function authorizedV2(auth) {
if (!auth) return false
if ((authOk.get(auth) ?? 0) > Date.now()) return true
const res = await fetch(`http://${HOST}:${TARGET}/api/session/active`, { headers: { authorization: auth } }).catch(() => undefined)
if (res?.ok) authOk.set(auth, Date.now() + 60_000)
return !!res?.ok
}
const limitCacheV2 = new Map()
async function contextLimitV2(directory, providerID, modelID, auth) {
let entry = limitCacheV2.get(directory)
if (!entry || Date.now() - entry.at > 60_000) {
const models = await apiV2(`/api/model?location[directory]=${encodeURIComponent(directory)}`, auth).catch(() => undefined)
entry = { at: Date.now(), models }
limitCacheV2.set(directory, entry)
}
const m = entry.models?.find((x) => x.providerID === providerID && (x.modelID ?? x.id) === modelID)
return m?.limit?.context || undefined
}
// Начало потока — первая часть ответа, конец — time.streamed (иначе последняя часть / запуск тула)
function stepTimingV2(info) {
let start = Infinity
let end = info.time?.streamed ?? 0
for (const c of info.content ?? []) {
if (c.time?.created) start = Math.min(start, c.time.created)
if (!info.time?.streamed) end = Math.max(end, c.type === "tool" ? (c.time?.created ?? 0) : (c.time?.completed ?? 0))
}
return start < Infinity && end ? { start, end } : undefined
}
const contentText = (content) =>
Array.isArray(content) ? content.map((c) => (c.type === "text" ? c.text : JSON.stringify(c).slice(0, 4000))).join("\n") : String(content ?? "")
// Что вызывали: для Code Mode (execute) — какие MCP-серверы в скрипте
function callTitleV2(c) {
const i = c.state?.input ?? {}
if (typeof i === "string") return i.slice(0, 200)
if (c.name === "execute" && typeof i.code === "string") {
const used = [...new Set([...i.code.matchAll(/tools\.([\w-]+)\.([\w-]+)/g)].map((m) => `${m[1]}.${m[2]}`))]
return used.length ? used.join(", ") : i.code.split("\n").find((l) => l.trim())?.trim() ?? ""
}
return String(i.description ?? i.command ?? i.path ?? i.filePath ?? i.pattern ?? i.url ?? i.query ?? i.id ?? "").slice(0, 200)
}
async function sessionStatsV2(id, auth) {
const d = getDb()
const s = d.prepare("select * from session_v2 where id = ?").get(id)
if (!s) return { error: "session not found" }
const kids = d
.prepare(
`with recursive k(id) as (select id from session_v2 where parent_id = ?
union all select s.id from session_v2 s join k on s.parent_id = k.id)
select coalesce(sum(tokens_input),0) tokens_input, coalesce(sum(tokens_output),0) tokens_output,
coalesce(sum(tokens_reasoning),0) tokens_reasoning, coalesce(sum(tokens_cache_read),0) tokens_cache_read,
coalesce(sum(tokens_cache_write),0) tokens_cache_write, coalesce(sum(cost),0) cost, count(*) n
from session_v2 where id in (select id from k)`,
)
.get(id)
const msgs = d
.prepare("select id, type, time_created t, data from session_message where session_id = ? order by seq")
.all(id)
.map((r) => ({ id: r.id, type: r.type, t: r.t, info: parse(r.data) ?? {} }))
const self = zeroTokens()
const speed = zeroSpeed()
const steps = []
const compactions = []
msgs.forEach((m, i) => {
if (m.type === "assistant") {
addTokens(self, m.info.tokens, m.info.cost)
addSpeed(speed, stepTimingV2(m.info), m.info)
const t = m.info.tokens
if (reqTotal(t)) steps.push({ t: m.info.time?.created, total: reqTotal(t), output: (t.output || 0) + (t.reasoning || 0), model: m.info.model?.id })
} else if (m.type === "compaction") {
// Запрос на сводку — тоже расход
addTokens(self, m.info.tokens, m.info.cost)
const next = msgs.slice(i + 1).find((x) => x.type !== "compaction")
const after = msgs.slice(i + 1).find((x) => x.type === "assistant" && reqTotal(x.info.tokens))
const done = m.info.status === "completed"
compactions.push({
at: steps.length,
t: m.info.time?.created ?? m.t,
auto: m.info.reason === "auto",
done,
failed: m.info.status === "failed",
before: steps.at(-1)?.total ?? 0,
after: after ? reqTotal(after.info.tokens) : undefined,
summary: done ? (m.info.tokens?.output || 0) + (m.info.tokens?.reasoning || 0) || estimate(m.info.summary) : undefined,
sec: done && next ? (next.t - (m.info.time?.created ?? m.t)) / 1000 : undefined,
})
}
})
// Что сейчас в контексте — спрашиваем сам OpenCode; без него — всё после последней compaction
let ctx = await apiV2(`/api/session/${id}/context`, auth).catch(() => undefined)
if (!ctx) {
const cut = msgs.map((m) => m.type === "compaction" && m.info.status === "completed").lastIndexOf(true)
ctx = msgs.slice(Math.max(0, cut)).map((m) => ({ ...m.info, id: m.id, type: m.type }))
}
const compacted = ctx.some((m) => m.type === "compaction")
const cats = new Map()
const calls = []
const add = (key, label, kind, n) => {
if (!n) return
const c = cats.get(key) ?? { key, label, kind, tokens: 0 }
c.tokens += n
cats.set(key, c)
}
for (const m of ctx) {
if (m.type === "user") add("user", "Сообщения пользователя", "user", estimate(m.text) + estimate(JSON.stringify(m.files ?? []).slice(0, 4000)))
else if (m.type === "compaction") add("summary", "Сводка compaction", "summary", estimate(m.summary))
else if (m.type === "skill") add("skill", "Скилы", "skill", estimate(m.text))
else if (m.type === "shell") add("tool:shell!", "shell (пользователь)", "tool", estimate(m.command) + estimate(contentText(m.output)))
else if (m.type === "synthetic" || m.type === "system") add("system-msg", "Служебные сообщения", "system", estimate(m.text))
else if (m.type === "assistant") {
for (const c of m.content ?? []) {
if (c.type === "text") add("assistant", "Ответы ассистента", "assistant", estimate(c.text))
else if (c.type === "reasoning") add("reasoning", "Reasoning", "reasoning", estimate(c.text))
else if (c.type === "tool") {
const st = c.state ?? {}
const n = estimate(typeof st.input === "string" ? st.input : JSON.stringify(st.input ?? {})) + estimate(contentText(st.content))
add(`tool:${c.name}`, toolLabel(c.name), "tool", n)
calls.push({ tool: toolLabel(c.name), title: callTitleV2(c), tokens: n, error: st.status === "error", t: c.time?.created })
}
}
}
}
const last = [...ctx].reverse().find((m) => m.type === "assistant" && (m.tokens?.input || 0) + (m.tokens?.cache?.read || 0) > 0)
const total = reqTotal(last?.tokens)
const estimated = [...cats.values()].reduce((a, c) => a + c.tokens, 0)
if (total > estimated) cats.set("system", { key: "system", label: "Система + схемы тулов (остаток)", kind: "system", tokens: total - estimated })
const sm = parse(s.model) ?? {}
const lastAny = [...msgs].reverse().find((m) => m.type === "assistant")
const providerID = last?.model?.providerID ?? lastAny?.info.model?.providerID ?? sm.providerID
const modelID = last?.model?.id ?? lastAny?.info.model?.id ?? sm.id ?? sm.model
const limit = providerID && modelID ? await contextLimitV2(s.directory, providerID, modelID, auth) : undefined
const withKids = sessionRowTokens(s)
const kt = sessionRowTokens(kids)
for (const k of ["input", "output", "reasoning", "cacheRead", "cacheWrite", "total", "cost"]) withKids[k] += kt[k]
return {
session: { id: s.id, title: s.title, directory: s.directory, projectID: s.project_id, parentID: s.parent_id },
model: { providerID, modelID, limit },
context: {
total,
estimated,
compacted,
messages: ctx.filter((m) => !["idle", "agent-switched", "model-switched", "location-switched"].includes(m.type)).length,
categories: [...cats.values()].sort((a, b) => b.tokens - a.tokens),
topCalls: calls.sort((a, b) => b.tokens - a.tokens).slice(0, 10),
},
steps,
compactions,
speed: speedOut(speed),
spend: { self, withSubagents: withKids, subagents: kids.n },
}
}
// Для общего расхода — без разбора content целиком: всё нужное достаёт SQLite
function globalRowsV2() {
return getDb()
.prepare(
`select m.id id, s.project_id pid, m.time_created t, m.type type,
json_extract(m.data, '$.tokens') tokens, json_extract(m.data, '$.cost') cost,
json_extract(m.data, '$.time.created') created, json_extract(m.data, '$.time.completed') completed,
json_extract(m.data, '$.time.streamed') streamed,
json_extract(m.data, '$.model.providerID') provider, json_extract(m.data, '$.model.id') model,
(select min(json_extract(value, '$.time.created')) from json_each(m.data, '$.content')) first
from session_message m join session_v2 s on s.id = m.session_id
where m.type in ('assistant', 'compaction')`,
)
.all()
.map((r) => ({
id: r.id,
pid: r.pid,
t: r.t,
tokens: parse(r.tokens),
cost: r.cost,
time: { created: r.created, completed: r.completed },
summary: r.type === "compaction",
model: r.model && `${r.provider}/${r.model}`,
x: r.first && r.streamed ? { start: r.first, end: r.streamed } : {},
}))
}
let globalCache
function globalStats(projectID) {
if (!globalCache || Date.now() - globalCache.at > 15_000) {
const sessions = getDb().prepare("select project_id pid, count(*) n from session_v2 group by project_id").all()
globalCache = { at: Date.now(), rows: globalRowsV2(), sessions }
}
const { rows, sessions } = globalCache
const midnight = new Date()
midnight.setHours(0, 0, 0, 0)
const daysAgo = (n) => new Date(midnight.getFullYear(), midnight.getMonth(), midnight.getDate() - n).getTime()
const windows = { today: midnight.getTime(), week: daysAgo(6), month: daysAgo(29), all: 0 }
const scope = (filter) => {
const out = {}
for (const [k, since] of Object.entries(windows)) {
out[k] = rows.filter((r) => r.t >= since && filter(r)).reduce((a, r) => addTokens(a, r.tokens, r.cost), zeroTokens())
}
return out
}
// Ряды для графика: сегодня — по часам, 7/30 дней — по дням, всё время — по дням или
// по неделям, если дней больше 90. Границы — по местному времени (Date сам учтёт DST)
const first = rows.reduce((a, r) => Math.min(a, r.t), Date.now())
const firstDay = new Date(first)
firstDay.setHours(0, 0, 0, 0)
const spanDays = Math.round((midnight - firstDay) / 86_400_000) + 1
const step = spanDays > 90 ? 7 : 1
const starts = {
today: Array.from({ length: 24 }, (_, h) => new Date(midnight.getFullYear(), midnight.getMonth(), midnight.getDate(), h).getTime()),
week: Array.from({ length: 7 }, (_, i) => daysAgo(6 - i)),
month: Array.from({ length: 30 }, (_, i) => daysAgo(29 - i)),
all: Array.from({ length: Math.ceil(spanDays / step) }, (_, i) => daysAgo(spanDays - 1 - i * step)),
}
const series = (filter) => {
const out = {}
for (const [k, st] of Object.entries(starts)) {
const buckets = st.map((t) => ({ t, ...zeroTokens(), models: {} }))
for (const r of rows) {
if (r.t < st[0] || !filter(r)) continue
let lo = 0
let hi = st.length - 1
while (lo < hi) {
const mid = (lo + hi + 1) >> 1
if (st[mid] <= r.t) lo = mid
else hi = mid - 1
}
addTokens(buckets[lo], r.tokens, r.cost)
const key = r.model ?? "?"
buckets[lo].models[key] = (buckets[lo].models[key] ?? 0) + reqTotal(r.tokens)
}
out[k] = { unit: k === "today" ? "hour" : k === "all" && step === 7 ? "week" : "day", buckets }
}
return out
}
const models = new Map()
const speeds = new Map()
for (const r of rows) {
if (!r.model) continue
models.set(r.model, addTokens(models.get(r.model) ?? zeroTokens(), r.tokens, r.cost))
speeds.set(r.model, addSpeed(speeds.get(r.model) ?? zeroSpeed(), r.x, r))
}
return {
project: projectID ? scope((r) => r.pid === projectID) : undefined,
projectSessions: sessions.find((x) => x.pid === projectID)?.n ?? 0,
all: scope(() => true),
series: {
project: projectID ? series((r) => r.pid === projectID) : undefined,
all: series(() => true),
},
allSessions: sessions.reduce((a, x) => a + x.n, 0),
models: [...models.entries()]
.map(([model, t]) => ({ model, ...t, speed: speedOut(speeds.get(model)) }))
.sort((a, b) => b.total - a.total)
.slice(0, 6),
}
}
// --- HTTP --------------------------------------------------------------------------------
function sendJson(res, status, body) {
res.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" })
res.end(JSON.stringify(body))
}
async function handleOwn(req, res) {
const url = new URL(req.url, `http://${HOST}`)
if (url.pathname === "/__ctx/panel.js") {
res.writeHead(200, { "content-type": "text/javascript; charset=utf-8", "cache-control": "no-store" })
return res.end(fs.readFileSync(path.join(HERE, "panel.js")))
}
try {
const auth = req.headers.authorization
if (!(await authorizedV2(auth))) {
res.writeHead(401, { "www-authenticate": 'Basic realm="Secure Area"', "content-type": "text/plain; charset=utf-8" })
return res.end("Unauthorized\n")
}
let m
if ((m = url.pathname.match(/^\/__ctx\/api\/session\/([\w-]+)$/)))
return sendJson(res, 200, await sessionStatsV2(m[1], auth))
if (url.pathname === "/__ctx/api/global") return sendJson(res, 200, globalStats(url.searchParams.get("project")))
} catch (e) {
return sendJson(res, 500, { error: String(e?.message ?? e) })
}
sendJson(res, 404, { error: "not found" })
}
function proxy(req, res) {
if (req.method === "GET" && new URL(req.url, `http://${HOST}`).pathname === "/sw.js") {
res.writeHead(200, { "content-type": "text/javascript; charset=utf-8", "cache-control": "no-store" })
return res.end(KILL_SW)
}
const headers = { ...req.headers, host: `${HOST}:${TARGET}` }
// HTML правим на лету — просим ответы без сжатия (loopback, трафик не важен). Не только при
// Accept: text/html: страницу может запросить и fetch() (service worker, предзагрузка)
if (req.method === "GET") delete headers["accept-encoding"]
const up = http.request({ host: HOST, port: TARGET, method: req.method, path: req.url, headers }, (ur) => {
const type = ur.headers["content-type"] ?? ""
if (!type.includes("text/html") || ur.headers["content-encoding"]) {
res.writeHead(ur.statusCode, ur.headers)
res.flushHeaders()
return ur.pipe(res)
}
const chunks = []
ur.on("data", (c) => chunks.push(c))
ur.on("end", () => {
let html = Buffer.concat(chunks).toString("utf8")
html = html.includes("</head>") ? html.replace("</head>", `${PANEL_TAG}</head>`) : html + PANEL_TAG
const out = Buffer.from(html, "utf8")
const h = { ...ur.headers, "content-length": out.length }
delete h["transfer-encoding"]
res.writeHead(ur.statusCode, h)
res.end(out)
})
})
up.on("error", (e) => {
if (res.headersSent) return res.destroy()
res.writeHead(502, { "content-type": "text/plain; charset=utf-8" })
res.end(`OpenCode на ${HOST}:${TARGET} недоступен: ${e.message}\n`)
})
// Клиент ушёл (закрыл вкладку, оборвал SSE) — рвём и запрос к OpenCode
res.on("close", () => {
if (!res.writableFinished) up.destroy()
})
req.pipe(up)
}
// WebSocket (терминал и пр.): после рукопожатия — просто труба TCP ↔ TCP
function upgrade(req, socket, head) {
const up = net.connect(TARGET, HOST, () => {
const lines = [`${req.method} ${req.url} HTTP/${req.httpVersion}`]
for (let i = 0; i < req.rawHeaders.length; i += 2) {
const [k, v] = [req.rawHeaders[i], req.rawHeaders[i + 1]]
lines.push(`${k}: ${k.toLowerCase() === "host" ? `${HOST}:${TARGET}` : v}`)
}
up.write(lines.join("\r\n") + "\r\n\r\n")
if (head?.length) up.write(head)
socket.pipe(up).pipe(socket)
})
const close = () => {
up.destroy()
socket.destroy()
}
up.on("error", close)
socket.on("error", close)
}
// Поднимает прокси; промис — когда слушает порт (или с ошибкой, например EADDRINUSE)
export function startPanel({ listen = DEFAULTS.listen, target = DEFAULTS.target, db: dbPath = DEFAULTS.db } = {}) {
LISTEN = Number(listen)
TARGET = Number(target)
DB_PATH = dbPath
const server = http.createServer((req, res) => {
if (req.url.startsWith("/__ctx/")) return void handleOwn(req, res)
proxy(req, res)
})
server.on("upgrade", upgrade)
return new Promise((resolve, reject) => {
server.once("error", reject)
server.listen(LISTEN, HOST, () => {
server.off("error", reject)
console.log(`context-panel: http://${HOST}:${LISTEN}/ → OpenCode ${HOST}:${TARGET}, БД ${DB_PATH}`)
resolve(server)
})
})
}
// Запуск из командной строки: node server.mjs …
const direct = (() => {
try {
return fs.realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)
} catch {
return false
}
})()
if (direct) {
startPanel({ listen: arg("listen", DEFAULTS.listen), target: arg("target", DEFAULTS.target), db: arg("db", DEFAULTS.db) }).catch((e) => {
console.error(`context-panel: ${e.message}`)
process.exit(1)
})
}