first commit

This commit is contained in:
feie9456 2026-01-05 11:10:59 +08:00
commit 744d1b65f8
10 changed files with 1446 additions and 0 deletions

36
.gitignore vendored Normal file
View File

@ -0,0 +1,36 @@
# dependencies (bun install)
node_modules
# output
out
dist
*.tgz
# code coverage
coverage
*.lcov
# logs
logs
_.log
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# caches
.eslintcache
.cache
*.tsbuildinfo
# IntelliJ based IDEs
.idea
# Finder (MacOS) folder config
.DS_Store
dist

15
README.md Normal file
View File

@ -0,0 +1,15 @@
# sound-speed-determination-server
To install dependencies:
```bash
bun install
```
To run:
```bash
bun run index.ts
```
This project was created using `bun init` in bun v1.3.0. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime.

663
admin.html Normal file
View File

@ -0,0 +1,663 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>管理后台 - 声速测定</title>
<style>
:root {
color-scheme: light;
--bg: #ffffff;
--fg: #111827;
--muted: #6b7280;
--border: #e5e7eb;
--panel: #f9fafb;
--danger: #b91c1c;
--ok: #065f46;
--accent: #2563eb;
--mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
--sans: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji",
"Segoe UI Emoji";
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: var(--sans);
background: var(--bg);
color: var(--fg);
}
header {
position: sticky;
top: 0;
z-index: 10;
background: var(--bg);
border-bottom: 1px solid var(--border);
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 12px 16px;
}
.row {
display: flex;
gap: 12px;
flex-wrap: wrap;
align-items: center;
}
.spacer {
flex: 1;
}
input,
select,
button {
font: inherit;
padding: 8px 10px;
border: 1px solid var(--border);
border-radius: 8px;
background: #fff;
color: var(--fg);
}
button {
cursor: pointer;
}
button.primary {
border-color: var(--accent);
background: var(--accent);
color: #fff;
}
button.ghost {
background: #fff;
}
.badge {
font-size: 12px;
padding: 3px 8px;
border-radius: 999px;
border: 1px solid var(--border);
background: var(--panel);
color: var(--muted);
}
.badge.ok {
color: var(--ok);
border-color: rgba(6, 95, 70, 0.25);
background: rgba(6, 95, 70, 0.06);
}
.badge.err {
color: var(--danger);
border-color: rgba(185, 28, 28, 0.25);
background: rgba(185, 28, 28, 0.06);
}
main {
max-width: 1200px;
margin: 0 auto;
padding: 16px;
display: grid;
grid-template-columns: 1.2fr 0.8fr;
gap: 16px;
}
@media (max-width: 1000px) {
main {
grid-template-columns: 1fr;
}
}
.panel {
border: 1px solid var(--border);
border-radius: 12px;
background: var(--panel);
}
.panel .panel-hd {
padding: 12px 12px 8px;
border-bottom: 1px solid var(--border);
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
.panel .panel-bd {
padding: 12px;
}
h2 {
font-size: 14px;
margin: 0;
color: var(--muted);
letter-spacing: 0.02em;
text-transform: uppercase;
}
.tabs {
display: inline-flex;
gap: 6px;
}
.tab {
padding: 8px 10px;
border-radius: 10px;
border: 1px solid var(--border);
background: #fff;
}
.tab.active {
border-color: rgba(37, 99, 235, 0.35);
background: rgba(37, 99, 235, 0.06);
color: var(--accent);
}
table {
width: 100%;
border-collapse: collapse;
background: #fff;
border: 1px solid var(--border);
border-radius: 10px;
overflow: hidden;
}
th,
td {
text-align: left;
padding: 10px 10px;
border-bottom: 1px solid var(--border);
vertical-align: top;
font-size: 13px;
}
th {
color: var(--muted);
font-weight: 600;
background: #fff;
}
tr:hover td {
background: rgba(37, 99, 235, 0.04);
}
.mono {
font-family: var(--mono);
font-size: 12px;
word-break: break-all;
}
.small {
font-size: 12px;
color: var(--muted);
}
.kv {
display: grid;
grid-template-columns: 160px 1fr;
gap: 8px;
font-size: 13px;
}
pre {
margin: 0;
padding: 12px;
background: #0b1220;
color: #e5e7eb;
border-radius: 10px;
overflow: auto;
font-family: var(--mono);
font-size: 12px;
line-height: 1.45;
max-height: 70vh;
}
.footer-row {
display: flex;
gap: 8px;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
}
.linkbtn {
border: 0;
background: transparent;
color: var(--accent);
padding: 0;
cursor: pointer;
}
.error {
color: var(--danger);
}
</style>
</head>
<body>
<header>
<div class="container">
<div class="row">
<strong>声速测定 - 管理后台</strong>
<span id="status" class="badge">未连接</span>
<div class="spacer"></div>
<label class="small">ADMIN_TOKEN</label>
<input id="token" style="min-width: 260px" placeholder="粘贴 ADMIN_TOKEN" />
<button id="saveToken" class="primary">保存</button>
<button id="clearToken" class="ghost">清除</button>
<button id="refresh" class="ghost">刷新</button>
</div>
<div class="row" style="margin-top: 10px">
<div class="tabs">
<button id="tabTracker" class="tab active">Tracker</button>
<button id="tabRecord" class="tab">Record</button>
</div>
<span id="stats" class="small"></span>
</div>
</div>
</header>
<main>
<section class="panel">
<div class="panel-hd">
<h2 id="listTitle">Tracker 列表</h2>
<div class="spacer"></div>
<label class="small">From</label>
<input id="from" type="datetime-local" />
<label class="small">To</label>
<input id="to" type="datetime-local" />
</div>
<div class="panel-bd">
<div class="row" id="filtersTracker">
<input id="fSession" placeholder="sessionId模糊" style="min-width: 220px" />
<input id="fEvent" placeholder="event模糊" style="min-width: 180px" />
<input id="fStep" placeholder="step模糊" style="min-width: 180px" />
<select id="pageSize">
<option value="20">20 / 页</option>
<option value="50" selected>50 / 页</option>
<option value="100">100 / 页</option>
<option value="200">200 / 页</option>
</select>
<button id="apply" class="primary">查询</button>
<button id="reset" class="ghost">重置</button>
</div>
<div class="row" id="filtersRecord" style="display: none">
<input id="rSession" placeholder="sessionId模糊" style="min-width: 220px" />
<select id="rNotes">
<option value="">notes全部</option>
<option value="has">notes</option>
<option value="empty">notes</option>
</select>
<select id="rPageSize">
<option value="20">20 / 页</option>
<option value="50" selected>50 / 页</option>
<option value="100">100 / 页</option>
<option value="200">200 / 页</option>
</select>
<button id="rApply" class="primary">查询</button>
<button id="rReset" class="ghost">重置</button>
</div>
<div id="error" class="small error" style="margin: 10px 0"></div>
<div style="overflow: auto">
<table id="table">
<thead>
<tr id="thead"></tr>
</thead>
<tbody id="tbody"></tbody>
</table>
</div>
<div class="footer-row" style="margin-top: 10px">
<div class="small" id="pagerInfo"></div>
<div class="row">
<button id="prev" class="ghost">上一页</button>
<button id="next" class="ghost">下一页</button>
</div>
</div>
</div>
</section>
<aside class="panel">
<div class="panel-hd">
<h2>详情</h2>
<div class="spacer"></div>
<button id="copy" class="ghost">复制 JSON</button>
<button id="download" class="ghost">下载 JSON</button>
</div>
<div class="panel-bd">
<div class="kv" style="margin-bottom: 10px">
<div class="small">当前选中</div>
<div class="mono" id="selectedId"></div>
</div>
<pre id="detail">选择左侧某一行查看详情</pre>
</div>
</aside>
</main>
<script type="module">
const $ = (id) => document.getElementById(id);
const state = {
mode: 'tracker',
page: 1,
total: 0,
pageSize: 50,
selected: null,
};
function setStatus(ok, text) {
const el = $('status');
el.textContent = text;
el.className = 'badge ' + (ok ? 'ok' : 'err');
}
function getToken() {
return (localStorage.getItem('ADMIN_TOKEN') || '').trim();
}
function setToken(v) {
localStorage.setItem('ADMIN_TOKEN', (v || '').trim());
}
async function api(path, params = undefined) {
const token = getToken();
if (!token) throw new Error('未设置 ADMIN_TOKEN');
let url = path;
if (params) {
const u = new URL(location.origin + path);
for (const [k, v] of Object.entries(params)) {
if (v === undefined || v === null || v === '') continue;
u.searchParams.set(k, String(v));
}
url = u.pathname + u.search;
}
const res = await fetch(url, {
headers: {
Authorization: 'Bearer ' + token,
},
});
const json = await res.json().catch(() => ({}));
if (!res.ok || !json.ok) {
throw new Error(json?.error || ('请求失败:' + res.status));
}
return json;
}
function toISOFromLocal(value) {
if (!value) return '';
// datetime-local 输出不带时区;按本地时间构造 Date
const d = new Date(value);
if (Number.isNaN(d.getTime())) return '';
return d.toISOString();
}
function setDetail(obj, id) {
state.selected = obj;
$('selectedId').textContent = id || '—';
$('detail').textContent = obj ? JSON.stringify(obj, null, 2) : '—';
}
function setError(msg) {
$('error').textContent = msg || '';
}
function renderTableHead() {
const thead = $('thead');
thead.innerHTML = '';
const cols =
state.mode === 'tracker'
?
['createdAt', 'event', 'step', 'sessionId', 'ip', 'id']
:
['createdAt', 'sessionId', 'notes', 'ip', 'id'];
for (const c of cols) {
const th = document.createElement('th');
th.textContent = c;
thead.appendChild(th);
}
}
function renderRows(rows) {
const tbody = $('tbody');
tbody.innerHTML = '';
for (const r of rows) {
const tr = document.createElement('tr');
tr.style.cursor = 'pointer';
tr.addEventListener('click', async () => {
try {
setError('');
const detail =
state.mode === 'tracker'
? await api('/api/admin/tracker/' + r.id)
: await api('/api/admin/record/' + r.id);
setDetail(detail.row, r.id);
} catch (e) {
setError(e?.message || String(e));
}
});
const cols =
state.mode === 'tracker'
?
[
new Date(r.createdAt).toLocaleString(),
r.event || '',
r.step || '',
r.sessionId || '',
r.ip || '',
r.id,
]
:
[
new Date(r.createdAt).toLocaleString(),
r.sessionId || '',
r.notes || '',
r.ip || '',
r.id,
];
for (let i = 0; i < cols.length; i++) {
const td = document.createElement('td');
const v = cols[i];
if (i === cols.length - 1) td.className = 'mono';
td.textContent = v;
tr.appendChild(td);
}
tbody.appendChild(tr);
}
}
function renderPager() {
const totalPages = Math.max(1, Math.ceil(state.total / state.pageSize));
$('pagerInfo').textContent = `第 ${state.page} / ${totalPages} 页,共 ${state.total} 条`;
$('prev').disabled = state.page <= 1;
$('next').disabled = state.page >= totalPages;
}
async function refreshStats() {
try {
const s = await api('/api/admin/stats');
setStatus(true, '已连接');
$('stats').textContent = `Tracker总 ${s.tracker.total}24h ${s.tracker.last24h}7d ${s.tracker.last7d} | Record总 ${s.record.total}24h ${s.record.last24h}7d ${s.record.last7d}`;
} catch (e) {
setStatus(false, '未连接');
$('stats').textContent = '';
throw e;
}
}
async function loadList() {
setError('');
renderTableHead();
const from = toISOFromLocal($('from').value);
const to = toISOFromLocal($('to').value);
if (state.mode === 'tracker') {
state.pageSize = Number($('pageSize').value || 50);
const params = {
page: state.page,
pageSize: state.pageSize,
from,
to,
sessionId: $('fSession').value.trim(),
event: $('fEvent').value.trim(),
step: $('fStep').value.trim(),
};
const data = await api('/api/admin/tracker', params);
state.total = data.total;
renderRows(data.rows);
renderPager();
$('listTitle').textContent = 'Tracker 列表';
} else {
state.pageSize = Number($('rPageSize').value || 50);
const params = {
page: state.page,
pageSize: state.pageSize,
from,
to,
sessionId: $('rSession').value.trim(),
notes: $('rNotes').value,
};
const data = await api('/api/admin/record', params);
state.total = data.total;
renderRows(data.rows);
renderPager();
$('listTitle').textContent = 'Record 列表';
}
}
function switchMode(mode) {
state.mode = mode;
state.page = 1;
state.total = 0;
setDetail(null, null);
$('tabTracker').classList.toggle('active', mode === 'tracker');
$('tabRecord').classList.toggle('active', mode === 'record');
$('filtersTracker').style.display = mode === 'tracker' ? '' : 'none';
$('filtersRecord').style.display = mode === 'record' ? '' : 'none';
renderTableHead();
$('tbody').innerHTML = '';
$('pagerInfo').textContent = '—';
}
$('saveToken').addEventListener('click', async () => {
setToken($('token').value);
try {
await refreshStats();
await loadList();
} catch (e) {
setError(e?.message || String(e));
}
});
$('clearToken').addEventListener('click', () => {
setToken('');
$('token').value = '';
setStatus(false, '未连接');
$('stats').textContent = '';
setError('已清除 token');
});
$('refresh').addEventListener('click', async () => {
try {
await refreshStats();
await loadList();
} catch (e) {
setError(e?.message || String(e));
}
});
$('apply').addEventListener('click', async () => {
state.page = 1;
try {
await loadList();
} catch (e) {
setError(e?.message || String(e));
}
});
$('reset').addEventListener('click', async () => {
$('fSession').value = '';
$('fEvent').value = '';
$('fStep').value = '';
$('from').value = '';
$('to').value = '';
state.page = 1;
try {
await loadList();
} catch (e) {
setError(e?.message || String(e));
}
});
$('rApply').addEventListener('click', async () => {
state.page = 1;
try {
await loadList();
} catch (e) {
setError(e?.message || String(e));
}
});
$('rReset').addEventListener('click', async () => {
$('rSession').value = '';
$('rNotes').value = '';
$('from').value = '';
$('to').value = '';
state.page = 1;
try {
await loadList();
} catch (e) {
setError(e?.message || String(e));
}
});
$('prev').addEventListener('click', async () => {
state.page = Math.max(1, state.page - 1);
try {
await loadList();
} catch (e) {
setError(e?.message || String(e));
}
});
$('next').addEventListener('click', async () => {
const totalPages = Math.max(1, Math.ceil(state.total / state.pageSize));
state.page = Math.min(totalPages, state.page + 1);
try {
await loadList();
} catch (e) {
setError(e?.message || String(e));
}
});
$('tabTracker').addEventListener('click', () => switchMode('tracker'));
$('tabRecord').addEventListener('click', () => switchMode('record'));
$('copy').addEventListener('click', async () => {
try {
if (!state.selected) throw new Error('没有可复制的内容');
await navigator.clipboard.writeText(JSON.stringify(state.selected, null, 2));
setError('已复制到剪贴板');
} catch (e) {
setError(e?.message || String(e));
}
});
$('download').addEventListener('click', () => {
try {
if (!state.selected) throw new Error('没有可下载的内容');
const blob = new Blob([JSON.stringify(state.selected, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
const id = $('selectedId').textContent || 'row';
a.href = url;
a.download = `${state.mode}-${id}.json`;
a.click();
URL.revokeObjectURL(url);
} catch (e) {
setError(e?.message || String(e));
}
});
// init
$('token').value = getToken();
switchMode('tracker');
(async () => {
try {
if (getToken()) {
await refreshStats();
await loadList();
} else {
setStatus(false, '未连接');
setError('请先在顶部设置 ADMIN_TOKEN');
}
} catch (e) {
setError(e?.message || String(e));
}
})();
</script>
</body>
</html>

263
bun.lock Normal file
View File

@ -0,0 +1,263 @@
{
"lockfileVersion": 1,
"workspaces": {
"": {
"name": "sound-speed-determination-server",
"dependencies": {
"@prisma/client": "^6.0.0",
"cors": "^2.8.5",
"express": "^4.19.2",
},
"devDependencies": {
"@types/bun": "latest",
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"prisma": "^6.0.0",
},
"peerDependencies": {
"typescript": "^5",
},
},
},
"packages": {
"@prisma/client": ["@prisma/client@6.19.1", "", { "peerDependencies": { "prisma": "*", "typescript": ">=5.1.0" }, "optionalPeers": ["prisma", "typescript"] }, "sha512-4SXj4Oo6HyQkLUWT8Ke5R0PTAfVOKip5Roo+6+b2EDTkFg5be0FnBWiuRJc0BC0sRQIWGMLKW1XguhVfW/z3/A=="],
"@prisma/config": ["@prisma/config@6.19.1", "", { "dependencies": { "c12": "3.1.0", "deepmerge-ts": "7.1.5", "effect": "3.18.4", "empathic": "2.0.0" } }, "sha512-bUL/aYkGXLwxVGhJmQMtslLT7KPEfUqmRa919fKI4wQFX4bIFUKiY8Jmio/2waAjjPYrtuDHa7EsNCnJTXxiOw=="],
"@prisma/debug": ["@prisma/debug@6.19.1", "", {}, "sha512-h1JImhlAd/s5nhY/e9qkAzausWldbeT+e4nZF7A4zjDYBF4BZmKDt4y0jK7EZapqOm1kW7V0e9agV/iFDy3fWw=="],
"@prisma/engines": ["@prisma/engines@6.19.1", "", { "dependencies": { "@prisma/debug": "6.19.1", "@prisma/engines-version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7", "@prisma/fetch-engine": "6.19.1", "@prisma/get-platform": "6.19.1" } }, "sha512-xy95dNJ7DiPf9IJ3oaVfX785nbFl7oNDzclUF+DIiJw6WdWCvPl0LPU0YqQLsrwv8N64uOQkH391ujo3wSo+Nw=="],
"@prisma/engines-version": ["@prisma/engines-version@7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7", "", {}, "sha512-03bgb1VD5gvuumNf+7fVGBzfpJPjmqV423l/WxsWk2cNQ42JD0/SsFBPhN6z8iAvdHs07/7ei77SKu7aZfq8bA=="],
"@prisma/fetch-engine": ["@prisma/fetch-engine@6.19.1", "", { "dependencies": { "@prisma/debug": "6.19.1", "@prisma/engines-version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7", "@prisma/get-platform": "6.19.1" } }, "sha512-mmgcotdaq4VtAHO6keov3db+hqlBzQS6X7tR7dFCbvXjLVTxBYdSJFRWz+dq7F9p6dvWyy1X0v8BlfRixyQK6g=="],
"@prisma/get-platform": ["@prisma/get-platform@6.19.1", "", { "dependencies": { "@prisma/debug": "6.19.1" } }, "sha512-zsg44QUiQAnFUyh6Fbt7c9HjMXHwFTqtrgcX7DAZmRgnkPyYT7Sh8Mn8D5PuuDYNtMOYcpLGg576MLfIORsBYw=="],
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@types/body-parser": ["@types/body-parser@1.19.6", "", { "dependencies": { "@types/connect": "*", "@types/node": "*" } }, "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g=="],
"@types/bun": ["@types/bun@1.3.5", "", { "dependencies": { "bun-types": "1.3.5" } }, "sha512-RnygCqNrd3srIPEWBd5LFeUYG7plCoH2Yw9WaZGyNmdTEei+gWaHqydbaIRkIkcbXwhBT94q78QljxN0Sk838w=="],
"@types/connect": ["@types/connect@3.4.38", "", { "dependencies": { "@types/node": "*" } }, "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug=="],
"@types/cors": ["@types/cors@2.8.19", "", { "dependencies": { "@types/node": "*" } }, "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg=="],
"@types/express": ["@types/express@4.17.25", "", { "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^4.17.33", "@types/qs": "*", "@types/serve-static": "^1" } }, "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw=="],
"@types/express-serve-static-core": ["@types/express-serve-static-core@4.19.7", "", { "dependencies": { "@types/node": "*", "@types/qs": "*", "@types/range-parser": "*", "@types/send": "*" } }, "sha512-FvPtiIf1LfhzsaIXhv/PHan/2FeQBbtBDtfX2QfvPxdUelMDEckK08SM6nqo1MIZY3RUlfA+HV8+hFUSio78qg=="],
"@types/http-errors": ["@types/http-errors@2.0.5", "", {}, "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg=="],
"@types/mime": ["@types/mime@1.3.5", "", {}, "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w=="],
"@types/node": ["@types/node@25.0.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA=="],
"@types/qs": ["@types/qs@6.14.0", "", {}, "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ=="],
"@types/range-parser": ["@types/range-parser@1.2.7", "", {}, "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ=="],
"@types/send": ["@types/send@0.17.6", "", { "dependencies": { "@types/mime": "^1", "@types/node": "*" } }, "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og=="],
"@types/serve-static": ["@types/serve-static@1.15.10", "", { "dependencies": { "@types/http-errors": "*", "@types/node": "*", "@types/send": "<1" } }, "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw=="],
"accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="],
"array-flatten": ["array-flatten@1.1.1", "", {}, "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg=="],
"body-parser": ["body-parser@1.20.4", "", { "dependencies": { "bytes": "~3.1.2", "content-type": "~1.0.5", "debug": "2.6.9", "depd": "2.0.0", "destroy": "~1.2.0", "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "on-finished": "~2.4.1", "qs": "~6.14.0", "raw-body": "~2.5.3", "type-is": "~1.6.18", "unpipe": "~1.0.0" } }, "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA=="],
"bun-types": ["bun-types@1.3.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-inmAYe2PFLs0SUbFOWSVD24sg1jFlMPxOjOSSCYqUgn4Hsc3rDc7dFvfVYjFPNHtov6kgUeulV4SxbuIV/stPw=="],
"bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
"c12": ["c12@3.1.0", "", { "dependencies": { "chokidar": "^4.0.3", "confbox": "^0.2.2", "defu": "^6.1.4", "dotenv": "^16.6.1", "exsolve": "^1.0.7", "giget": "^2.0.0", "jiti": "^2.4.2", "ohash": "^2.0.11", "pathe": "^2.0.3", "perfect-debounce": "^1.0.0", "pkg-types": "^2.2.0", "rc9": "^2.1.2" }, "peerDependencies": { "magicast": "^0.3.5" }, "optionalPeers": ["magicast"] }, "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw=="],
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
"call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="],
"chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
"citty": ["citty@0.1.6", "", { "dependencies": { "consola": "^3.2.3" } }, "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ=="],
"confbox": ["confbox@0.2.2", "", {}, "sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ=="],
"consola": ["consola@3.4.2", "", {}, "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA=="],
"content-disposition": ["content-disposition@0.5.4", "", { "dependencies": { "safe-buffer": "5.2.1" } }, "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ=="],
"content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="],
"cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
"cookie-signature": ["cookie-signature@1.0.7", "", {}, "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA=="],
"cors": ["cors@2.8.5", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g=="],
"debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
"deepmerge-ts": ["deepmerge-ts@7.1.5", "", {}, "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw=="],
"defu": ["defu@6.1.4", "", {}, "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg=="],
"depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="],
"destr": ["destr@2.0.5", "", {}, "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA=="],
"destroy": ["destroy@1.2.0", "", {}, "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg=="],
"dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="],
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
"effect": ["effect@3.18.4", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "fast-check": "^3.23.1" } }, "sha512-b1LXQJLe9D11wfnOKAk3PKxuqYshQ0Heez+y5pnkd3jLj1yx9QhM72zZ9uUrOQyNvrs2GZZd/3maL0ZV18YuDA=="],
"empathic": ["empathic@2.0.0", "", {}, "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA=="],
"encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="],
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
"es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
"escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
"etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="],
"express": ["express@4.22.1", "", { "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", "body-parser": "~1.20.3", "content-disposition": "~0.5.4", "content-type": "~1.0.4", "cookie": "~0.7.1", "cookie-signature": "~1.0.6", "debug": "2.6.9", "depd": "2.0.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "finalhandler": "~1.3.1", "fresh": "~0.5.2", "http-errors": "~2.0.0", "merge-descriptors": "1.0.3", "methods": "~1.1.2", "on-finished": "~2.4.1", "parseurl": "~1.3.3", "path-to-regexp": "~0.1.12", "proxy-addr": "~2.0.7", "qs": "~6.14.0", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", "send": "~0.19.0", "serve-static": "~1.16.2", "setprototypeof": "1.2.0", "statuses": "~2.0.1", "type-is": "~1.6.18", "utils-merge": "1.0.1", "vary": "~1.1.2" } }, "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g=="],
"exsolve": ["exsolve@1.0.8", "", {}, "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA=="],
"fast-check": ["fast-check@3.23.2", "", { "dependencies": { "pure-rand": "^6.1.0" } }, "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A=="],
"finalhandler": ["finalhandler@1.3.2", "", { "dependencies": { "debug": "2.6.9", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "on-finished": "~2.4.1", "parseurl": "~1.3.3", "statuses": "~2.0.2", "unpipe": "~1.0.0" } }, "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg=="],
"forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="],
"fresh": ["fresh@0.5.2", "", {}, "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q=="],
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
"giget": ["giget@2.0.0", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.0", "defu": "^6.1.4", "node-fetch-native": "^1.6.6", "nypm": "^0.6.0", "pathe": "^2.0.3" }, "bin": { "giget": "dist/cli.mjs" } }, "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA=="],
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
"has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
"http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="],
"iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="],
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
"ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
"media-typer": ["media-typer@0.3.0", "", {}, "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ=="],
"merge-descriptors": ["merge-descriptors@1.0.3", "", {}, "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ=="],
"methods": ["methods@1.1.2", "", {}, "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w=="],
"mime": ["mime@1.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="],
"mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
"mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
"ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
"negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="],
"node-fetch-native": ["node-fetch-native@1.6.7", "", {}, "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="],
"nypm": ["nypm@0.6.2", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.2", "pathe": "^2.0.3", "pkg-types": "^2.3.0", "tinyexec": "^1.0.1" }, "bin": { "nypm": "dist/cli.mjs" } }, "sha512-7eM+hpOtrKrBDCh7Ypu2lJ9Z7PNZBdi/8AT3AX8xoCj43BBVHD0hPSTEvMtkMpfs8FCqBGhxB+uToIQimA111g=="],
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
"ohash": ["ohash@2.0.11", "", {}, "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ=="],
"on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="],
"parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
"path-to-regexp": ["path-to-regexp@0.1.12", "", {}, "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ=="],
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
"perfect-debounce": ["perfect-debounce@1.0.0", "", {}, "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA=="],
"pkg-types": ["pkg-types@2.3.0", "", { "dependencies": { "confbox": "^0.2.2", "exsolve": "^1.0.7", "pathe": "^2.0.3" } }, "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig=="],
"prisma": ["prisma@6.19.1", "", { "dependencies": { "@prisma/config": "6.19.1", "@prisma/engines": "6.19.1" }, "peerDependencies": { "typescript": ">=5.1.0" }, "optionalPeers": ["typescript"], "bin": { "prisma": "build/index.js" } }, "sha512-XRfmGzh6gtkc/Vq3LqZJcS2884dQQW3UhPo6jNRoiTW95FFQkXFg8vkYEy6og+Pyv0aY7zRQ7Wn1Cvr56XjhQQ=="],
"proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="],
"pure-rand": ["pure-rand@6.1.0", "", {}, "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA=="],
"qs": ["qs@6.14.1", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ=="],
"range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="],
"raw-body": ["raw-body@2.5.3", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "unpipe": "~1.0.0" } }, "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA=="],
"rc9": ["rc9@2.1.2", "", { "dependencies": { "defu": "^6.1.4", "destr": "^2.0.3" } }, "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg=="],
"readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
"send": ["send@0.19.2", "", { "dependencies": { "debug": "2.6.9", "depd": "2.0.0", "destroy": "1.2.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "fresh": "~0.5.2", "http-errors": "~2.0.1", "mime": "1.6.0", "ms": "2.1.3", "on-finished": "~2.4.1", "range-parser": "~1.2.1", "statuses": "~2.0.2" } }, "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg=="],
"serve-static": ["serve-static@1.16.3", "", { "dependencies": { "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "parseurl": "~1.3.3", "send": "~0.19.1" } }, "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA=="],
"setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="],
"side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="],
"side-channel-list": ["side-channel-list@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="],
"side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="],
"side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
"statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
"tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="],
"toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
"type-is": ["type-is@1.6.18", "", { "dependencies": { "media-typer": "0.3.0", "mime-types": "~2.1.24" } }, "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
"unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="],
"utils-merge": ["utils-merge@1.0.1", "", {}, "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA=="],
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
"send/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
}
}

