opencode-dashboard-client 0.1.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/dist/assets/index-D9vQPXiw.js +1 -0
- package/dist/assets/index-DsbIuDpC.css +1 -0
- package/dist/index.html +23 -0
- package/package.json +25 -0
- package/server.ts +51 -0
- package/src/config.ts +11 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
(function(){const e=document.createElement("link").relList;if(e&&e.supports&&e.supports("modulepreload"))return;for(const i of document.querySelectorAll('link[rel="modulepreload"]'))o(i);new MutationObserver(i=>{for(const r of i)if(r.type==="childList")for(const a of r.addedNodes)a.tagName==="LINK"&&a.rel==="modulepreload"&&o(a)}).observe(document,{childList:!0,subtree:!0});function n(i){const r={};return i.integrity&&(r.integrity=i.integrity),i.referrerPolicy&&(r.referrerPolicy=i.referrerPolicy),i.crossOrigin==="use-credentials"?r.credentials="include":i.crossOrigin==="anonymous"?r.credentials="omit":r.credentials="same-origin",r}function o(i){if(i.ep)return;i.ep=!0;const r=n(i);fetch(i.href,r)}})();async function b(s,e){const n=await fetch(s+e);if(!n.ok){let o=`${n.status} ${n.statusText}`;try{const i=await n.json();i.detail&&(o=String(i.detail))}catch{}throw new Error(o)}return n.json()}function v(s){return`/api/s/${s}`}const S={overview:s=>b(v(s),"/overview"),projects:s=>b(v(s),"/projects"),project:(s,e)=>b(v(s),`/projects/${encodeURIComponent(e)}`),session:(s,e)=>b(v(s),`/sessions/${encodeURIComponent(e)}`)},A=[["input","seg-in"],["output","seg-out"],["reasoning","seg-rea"],["cacheRead","seg-cr"],["cacheWrite","seg-cw"]];function t(s,e,n){const o=document.createElement(s);return e&&(o.className=e),n!==void 0&&(o.textContent=n),o}function M(s){return s>=1e6?(s/1e6).toFixed(1).replace(/\.0$/,"")+"M":s>=1e3?(s/1e3).toFixed(1).replace(/\.0$/,"")+"K":String(s)}function y(s){if(!s)return"$0";if(s<1e-4)return"< $0.0001";const e=s.toFixed(4).replace(/0+$/,"").replace(/\.$/,"");return"$"+(e===""?"0":e)}function U(s){const e=Math.max(0,Math.round((Date.now()-s)/1e3));return e<60?`${e}s ago`:e<3600?`${Math.round(e/60)}m ago`:e<86400?`${Math.round(e/3600)}h ago`:`${Math.round(e/86400)}d ago`}function W(s){const e=t("div","bar");if(!s.total)return e.classList.add("bar-empty"),e.title="0 tokens",e;for(const[n,o]of A){const i=s[n]/s.total*100;if(i<=0)continue;const r=t("div",`seg ${o}`);r.style.width=`${i}%`,r.title=`${n}: ${M(s[n])}`,e.appendChild(r)}return e}function w(s,e=!1){const n=t("div","tcell"+(e?" hero":"")),o=t("div","bar-wrap");return o.appendChild(W(s)),n.append(o,t("span","num",M(s.total))),n}const m=[{name:"main",url:"http://127.0.0.1:8791"},{name:"backup",url:"http://127.0.0.1:8792"},{name:"dev",url:"http://127.0.0.1:8793"},{name:"staging",url:"http://127.0.0.1:8794"}],P=30,k=["#58a6ff","#3fb950","#a371f7","#d29922","#f85149"];function F(s){if(!s.data)return null;try{return JSON.parse(String(s.data))}catch{return null}}function B(s,e){return{input:s.input+e.input,output:s.output+e.output,reasoning:s.reasoning+e.reasoning,cacheRead:s.cacheRead+e.cacheRead,cacheWrite:s.cacheWrite+e.cacheWrite,total:s.total+e.total}}function E(s,e){s.tabIndex=0,s.addEventListener("click",e),s.addEventListener("keydown",n=>{(n.key==="Enter"||n.key===" ")&&(n.preventDefault(),e())})}class V{constructor(e){this.idx=e,this.base=m[e].url,this.root=t("section","server"),this.root.dataset.url=this.base}idx;overview=null;projects=[];projectDetails=new Map;sessionDetails=new Map;expandedProjects=new Set;expandedSessions=new Set;sessionLimits=new Map;error=null;es=null;liveRef=null;updRef=null;root;base;start(){this.connect(),this.refresh()}touch(){this.updRef&&this.overview&&(this.updRef.textContent=`updated ${U(this.overview.updatedAt)}`)}connect(){this.es?.close();const e=new EventSource(`${v(this.idx)}/stream`);this.es=e;const n=o=>this.onUpdate(F(o));e.addEventListener("update",n),e.onmessage=n,e.onopen=()=>this.setLive(!0),e.onerror=()=>this.setLive(!1)}setLive(e){this.liveRef&&(this.liveRef.textContent=e?"● live":"reconnecting…",this.liveRef.classList.toggle("on",e))}async refresh(){try{const[e,n]=await Promise.all([S.overview(this.idx),S.projects(this.idx)]);this.overview=e,this.projects=n,this.error=null}catch(e){this.error=e instanceof Error?e.message:String(e)}this.render()}async loadProject(e){if(this.expandedProjects.has(e)){try{this.projectDetails.set(e,await S.project(this.idx,e))}catch{this.expandedProjects.delete(e)}this.render()}}async loadSession(e){if(this.expandedSessions.has(e)){try{this.sessionDetails.set(e,await S.session(this.idx,e))}catch{this.expandedSessions.delete(e)}this.render()}}onUpdate(e){if(!e||e.type!=="updated")return;const n=[this.refresh()];for(const o of this.expandedProjects)n.push(this.loadProject(o));for(const o of this.expandedSessions)n.push(this.loadSession(o));Promise.all(n)}render(){if(this.root.replaceChildren(),this.error&&!this.overview){const c=t("div","error");c.append(t("h3","",this.base),t("p","",this.error));const f=t("button","","Retry");f.addEventListener("click",()=>{this.refresh()}),c.appendChild(f),this.root.appendChild(c);return}const e=this.overview,n=t("header","srv-head");n.append(t("h2","srv-host",e?e.host:this.base),t("span","srv-ver",e?`opencode ${e.opencodeVersion}`:""),t("span","srv-url",this.base));const o=t("span","live");this.liveRef=o,this.setLive(this.es?.readyState===EventSource.OPEN);const i=t("span","srv-upd");this.updRef=i,this.touch(),n.append(o,i),this.root.appendChild(n);const r=t("div","stats"),a=R("sessions",e?T(e.mainSessionCount,e.sessionCount):"…");a.title=e?`${e.mainSessionCount} main sessions · ${e.sessionCount} total (incl. subagents)`:"",r.append(a,R("projects",e?String(e.projectCount):"…"));const l=t("div","stat stat-tokens");if(l.appendChild(t("span","lbl","tokens")),e?l.appendChild(w(e.tokens,!0)):l.appendChild(t("div","skeleton","")),r.appendChild(l),r.appendChild(J(e?e.cost:null)),this.root.appendChild(r),this.error&&this.root.appendChild(t("div","error error-bar",`refresh failed: ${this.error}`)),this.root.appendChild(t("h3","sec-head","Projects")),!e){const c=t("table","tbl"),f=t("tbody");for(let C=0;C<3;C++){const g=t("tr","skeleton-row"),$=t("td");$.colSpan=4,$.appendChild(t("div","skeleton")),g.appendChild($),f.appendChild(g)}c.appendChild(f),this.root.appendChild(c);return}if(this.projects.length===0){this.root.appendChild(t("div","empty","No projects found on this host."));return}const p=t("table","tbl"),h=t("thead"),d=t("tr");for(const c of["Project","Sessions","Tokens","Cost"])d.appendChild(t("th","",c));h.appendChild(d),p.appendChild(h);const u=t("tbody");for(const c of this.projects)this.renderProject(u,c);p.appendChild(u),this.root.appendChild(p)}renderProject(e,n){const o=this.expandedProjects.has(n.id),i=t("td","p-name");i.append(t("span","tgl",o?"▾":"▸"),t("span","",n.name));const r=t("td");r.appendChild(w(n.tokens));const a=t("tr","prow"+(o?" open":""));if(a.append(i,G(n),r,t("td","cost",y(n.cost))),E(a,()=>this.toggleProject(n.id)),e.appendChild(a),o){const l=t("tr","pdetail"),p=t("td");p.colSpan=4;const h=this.projectDetails.get(n.id);if(!h)p.appendChild(t("div","muted","Loading…"));else{const d=this.sessionLimits.get(n.id)??P,u=t("div","stree");this.renderSessions(u,h.sessions,d,()=>{this.sessionLimits.set(n.id,d+P),this.render()}),p.appendChild(u)}l.appendChild(p),e.appendChild(l)}}toggleProject(e){this.expandedProjects.has(e)?(this.expandedProjects.delete(e),this.sessionLimits.delete(e),this.render()):(this.expandedProjects.add(e),this.loadProject(e))}renderSessions(e,n,o,i){const r=new Map(n.map(d=>[d.id,d])),a=new Map,l=[];for(const d of n)if(d.parentId&&r.has(d.parentId)){const u=a.get(d.parentId)??[];u.push(d),a.set(d.parentId,u)}else l.push(d);const p=new Map,h=d=>{let u={...d.tokens},c=d.cost;for(const C of a.get(d.id)??[]){const g=h(C);u=B(u,g.tokens),c+=g.cost}const f={tokens:u,cost:c};return p.set(d.id,f),f};for(const d of l)h(d);for(const d of l.slice(0,o))this.renderSession(e,d,0,a,p);if(l.length>o){const d=t("button","more",`Show ${l.length-o} more sessions`);d.addEventListener("click",i),e.appendChild(d)}}renderSession(e,n,o,i,r){const a=i.get(n.id)??[],l=this.expandedSessions.has(n.id),p=r.get(n.id)??{tokens:n.tokens,cost:n.cost},h=t("div","srow"+(l?" open":""));h.style.setProperty("--depth",String(o));const d=t("span","tgl",l?"▾":"▸");if(a.length){const c=t("span","badge",String(a.length));d.append(c)}const u=t("div","s-main");if(u.append(t("span","s-title",n.title||"Untitled session"),t("span","s-meta",`${n.agent} · ${n.model}`)),h.append(d,u,w(p.tokens),t("span","cost",y(p.cost))),E(h,()=>this.toggleSession(n.id)),e.appendChild(h),l){const c=t("div","s-detail");c.style.setProperty("--depth",String(o+1));const f=this.sessionDetails.get(n.id);f?f.models.length===0?c.appendChild(t("div","muted","No token usage recorded")):(c.appendChild(this.modelsTable(f.models)),a.length&&c.appendChild(t("div","muted note","model breakdown counts only this session — row totals include subagents"))):c.appendChild(t("div","muted","Loading…")),e.appendChild(c)}if(l)for(const c of a)this.renderSession(e,c,o+1,i,r)}toggleSession(e){this.expandedSessions.has(e)?(this.expandedSessions.delete(e),this.render()):(this.expandedSessions.add(e),this.loadSession(e))}modelsTable(e){const n=t("table","tbl tbl-models"),o=t("thead"),i=t("tr");for(const a of["Model","Mode","Msgs","Tokens","Cost"])i.appendChild(t("th","",a));o.appendChild(i),n.appendChild(o);const r=t("tbody");for(const a of e){const l=t("td","p-name");l.append(t("span","",a.model),t("span","muted",a.provider));const p=t("td");p.appendChild(w(a.tokens));const h=t("tr");h.append(l,t("td","",a.mode),t("td","num",String(a.messageCount)),p,t("td","cost",y(a.cost))),r.appendChild(h)}return n.appendChild(r),n}}function R(s,e){const n=t("div","stat");return n.append(t("span","num",e),t("span","lbl",s)),n}function T(s,e){return`${s} / ${e}`}function G(s){const e=t("td","num",T(s.mainSessionCount,s.sessionCount));return e.title=`${s.mainSessionCount} main sessions · ${s.sessionCount} total (incl. subagents)`,e}function J(s){const e=t("div","stat stat-cost");return e.append(t("span","num",s===null?"…":y(s)),t("span","lbl","cost")),e}const j=document.getElementById("app");m.length>1&&j.classList.add("multi");const x=[];for(let s=0;s<m.length;s++){const e=new V(s);e.root.style.borderTop=`3px solid ${k[s%k.length]}`,x.push(e),j.appendChild(e.root),e.start()}const L="overall",I=t("nav","tabs"),O=new Map,N=(s,e)=>{const n=t("button","tab",s);n.addEventListener("click",()=>D(e)),I.appendChild(n),O.set(e,n)};N("Overall",L);m.forEach((s,e)=>N(s.name,String(e)));j.before(I);function D(s){const e=s===L;j.classList.toggle("multi",e&&m.length>1),x.forEach((n,o)=>{n.root.hidden=!(e||String(o)===s)});for(const[n,o]of O)o.classList.toggle("active",n===s)}D(L);setInterval(()=>{for(const s of x)s.touch()},3e4);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
:root{--bg: #0d1117;--panel: #161b22;--inset: #21262d;--border: #30363d;--text: #e6edf3;--muted: #8b949e;--accent: #58a6ff;--danger: #f85149;--warn: #d29922;--live: #3fb950;--in: #58a6ff;--out: #3fb950;--rea: #a371f7;--cr: #2f6f78;--cw: #8a6d1f;--mono: ui-monospace, "SF Mono", "Cascadia Code", Menlo, Consolas, monospace}*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);font:14px/1.5 -apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif}.app-head{position:sticky;top:0;z-index:10;display:flex;align-items:center;justify-content:space-between;gap:16px;padding:14px 20px;background:var(--panel);border-bottom:1px solid var(--border)}.app-head h1{margin:0;font-size:16px;font-weight:600;letter-spacing:.01em}.legend{display:flex;gap:14px;font-size:12px;color:var(--muted)}.legend span{display:inline-flex;align-items:center;gap:5px}.dot{width:10px;height:10px;border-radius:3px;display:inline-block}#app{display:flex;flex-direction:column;gap:16px;padding:20px;max-width:1100px;margin:0 auto}#app.multi{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));align-items:start;max-width:1400px}@media(max-width:860px){#app.multi{grid-template-columns:1fr}}.tabs{display:flex;flex-wrap:wrap;gap:4px;max-width:1100px;margin:16px auto 0;padding:0 20px}.tab{background:none;border:1px solid var(--border);border-bottom:none;border-radius:8px 8px 0 0;color:var(--text-dim);font:inherit;font-size:13px;padding:7px 14px;cursor:pointer;max-width:260px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.tab:hover{color:var(--text)}.tab.active{color:var(--text);background:var(--panel);border-color:var(--accent);border-bottom:2px solid var(--accent)}.server{background:var(--panel);border:1px solid var(--border);border-top:3px solid var(--accent);border-radius:10px;padding:16px 20px 20px}.srv-head{display:flex;align-items:baseline;gap:10px;flex-wrap:wrap}.srv-host{margin:0;font-size:18px;font-weight:650}.srv-ver,.srv-url{color:var(--muted);font-size:12px}.srv-url{font-family:var(--mono)}.srv-upd{margin-left:auto;color:var(--muted);font-size:12px}.live{font-size:12px;color:var(--warn)}.live.on{color:var(--live);animation:pulse 2s infinite}@media(prefers-reduced-motion:reduce){.live.on{animation:none}}@keyframes pulse{50%{opacity:.4}}.stats{display:flex;gap:12px;margin:16px 0 8px;flex-wrap:wrap}.stat{background:var(--inset);border-radius:8px;padding:10px 14px;min-width:96px;display:flex;flex-direction:column;gap:2px}.stat .num{font-family:var(--mono);font-size:18px;font-weight:600;font-variant-numeric:tabular-nums}.stat .lbl{color:var(--muted);font-size:11px;text-transform:uppercase;letter-spacing:.05em}.stat-tokens{flex:1;min-width:220px;gap:8px}.sec-head{margin:18px 0 8px;font-size:12px;font-weight:600;text-transform:uppercase;letter-spacing:.06em;color:var(--muted)}.tbl{width:100%;border-collapse:collapse}.tbl th{text-align:left;color:var(--muted);font-size:11px;font-weight:500;text-transform:uppercase;letter-spacing:.04em;padding:6px 8px;border-bottom:1px solid var(--border)}.tbl td{padding:7px 8px;border-bottom:1px solid var(--border);vertical-align:middle}.tbl tbody tr:last-child td{border-bottom:none}.prow{cursor:pointer;transition:background .12s}.prow:hover,.prow:focus-visible{background:#ffffff0a}.prow:focus-visible,.srow:focus-visible{outline:2px solid var(--accent);outline-offset:-2px}.p-name{display:flex;align-items:center;gap:6px}.tgl{width:18px;flex:none;display:inline-flex;align-items:center;color:var(--muted);cursor:pointer;-webkit-user-select:none;user-select:none;font-size:11px}.badge{background:var(--inset);color:var(--muted);border-radius:8px;font-size:10px;padding:1px 5px;margin-left:2px;font-family:var(--mono)}.cost{text-align:right;font-family:var(--mono);font-variant-numeric:tabular-nums;white-space:nowrap}td.cost{text-align:right}.num{font-family:var(--mono);font-variant-numeric:tabular-nums}.muted{color:var(--muted);font-size:12px}.stree{display:flex;flex-direction:column;padding:4px 0}.srow{display:grid;grid-template-columns:18px minmax(0,1fr) 150px 70px;align-items:center;gap:8px;padding:6px 8px;padding-left:calc(8px + var(--depth) * 20px);cursor:pointer;border-radius:6px;transition:background .12s}.srow:hover{background:#ffffff0a}.s-main{display:flex;flex-direction:column;min-width:0}.s-title{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.s-meta{color:var(--muted);font-size:11px}.s-detail{padding-left:calc(8px + var(--depth) * 20px + 18px);margin:4px 0 8px}.note{margin-top:8px}.tbl-models{background:var(--inset);border:1px solid var(--border);border-radius:8px;overflow:hidden}.bar-wrap{width:100%}.bar{display:flex;height:8px;border-radius:2px;overflow:hidden;background:var(--inset)}.hero .bar{height:16px;border-radius:3px}.bar-empty{background:var(--inset)}.seg{height:100%;min-width:2px}.seg-in{background:var(--in)}.seg-out{background:var(--out)}.seg-rea{background:var(--rea)}.seg-cr{background:var(--cr)}.seg-cw{background:var(--cw)}.tcell{display:flex;flex-direction:column;gap:4px;min-width:130px;max-width:200px}.tcell .num{font-family:var(--mono);font-size:12px;color:var(--text);text-align:right;font-variant-numeric:tabular-nums}.tcell.hero{min-width:220px;max-width:320px}.tcell.hero .num{font-size:20px;font-weight:600}.empty{color:var(--muted);padding:16px 8px;text-align:center}.more{align-self:flex-start;margin:8px 0 4px 26px;background:var(--inset);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:5px 12px;font-size:12px;cursor:pointer;transition:border-color .12s,background .12s}.more:hover{border-color:var(--accent)}.skeleton-row td{padding:12px 8px}.skeleton{height:14px;border-radius:4px;background:linear-gradient(90deg,var(--inset) 25%,#2d333b 50%,var(--inset) 75%);background-size:200% 100%;animation:shimmer 1.2s infinite}@keyframes shimmer{to{background-position:-200% 0}}.error{color:var(--danger);padding:8px}.error h3{color:inherit;text-transform:none;letter-spacing:0;margin:0 0 4px;font-size:15px}.error p{margin:0 0 8px;font-size:13px;color:var(--muted)}.error button{background:var(--inset);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:5px 14px;cursor:pointer}.error-bar{background:#f8514914;border:1px solid rgba(248,81,73,.4);border-radius:6px;margin:8px 0;font-size:12px}
|
package/dist/index.html
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>opencode token usage</title>
|
|
7
|
+
<script type="module" crossorigin src="/assets/index-D9vQPXiw.js"></script>
|
|
8
|
+
<link rel="stylesheet" crossorigin href="/assets/index-DsbIuDpC.css">
|
|
9
|
+
</head>
|
|
10
|
+
<body>
|
|
11
|
+
<header class="app-head">
|
|
12
|
+
<h1>opencode token usage</h1>
|
|
13
|
+
<div class="legend">
|
|
14
|
+
<span><i class="dot seg-in"></i>input</span>
|
|
15
|
+
<span><i class="dot seg-out"></i>output</span>
|
|
16
|
+
<span><i class="dot seg-rea"></i>reasoning</span>
|
|
17
|
+
<span><i class="dot seg-cr"></i>cache read</span>
|
|
18
|
+
<span><i class="dot seg-cw"></i>cache write</span>
|
|
19
|
+
</div>
|
|
20
|
+
</header>
|
|
21
|
+
<main id="app"></main>
|
|
22
|
+
</body>
|
|
23
|
+
</html>
|
package/package.json
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "opencode-dashboard-client",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Front-end for the opencode token-usage dashboard. Ships the built SPA plus the front-end server (bun server.ts) that proxies to dashboard backends.",
|
|
6
|
+
"files": [
|
|
7
|
+
"dist",
|
|
8
|
+
"server.ts",
|
|
9
|
+
"src/config.ts"
|
|
10
|
+
],
|
|
11
|
+
"publishConfig": {
|
|
12
|
+
"access": "public"
|
|
13
|
+
},
|
|
14
|
+
"scripts": {
|
|
15
|
+
"dev": "vite",
|
|
16
|
+
"build": "tsc && vite build",
|
|
17
|
+
"preview": "vite preview",
|
|
18
|
+
"mock": "bun mock-server.ts",
|
|
19
|
+
"prepublishOnly": "npm run build"
|
|
20
|
+
},
|
|
21
|
+
"devDependencies": {
|
|
22
|
+
"typescript": "^5.7.0",
|
|
23
|
+
"vite": "^7.0.0"
|
|
24
|
+
}
|
|
25
|
+
}
|
package/server.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// Front-end server: single entry point for clients. Serves dist/ and proxies
|
|
2
|
+
// /api/s/{i}/* to servers[i].url — same route scheme as vite.config.ts, so
|
|
3
|
+
// clients never reach the real backends (they may be unreachable from the
|
|
4
|
+
// client's network). Run: bun server.ts (PORT env, default 5173)
|
|
5
|
+
import { serve } from "bun";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { servers } from "./src/config";
|
|
8
|
+
|
|
9
|
+
const DIST = join(import.meta.dir, "dist");
|
|
10
|
+
const PORT = Number(process.env.PORT ?? 5173);
|
|
11
|
+
|
|
12
|
+
const MIME: Record<string, string> = {
|
|
13
|
+
".html": "text/html; charset=utf-8",
|
|
14
|
+
".js": "text/javascript",
|
|
15
|
+
".css": "text/css",
|
|
16
|
+
".svg": "image/svg+xml",
|
|
17
|
+
".json": "application/json",
|
|
18
|
+
".png": "image/png",
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const file = async (path: string) => {
|
|
22
|
+
const f = Bun.file(join(DIST, path));
|
|
23
|
+
return (await f.exists()) ? f : null;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
serve({
|
|
27
|
+
port: PORT,
|
|
28
|
+
async fetch(req) {
|
|
29
|
+
const url = new URL(req.url);
|
|
30
|
+
|
|
31
|
+
const m = url.pathname.match(/^\/api\/s\/(\d+)(\/.*)?$/);
|
|
32
|
+
if (m) {
|
|
33
|
+
const target = servers[Number(m[1])];
|
|
34
|
+
if (!target) return new Response("unknown server", { status: 404 });
|
|
35
|
+
const res = await fetch(target.url + (m[2] ?? "") + url.search, req);
|
|
36
|
+
return new Response(res.body, { status: res.status, headers: res.headers });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const path = url.pathname === "/" ? "/index.html" : url.pathname;
|
|
40
|
+
const f = await file(path);
|
|
41
|
+
if (f) {
|
|
42
|
+
const ext = path.slice(path.lastIndexOf("."));
|
|
43
|
+
return new Response(f, { headers: { "content-type": MIME[ext] ?? "application/octet-stream" } });
|
|
44
|
+
}
|
|
45
|
+
const idx = await file("index.html"); // SPA fallback
|
|
46
|
+
if (idx) return new Response(idx, { headers: { "content-type": MIME[".html"] } });
|
|
47
|
+
return new Response("not found", { status: 404 });
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
console.log(`front-end server http://localhost:${PORT} (${servers.length} backends proxied)`);
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export interface ServerConfig {
|
|
2
|
+
name: string;
|
|
3
|
+
url: string;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export const servers: ServerConfig[] = [
|
|
7
|
+
{ name: "main", url: "http://127.0.0.1:8791" },
|
|
8
|
+
{ name: "backup", url: "http://127.0.0.1:8792" },
|
|
9
|
+
{ name: "dev", url: "http://127.0.0.1:8793" },
|
|
10
|
+
{ name: "staging", url: "http://127.0.0.1:8794" },
|
|
11
|
+
];
|