342
index.ts Normal file
View File

@ -0,0 +1,342 @@
import express from 'express';
import cors from 'cors';
import { PrismaClient } from '@prisma/client';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const prisma = new PrismaClient();
const app = express();
// dev 环境下允许跨域,便于前端本地调试
app.use(cors({ origin: true, credentials: true }));
app.use(express.json({ limit: '2mb' }));
// =========================
// 静态文件托管前端打包产物Vite dist
// =========================
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// 可通过环境变量覆盖STATIC_DIR=D:\path\to\dist
const staticDir = process.env.STATIC_DIR
? path.resolve(process.env.STATIC_DIR)
: path.resolve(__dirname, 'dist');
app.get('/health', (_req, res) => {
res.status(200).json({ ok: true });
});
// =========================
// Admin管理页面 + 管理 API
// =========================
const adminToken = (process.env.ADMIN_TOKEN ?? '').trim();
const requireAdmin: express.RequestHandler = (req, res, next) => {
if (!adminToken) {
res
.status(500)
.json({ ok: false, error: 'ADMIN_TOKEN is not set on server. Set env ADMIN_TOKEN to enable admin.' });
return;
}
const auth = typeof req.headers.authorization === 'string' ? req.headers.authorization : '';
const bearer = auth.startsWith('Bearer ') ? auth.slice('Bearer '.length).trim() : '';
const headerToken = typeof req.headers['x-admin-token'] === 'string' ? req.headers['x-admin-token'].trim() : '';
const queryToken = typeof req.query.token === 'string' ? req.query.token.trim() : '';
const token = bearer || headerToken || queryToken;
if (!token || token !== adminToken) {
res.status(401).json({ ok: false, error: 'unauthorized' });
return;
}
next();
};
const toInt = (v: unknown, fallback: number) => {
const n = typeof v === 'string' ? Number(v) : typeof v === 'number' ? v : NaN;
return Number.isFinite(n) ? Math.trunc(n) : fallback;
};
const toDate = (v: unknown): Date | null => {
if (typeof v !== 'string' || !v.trim()) return null;
const d = new Date(v);
return Number.isNaN(d.getTime()) ? null : d;
};
const safeContains = (v: unknown) => (typeof v === 'string' && v.trim() ? v.trim() : null);
app.get('/admin', (req, res) => {
// 直接返回静态 HTML不依赖前端构建并复用 requireAdmin 的 token 逻辑
// 这里不强制 requireAdmin避免每次打开都要手动带 header页面内会用 token 访问管理 API
res.sendFile(path.join(__dirname, 'admin.html'));
});
app.get('/api/admin/stats', requireAdmin, async (_req, res) => {
const now = new Date();
const d24h = new Date(now.getTime() - 24 * 60 * 60 * 1000);
const d7d = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
try {
const [trackerTotal, recordTotal, tracker24h, record24h, tracker7d, record7d] = await Promise.all([
prisma.trackerEvent.count(),
prisma.recordSubmission.count(),
prisma.trackerEvent.count({ where: { createdAt: { gte: d24h } } }),
prisma.recordSubmission.count({ where: { createdAt: { gte: d24h } } }),
prisma.trackerEvent.count({ where: { createdAt: { gte: d7d } } }),
prisma.recordSubmission.count({ where: { createdAt: { gte: d7d } } }),
]);
res.status(200).json({
ok: true,
tracker: { total: trackerTotal, last24h: tracker24h, last7d: tracker7d },
record: { total: recordTotal, last24h: record24h, last7d: record7d },
});
} catch (e: any) {
res.status(500).json({ ok: false, error: String(e?.message ?? e) });
}
});
app.get('/api/admin/tracker', requireAdmin, async (req, res) => {
const page = Math.max(1, toInt(req.query.page, 1));
const pageSize = Math.min(200, Math.max(1, toInt(req.query.pageSize, 50)));
const skip = (page - 1) * pageSize;
const sessionId = safeContains(req.query.sessionId);
const event = safeContains(req.query.event);
const step = safeContains(req.query.step);
const from = toDate(req.query.from);
const to = toDate(req.query.to);
const where = {
...(sessionId ? { sessionId: { contains: sessionId, mode: 'insensitive' as const } } : {}),
...(event ? { event: { contains: event, mode: 'insensitive' as const } } : {}),
...(step ? { step: { contains: step, mode: 'insensitive' as const } } : {}),
...(from || to
? {
createdAt: {
...(from ? { gte: from } : {}),
...(to ? { lte: to } : {}),
},
}
: {}),
};
try {
const [total, rows] = await Promise.all([
prisma.trackerEvent.count({ where }),
prisma.trackerEvent.findMany({
where,
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
skip,
take: pageSize,
select: {
id: true,
createdAt: true,
event: true,
step: true,
sessionId: true,
ip: true,
userAgent: true,
},
}),
]);
res.status(200).json({ ok: true, page, pageSize, total, rows });
} catch (e: any) {
res.status(500).json({ ok: false, error: String(e?.message ?? e) });
}
});
app.get('/api/admin/tracker/:id', requireAdmin, async (req, res) => {
const id = req.params.id;
try {
const row = await prisma.trackerEvent.findUnique({ where: { id } });
if (!row) {
res.status(404).json({ ok: false, error: 'not found' });
return;
}
res.status(200).json({ ok: true, row });
} catch (e: any) {
res.status(500).json({ ok: false, error: String(e?.message ?? e) });
}
});
app.get('/api/admin/record', requireAdmin, async (req, res) => {
const page = Math.max(1, toInt(req.query.page, 1));
const pageSize = Math.min(200, Math.max(1, toInt(req.query.pageSize, 50)));
const skip = (page - 1) * pageSize;
const sessionId = safeContains(req.query.sessionId);
const from = toDate(req.query.from);
const to = toDate(req.query.to);
const notes = safeContains(req.query.notes);
const where = {
...(sessionId ? { sessionId: { contains: sessionId, mode: 'insensitive' as const } } : {}),
...(notes === 'has' ? { notes: { not: null } } : {}),
...(notes === 'empty' ? { notes: null } : {}),
...(from || to
? {
createdAt: {
...(from ? { gte: from } : {}),
...(to ? { lte: to } : {}),
},
}
: {}),
};
try {
const [total, rows] = await Promise.all([
prisma.recordSubmission.count({ where }),
prisma.recordSubmission.findMany({
where,
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
skip,
take: pageSize,
select: {
id: true,
createdAt: true,
sessionId: true,
notes: true,
ip: true,
userAgent: true,
},
}),
]);
res.status(200).json({ ok: true, page, pageSize, total, rows });
} catch (e: any) {
res.status(500).json({ ok: false, error: String(e?.message ?? e) });
}
});
app.get('/api/admin/record/:id', requireAdmin, async (req, res) => {
const id = req.params.id;
try {
const row = await prisma.recordSubmission.findUnique({ where: { id } });
if (!row) {
res.status(404).json({ ok: false, error: 'not found' });
return;
}
res.status(200).json({ ok: true, row });
} catch (e: any) {
res.status(500).json({ ok: false, error: String(e?.message ?? e) });
}
});
type TrackerPayload = {
event: string;
data?: unknown;
ts?: number;
sessionId?: string;
step?: string;
};
app.post('/api/tracker', async (req, res) => {
const body = (req.body ?? {}) as Partial<TrackerPayload>;
const event = typeof body.event === 'string' && body.event.trim() ? body.event.trim() : null;
if (!event) {
res.status(400).json({ ok: false, error: 'event is required' });
return;
}
const sessionId = typeof body.sessionId === 'string' ? body.sessionId : null;
const step = typeof body.step === 'string' ? body.step : null;
const userAgent = typeof req.headers['user-agent'] === 'string' ? req.headers['user-agent'] : null;
const ip = (req.headers['x-forwarded-for'] as string | undefined)?.split(',')[0]?.trim() || req.ip;
try {
await prisma.trackerEvent.create({
data: {
event,
step,
sessionId,
data: (body.data ?? null) as any,
ip: ip ?? null,
userAgent,
},
});
res.status(200).json({ ok: true });
} catch (e: any) {
res.status(500).json({ ok: false, error: String(e?.message ?? e) });
}
});
type RecordPayload = {
ts?: number;
raw: unknown;
computed: unknown;
context: unknown;
notes?: string;
};
app.post('/api/record', async (req, res) => {
const body = (req.body ?? {}) as Partial<RecordPayload>;
if (body.raw === undefined || body.computed === undefined || body.context === undefined) {
res.status(400).json({ ok: false, error: 'raw/computed/context are required' });
return;
}
const notes = typeof body.notes === 'string' ? body.notes : null;
const userAgent = typeof req.headers['user-agent'] === 'string' ? req.headers['user-agent'] : null;
const ip = (req.headers['x-forwarded-for'] as string | undefined)?.split(',')[0]?.trim() || req.ip;
// 可选:从 context 里取 sessionId前端提交会带 tracker 的 sessionId
const sessionId = typeof (body as any)?.context?.trackerSessionId === 'string'
? (body as any).context.trackerSessionId
: (typeof (body as any)?.sessionId === 'string' ? (body as any).sessionId : null);
try {
const row = await prisma.recordSubmission.create({
data: {
raw: body.raw as any,
computed: body.computed as any,
context: body.context as any,
notes,
sessionId,
ip: ip ?? null,
userAgent,
},
select: { id: true, createdAt: true },
});
res.status(200).json({ ok: true, id: row.id, createdAt: row.createdAt });
} catch (e: any) {
res.status(500).json({ ok: false, error: String(e?.message ?? e) });
}
});
// 先挂静态资源js/css/img 等)
app.use(express.static(staticDir, {
index: false,
fallthrough: true,
maxAge: '1h',
}));
// SPA fallback非 /api/* 且非静态资源命中时,返回 dist/index.html
app.get(/^(?!\/api\/).*/, (_req, res) => {
res.sendFile(path.join(staticDir, 'index.html'));
});
const port = Number(process.env.PORT ?? 3001);
app.listen(port, () => {
// eslint-disable-next-line no-console
console.log(`API server listening on http://localhost:${port}`);
});
const shutdown = async () => {
try {
await prisma.$disconnect();
} catch {
// ignore
}
process.exit(0);
};
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);

27
package.json Normal file
View File

@ -0,0 +1,27 @@
{
"name": "sound-speed-determination-server",
"module": "index.ts",
"type": "module",
"private": true,
"scripts": {
"dev": "bun --watch index.ts",
"start": "bun index.ts",
"prisma:generate": "bunx prisma generate",
"prisma:migrate": "bunx prisma migrate dev",
"prisma:studio": "bunx prisma studio"
},
"dependencies": {
"@prisma/client": "^6.0.0",
"cors": "^2.8.5",
"express": "^4.19.2"
},
"devDependencies": {
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"prisma": "^6.0.0",
"@types/bun": "latest"
},
"peerDependencies": {
"typescript": "^5"
}
}

View File

@ -0,0 +1,28 @@
-- CreateTable
CREATE TABLE "TrackerEvent" (
"id" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"event" TEXT NOT NULL,
"step" TEXT,
"sessionId" TEXT,
"data" JSONB,
"ip" TEXT,
"userAgent" TEXT,
CONSTRAINT "TrackerEvent_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "RecordSubmission" (
"id" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"raw" JSONB NOT NULL,
"computed" JSONB NOT NULL,
"context" JSONB NOT NULL,
"notes" TEXT,
"sessionId" TEXT,
"ip" TEXT,
"userAgent" TEXT,
CONSTRAINT "RecordSubmission_pkey" PRIMARY KEY ("id")
);

View File

@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"

40
prisma/schema.prisma Normal file
View File

@ -0,0 +1,40 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model TrackerEvent {
id String @id @default(uuid())
createdAt DateTime @default(now())
event String
step String?
sessionId String?
data Json?
ip String?
userAgent String?
}
model RecordSubmission {
id String @id @default(uuid())
createdAt DateTime @default(now())
// what the client typed
raw Json
// derived values computed client-side (avgΔl, v, etc)
computed Json
// wiring/osc/signal context
context Json
notes String?
sessionId String?
ip String?
userAgent String?
}

29
tsconfig.json Normal file
View File

@ -0,0 +1,29 @@
{
"compilerOptions": {
// Environment setup & latest features
"lib": ["ESNext"],
"target": "ESNext",
"module": "Preserve",
"moduleDetection": "force",
"jsx": "react-jsx",
"allowJs": true,
// Bundler mode
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
// Best practices
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
// Some stricter flags (disabled by default)
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false
}
}