@syncular/server-hono 0.0.6-96 → 0.2.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/README.md +5 -23
- package/dist/admin-page.d.ts +14 -0
- package/dist/admin-page.js +217 -0
- package/dist/admin.d.ts +39 -0
- package/dist/admin.js +142 -0
- package/dist/index.d.ts +16 -14
- package/dist/index.js +134 -23
- package/package.json +19 -53
- package/src/admin-page.ts +217 -0
- package/src/admin.ts +193 -0
- package/src/index.ts +175 -31
- package/dist/api-key-auth.d.ts +0 -49
- package/dist/api-key-auth.d.ts.map +0 -1
- package/dist/api-key-auth.js +0 -110
- package/dist/api-key-auth.js.map +0 -1
- package/dist/blobs.d.ts +0 -69
- package/dist/blobs.d.ts.map +0 -1
- package/dist/blobs.js +0 -383
- package/dist/blobs.js.map +0 -1
- package/dist/console/gateway.d.ts +0 -33
- package/dist/console/gateway.d.ts.map +0 -1
- package/dist/console/gateway.js +0 -2534
- package/dist/console/gateway.js.map +0 -1
- package/dist/console/index.d.ts +0 -11
- package/dist/console/index.d.ts.map +0 -1
- package/dist/console/index.js +0 -11
- package/dist/console/index.js.map +0 -1
- package/dist/console/routes.d.ts +0 -33
- package/dist/console/routes.d.ts.map +0 -1
- package/dist/console/routes.js +0 -3029
- package/dist/console/routes.js.map +0 -1
- package/dist/console/schemas.d.ts +0 -490
- package/dist/console/schemas.d.ts.map +0 -1
- package/dist/console/schemas.js +0 -303
- package/dist/console/schemas.js.map +0 -1
- package/dist/console/types.d.ts +0 -175
- package/dist/console/types.d.ts.map +0 -1
- package/dist/console/types.js +0 -2
- package/dist/console/types.js.map +0 -1
- package/dist/console/ui.d.ts +0 -38
- package/dist/console/ui.d.ts.map +0 -1
- package/dist/console/ui.js +0 -43
- package/dist/console/ui.js.map +0 -1
- package/dist/create-server.d.ts +0 -68
- package/dist/create-server.d.ts.map +0 -1
- package/dist/create-server.js +0 -110
- package/dist/create-server.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/openapi.d.ts +0 -45
- package/dist/openapi.d.ts.map +0 -1
- package/dist/openapi.js +0 -59
- package/dist/openapi.js.map +0 -1
- package/dist/proxy/connection-manager.d.ts +0 -78
- package/dist/proxy/connection-manager.d.ts.map +0 -1
- package/dist/proxy/connection-manager.js +0 -251
- package/dist/proxy/connection-manager.js.map +0 -1
- package/dist/proxy/index.d.ts +0 -8
- package/dist/proxy/index.d.ts.map +0 -1
- package/dist/proxy/index.js +0 -8
- package/dist/proxy/index.js.map +0 -1
- package/dist/proxy/routes.d.ts +0 -74
- package/dist/proxy/routes.d.ts.map +0 -1
- package/dist/proxy/routes.js +0 -147
- package/dist/proxy/routes.js.map +0 -1
- package/dist/rate-limit.d.ts +0 -101
- package/dist/rate-limit.d.ts.map +0 -1
- package/dist/rate-limit.js +0 -186
- package/dist/rate-limit.js.map +0 -1
- package/dist/routes.d.ts +0 -161
- package/dist/routes.d.ts.map +0 -1
- package/dist/routes.js +0 -1493
- package/dist/routes.js.map +0 -1
- package/dist/ws.d.ts +0 -235
- package/dist/ws.d.ts.map +0 -1
- package/dist/ws.js +0 -614
- package/dist/ws.js.map +0 -1
- package/src/__tests__/blob-routes.test.ts +0 -424
- package/src/__tests__/console-gateway-live-routes.test.ts +0 -297
- package/src/__tests__/console-gateway-routes.test.ts +0 -1364
- package/src/__tests__/console-routes.test.ts +0 -1626
- package/src/__tests__/console-ui.test.ts +0 -114
- package/src/__tests__/create-server.test.ts +0 -485
- package/src/__tests__/pull-chunk-storage.test.ts +0 -576
- package/src/__tests__/rate-limit.test.ts +0 -78
- package/src/__tests__/realtime-bridge.test.ts +0 -135
- package/src/__tests__/sync-rate-limit-routing.test.ts +0 -185
- package/src/__tests__/ws-connection-manager.test.ts +0 -176
- package/src/api-key-auth.ts +0 -179
- package/src/blobs.ts +0 -534
- package/src/console/gateway.ts +0 -3603
- package/src/console/index.ts +0 -11
- package/src/console/routes.ts +0 -3936
- package/src/console/schemas.ts +0 -434
- package/src/console/types.ts +0 -185
- package/src/console/ui.ts +0 -100
- package/src/create-server.ts +0 -207
- package/src/openapi.ts +0 -74
- package/src/proxy/connection-manager.ts +0 -340
- package/src/proxy/index.ts +0 -8
- package/src/proxy/routes.ts +0 -223
- package/src/rate-limit.ts +0 -321
- package/src/routes.ts +0 -2059
- package/src/ws.ts +0 -802
package/README.md
CHANGED
|
@@ -1,28 +1,10 @@
|
|
|
1
1
|
# @syncular/server-hono
|
|
2
2
|
|
|
3
|
-
Hono adapter for
|
|
3
|
+
Hono adapter for the Syncular sync server.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
Part of [Syncular](https://syncular.dev) — an offline-first sync framework.
|
|
6
|
+
See the [Syncular repository](https://github.com/syncular/syncular) for docs.
|
|
6
7
|
|
|
7
|
-
|
|
8
|
-
npm install @syncular/server-hono hono
|
|
9
|
-
```
|
|
8
|
+
## License
|
|
10
9
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
```bash
|
|
14
|
-
npm install @syncular/console
|
|
15
|
-
```
|
|
16
|
-
|
|
17
|
-
## Documentation
|
|
18
|
-
|
|
19
|
-
- Hono adapter: https://syncular.dev/docs/server/hono-adapter
|
|
20
|
-
- API reference: https://syncular.dev/docs/api
|
|
21
|
-
- Operations & console: https://syncular.dev/docs/build/operations
|
|
22
|
-
|
|
23
|
-
## Links
|
|
24
|
-
|
|
25
|
-
- GitHub: https://github.com/syncular/syncular
|
|
26
|
-
- Issues: https://github.com/syncular/syncular/issues
|
|
27
|
-
|
|
28
|
-
> Status: Alpha. APIs and storage layouts may change between releases.
|
|
10
|
+
Apache-2.0
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The single static console page (TODO §2.5). Zero framework, no build
|
|
3
|
+
* step, ~one file: it fetches the sibling JSON endpoints (relative to its
|
|
4
|
+
* own mount path), renders tables, and offers an auto-refresh toggle. This
|
|
5
|
+
* is the v2 answer to v1's full React console app — 5% of the code, the
|
|
6
|
+
* 80% operator value.
|
|
7
|
+
*
|
|
8
|
+
* The HTML is a single exported string so the routes module can serve it
|
|
9
|
+
* with no filesystem read (works identically on Bun, Node, and Workers).
|
|
10
|
+
* All fetches are same-origin and relative ('./clients', …), so the page
|
|
11
|
+
* works under whatever prefix the host mounts the routes at, and the host's
|
|
12
|
+
* `authorize` guard applies to the page's own XHRs (same cookies/headers).
|
|
13
|
+
*/
|
|
14
|
+
export declare const ADMIN_CONSOLE_HTML = "<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\" />\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n<title>Syncular console</title>\n<style>\n :root {\n --bg: #0f1115; --panel: #171a21; --border: #262b36; --fg: #d7dce5;\n --muted: #8a93a6; --accent: #5b9dff; --ok: #43c17a; --warn: #e0a83a;\n --mono: ui-monospace, SFMono-Regular, \"SF Mono\", Menlo, Consolas, monospace;\n --sans: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;\n }\n * { box-sizing: border-box; }\n body { margin: 0; background: var(--bg); color: var(--fg); font: 14px/1.5 var(--sans); }\n header { display: flex; align-items: center; gap: 16px; flex-wrap: wrap;\n padding: 14px 20px; border-bottom: 1px solid var(--border); background: var(--panel); }\n header h1 { font-size: 15px; margin: 0; font-weight: 600; letter-spacing: .02em; }\n header .spacer { flex: 1; }\n header label { color: var(--muted); font-size: 13px; display: inline-flex; gap: 6px; align-items: center; }\n header input[type=text] { background: var(--bg); border: 1px solid var(--border); color: var(--fg);\n border-radius: 6px; padding: 5px 8px; font: 13px var(--mono); width: 140px; }\n header button { background: var(--bg); border: 1px solid var(--border); color: var(--fg);\n border-radius: 6px; padding: 5px 11px; cursor: pointer; font: 13px var(--sans); }\n header button:hover { border-color: var(--accent); }\n #status { color: var(--muted); font: 12px var(--mono); }\n main { padding: 18px 20px; display: grid; gap: 18px; grid-template-columns: repeat(auto-fit, minmax(420px, 1fr)); }\n section { background: var(--panel); border: 1px solid var(--border); border-radius: 10px; overflow: hidden; }\n section > h2 { font-size: 13px; margin: 0; padding: 10px 14px; border-bottom: 1px solid var(--border);\n font-weight: 600; color: var(--fg); display: flex; justify-content: space-between; align-items: center; }\n section > h2 .count { color: var(--muted); font: 12px var(--mono); font-weight: 400; }\n .body { max-height: 360px; overflow: auto; }\n table { width: 100%; border-collapse: collapse; font: 12px var(--mono); }\n th, td { text-align: left; padding: 6px 10px; border-bottom: 1px solid var(--border); white-space: nowrap; }\n th { color: var(--muted); font-weight: 500; position: sticky; top: 0; background: var(--panel); z-index: 1; }\n td.wrap { white-space: normal; word-break: break-all; }\n tr:hover td { background: #1c212b; }\n .pill { display: inline-block; padding: 1px 7px; border-radius: 999px; font-size: 11px; }\n .pill.ok { background: rgba(67,193,122,.15); color: var(--ok); }\n .pill.warn { background: rgba(224,168,58,.15); color: var(--warn); }\n .pill.idle { background: rgba(138,147,166,.15); color: var(--muted); }\n .kv { display: grid; grid-template-columns: auto 1fr; gap: 4px 14px; padding: 10px 14px; font: 12px var(--mono); }\n .kv dt { color: var(--muted); }\n .empty { color: var(--muted); padding: 14px; font-style: italic; }\n .evt-type { color: var(--accent); }\n .full { grid-column: 1 / -1; }\n</style>\n</head>\n<body>\n<header>\n <h1>Syncular console</h1>\n <label>partition\n <input id=\"partition\" type=\"text\" placeholder=\"(default)\" />\n </label>\n <button id=\"refresh\">Refresh</button>\n <label><input id=\"auto\" type=\"checkbox\" /> auto</label>\n <span class=\"spacer\"></span>\n <span id=\"status\">idle</span>\n</header>\n<main>\n <section><h2>Horizon <span class=\"count\" id=\"horizon-rec\"></span></h2><div class=\"body\" id=\"horizon\"></div></section>\n <section><h2>Store stats</h2><div class=\"body\" id=\"stats\"></div></section>\n <section><h2>Clients <span class=\"count\" id=\"clients-count\"></span></h2><div class=\"body\" id=\"clients\"></div></section>\n <section><h2>Recent commits <span class=\"count\" id=\"commits-count\"></span></h2><div class=\"body\" id=\"commits\"></div></section>\n <section class=\"full\"><h2>Event stream <span class=\"count\" id=\"events-count\"></span></h2><div class=\"body\" id=\"events\"></div></section>\n</main>\n<script>\n(function () {\n var el = function (id) { return document.getElementById(id); };\n var status = el('status');\n\n function q() {\n var p = el('partition').value.trim();\n return p ? ('?partition=' + encodeURIComponent(p)) : '';\n }\n // The endpoints are siblings of the page under the SAME mount prefix. The\n // page is served at the mount root (\u2026/admin or \u2026/admin/); strip any\n // trailing slash so 'base + /clients' hits '\u2026/admin/clients' regardless of\n // whether the URL had the trailing slash.\n var base = location.pathname.replace(/\\/+$/, '');\n function get(path) {\n return fetch(base + path + q(), { headers: { accept: 'application/json' } })\n .then(function (r) {\n if (!r.ok) return r.json().then(function (b) { throw new Error(b.code || ('HTTP ' + r.status)); });\n return r.json();\n });\n }\n function esc(v) {\n return String(v == null ? '' : v)\n .replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');\n }\n function ago(ms) {\n var d = Date.now() - ms;\n if (d < 0) d = 0;\n if (d < 1000) return d + 'ms';\n if (d < 60000) return Math.round(d / 1000) + 's';\n if (d < 3600000) return Math.round(d / 60000) + 'm';\n return Math.round(d / 3600000) + 'h';\n }\n function bytes(n) {\n if (n == null) return '-';\n if (n < 1024) return n + ' B';\n if (n < 1048576) return (n / 1024).toFixed(1) + ' KiB';\n return (n / 1048576).toFixed(1) + ' MiB';\n }\n function table(head, rows) {\n if (!rows.length) return '<div class=\"empty\">none</div>';\n var h = '<table><thead><tr>' + head.map(function (c) { return '<th>' + esc(c) + '</th>'; }).join('') + '</tr></thead><tbody>';\n h += rows.map(function (r) {\n return '<tr>' + r.map(function (c) { return '<td>' + c + '</td>'; }).join('') + '</tr>';\n }).join('');\n return h + '</tbody></table>';\n }\n\n function renderHorizon(h) {\n el('horizon').innerHTML =\n '<dl class=\"kv\">' +\n '<dt>maxCommitSeq</dt><dd>' + esc(h.maxCommitSeq) + '</dd>' +\n '<dt>horizonSeq</dt><dd>' + esc(h.horizonSeq) + '</dd>' +\n '<dt>retained commits</dt><dd>' + esc(h.retainedCommits) + '</dd>' +\n '<dt>active cursor floor</dt><dd>' + esc(h.activeCursorFloor == null ? 'none' : h.activeCursorFloor) + '</dd>' +\n '<dt>recommended horizon</dt><dd>' + esc(h.recommendedHorizonSeq) + '</dd>' +\n '</dl>';\n var rec = el('horizon-rec');\n if (h.recommendation === 'prune-recommended') {\n rec.innerHTML = '<span class=\"pill warn\">prune recommended</span>';\n } else {\n rec.innerHTML = '<span class=\"pill ok\">up to date</span>';\n }\n }\n function renderStats(s) {\n var seg = s.segments, blob = s.blobs;\n var rows = '<dl class=\"kv\">';\n if (seg) {\n rows += '<dt>segments</dt><dd>' + esc(seg.count) + ' (' + bytes(seg.bytes) + ')</dd>' +\n '<dt> rows / sqlite</dt><dd>' + esc(seg.rowsSegments) + ' / ' + esc(seg.sqliteSegments) + '</dd>';\n } else { rows += '<dt>segments</dt><dd>n/a</dd>'; }\n if (blob) {\n rows += '<dt>blobs</dt><dd>' + esc(blob.count) + ' (' + bytes(blob.bytes) + ')</dd>';\n } else { rows += '<dt>blobs</dt><dd>n/a</dd>'; }\n el('stats').innerHTML = rows + '</dl>';\n }\n function renderClients(list) {\n el('clients-count').textContent = list.length + ' known';\n el('clients').innerHTML = table(['client', 'actor', 'cursor', 'seen', 'subs', ''],\n list.map(function (c) {\n var pill = c.active ? '<span class=\"pill ok\">active</span>' : '<span class=\"pill idle\">idle</span>';\n return [esc(c.clientId), esc(c.actorId), esc(c.cursor), ago(c.updatedAtMs), esc(c.subscriptions.length), pill];\n }));\n }\n function renderCommits(list) {\n el('commits-count').textContent = list.length + ' shown';\n el('commits').innerHTML = table(['seq', 'actor', 'client', 'changes', 'tables', 'when'],\n list.map(function (c) {\n return [esc(c.commitSeq), esc(c.actorId), esc(c.clientId), esc(c.changeCount),\n '<span class=\"wrap\">' + esc(c.tables.join(', ')) + '</span>', ago(c.createdAtMs)];\n }));\n }\n function renderEvents(res) {\n el('events-count').textContent = res.hasEventStream\n ? (res.events.length + ' events')\n : 'no ring buffer wired';\n if (!res.hasEventStream) { el('events').innerHTML = '<div class=\"empty\">no RingBufferEvents wired on this admin</div>'; return; }\n el('events').innerHTML = table(['when', 'type', 'detail'],\n res.events.map(function (e) {\n var detail = Object.keys(e)\n .filter(function (k) { return k !== 'type' && k !== 'atMs'; })\n .map(function (k) { return k + '=' + JSON.stringify(e[k]); })\n .join(' ');\n return [ago(e.atMs), '<span class=\"evt-type\">' + esc(e.type) + '</span>',\n '<span class=\"wrap\">' + esc(detail) + '</span>'];\n }));\n }\n\n var loading = false;\n function loadAll() {\n if (loading) return;\n loading = true;\n status.textContent = 'loading\u2026';\n Promise.all([\n get('/horizon').then(function (r) { renderHorizon(r.horizon); }),\n get('/stats').then(function (r) { renderStats(r.stats); }),\n get('/clients').then(function (r) { renderClients(r.clients); }),\n get('/commits').then(function (r) { renderCommits(r.commits); }),\n get('/events').then(renderEvents)\n ]).then(function () {\n status.textContent = 'updated ' + new Date().toLocaleTimeString();\n }).catch(function (err) {\n status.textContent = 'error: ' + err.message;\n }).finally(function () { loading = false; });\n }\n\n el('refresh').addEventListener('click', loadAll);\n el('partition').addEventListener('change', loadAll);\n var timer = null;\n el('auto').addEventListener('change', function () {\n if (this.checked) { timer = setInterval(loadAll, 2000); loadAll(); }\n else if (timer) { clearInterval(timer); timer = null; }\n });\n loadAll();\n})();\n</script>\n</body>\n</html>";
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The single static console page (TODO §2.5). Zero framework, no build
|
|
3
|
+
* step, ~one file: it fetches the sibling JSON endpoints (relative to its
|
|
4
|
+
* own mount path), renders tables, and offers an auto-refresh toggle. This
|
|
5
|
+
* is the v2 answer to v1's full React console app — 5% of the code, the
|
|
6
|
+
* 80% operator value.
|
|
7
|
+
*
|
|
8
|
+
* The HTML is a single exported string so the routes module can serve it
|
|
9
|
+
* with no filesystem read (works identically on Bun, Node, and Workers).
|
|
10
|
+
* All fetches are same-origin and relative ('./clients', …), so the page
|
|
11
|
+
* works under whatever prefix the host mounts the routes at, and the host's
|
|
12
|
+
* `authorize` guard applies to the page's own XHRs (same cookies/headers).
|
|
13
|
+
*/
|
|
14
|
+
export const ADMIN_CONSOLE_HTML = `<!doctype html>
|
|
15
|
+
<html lang="en">
|
|
16
|
+
<head>
|
|
17
|
+
<meta charset="utf-8" />
|
|
18
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
19
|
+
<title>Syncular console</title>
|
|
20
|
+
<style>
|
|
21
|
+
:root {
|
|
22
|
+
--bg: #0f1115; --panel: #171a21; --border: #262b36; --fg: #d7dce5;
|
|
23
|
+
--muted: #8a93a6; --accent: #5b9dff; --ok: #43c17a; --warn: #e0a83a;
|
|
24
|
+
--mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
|
|
25
|
+
--sans: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
|
|
26
|
+
}
|
|
27
|
+
* { box-sizing: border-box; }
|
|
28
|
+
body { margin: 0; background: var(--bg); color: var(--fg); font: 14px/1.5 var(--sans); }
|
|
29
|
+
header { display: flex; align-items: center; gap: 16px; flex-wrap: wrap;
|
|
30
|
+
padding: 14px 20px; border-bottom: 1px solid var(--border); background: var(--panel); }
|
|
31
|
+
header h1 { font-size: 15px; margin: 0; font-weight: 600; letter-spacing: .02em; }
|
|
32
|
+
header .spacer { flex: 1; }
|
|
33
|
+
header label { color: var(--muted); font-size: 13px; display: inline-flex; gap: 6px; align-items: center; }
|
|
34
|
+
header input[type=text] { background: var(--bg); border: 1px solid var(--border); color: var(--fg);
|
|
35
|
+
border-radius: 6px; padding: 5px 8px; font: 13px var(--mono); width: 140px; }
|
|
36
|
+
header button { background: var(--bg); border: 1px solid var(--border); color: var(--fg);
|
|
37
|
+
border-radius: 6px; padding: 5px 11px; cursor: pointer; font: 13px var(--sans); }
|
|
38
|
+
header button:hover { border-color: var(--accent); }
|
|
39
|
+
#status { color: var(--muted); font: 12px var(--mono); }
|
|
40
|
+
main { padding: 18px 20px; display: grid; gap: 18px; grid-template-columns: repeat(auto-fit, minmax(420px, 1fr)); }
|
|
41
|
+
section { background: var(--panel); border: 1px solid var(--border); border-radius: 10px; overflow: hidden; }
|
|
42
|
+
section > h2 { font-size: 13px; margin: 0; padding: 10px 14px; border-bottom: 1px solid var(--border);
|
|
43
|
+
font-weight: 600; color: var(--fg); display: flex; justify-content: space-between; align-items: center; }
|
|
44
|
+
section > h2 .count { color: var(--muted); font: 12px var(--mono); font-weight: 400; }
|
|
45
|
+
.body { max-height: 360px; overflow: auto; }
|
|
46
|
+
table { width: 100%; border-collapse: collapse; font: 12px var(--mono); }
|
|
47
|
+
th, td { text-align: left; padding: 6px 10px; border-bottom: 1px solid var(--border); white-space: nowrap; }
|
|
48
|
+
th { color: var(--muted); font-weight: 500; position: sticky; top: 0; background: var(--panel); z-index: 1; }
|
|
49
|
+
td.wrap { white-space: normal; word-break: break-all; }
|
|
50
|
+
tr:hover td { background: #1c212b; }
|
|
51
|
+
.pill { display: inline-block; padding: 1px 7px; border-radius: 999px; font-size: 11px; }
|
|
52
|
+
.pill.ok { background: rgba(67,193,122,.15); color: var(--ok); }
|
|
53
|
+
.pill.warn { background: rgba(224,168,58,.15); color: var(--warn); }
|
|
54
|
+
.pill.idle { background: rgba(138,147,166,.15); color: var(--muted); }
|
|
55
|
+
.kv { display: grid; grid-template-columns: auto 1fr; gap: 4px 14px; padding: 10px 14px; font: 12px var(--mono); }
|
|
56
|
+
.kv dt { color: var(--muted); }
|
|
57
|
+
.empty { color: var(--muted); padding: 14px; font-style: italic; }
|
|
58
|
+
.evt-type { color: var(--accent); }
|
|
59
|
+
.full { grid-column: 1 / -1; }
|
|
60
|
+
</style>
|
|
61
|
+
</head>
|
|
62
|
+
<body>
|
|
63
|
+
<header>
|
|
64
|
+
<h1>Syncular console</h1>
|
|
65
|
+
<label>partition
|
|
66
|
+
<input id="partition" type="text" placeholder="(default)" />
|
|
67
|
+
</label>
|
|
68
|
+
<button id="refresh">Refresh</button>
|
|
69
|
+
<label><input id="auto" type="checkbox" /> auto</label>
|
|
70
|
+
<span class="spacer"></span>
|
|
71
|
+
<span id="status">idle</span>
|
|
72
|
+
</header>
|
|
73
|
+
<main>
|
|
74
|
+
<section><h2>Horizon <span class="count" id="horizon-rec"></span></h2><div class="body" id="horizon"></div></section>
|
|
75
|
+
<section><h2>Store stats</h2><div class="body" id="stats"></div></section>
|
|
76
|
+
<section><h2>Clients <span class="count" id="clients-count"></span></h2><div class="body" id="clients"></div></section>
|
|
77
|
+
<section><h2>Recent commits <span class="count" id="commits-count"></span></h2><div class="body" id="commits"></div></section>
|
|
78
|
+
<section class="full"><h2>Event stream <span class="count" id="events-count"></span></h2><div class="body" id="events"></div></section>
|
|
79
|
+
</main>
|
|
80
|
+
<script>
|
|
81
|
+
(function () {
|
|
82
|
+
var el = function (id) { return document.getElementById(id); };
|
|
83
|
+
var status = el('status');
|
|
84
|
+
|
|
85
|
+
function q() {
|
|
86
|
+
var p = el('partition').value.trim();
|
|
87
|
+
return p ? ('?partition=' + encodeURIComponent(p)) : '';
|
|
88
|
+
}
|
|
89
|
+
// The endpoints are siblings of the page under the SAME mount prefix. The
|
|
90
|
+
// page is served at the mount root (…/admin or …/admin/); strip any
|
|
91
|
+
// trailing slash so 'base + /clients' hits '…/admin/clients' regardless of
|
|
92
|
+
// whether the URL had the trailing slash.
|
|
93
|
+
var base = location.pathname.replace(/\\/+$/, '');
|
|
94
|
+
function get(path) {
|
|
95
|
+
return fetch(base + path + q(), { headers: { accept: 'application/json' } })
|
|
96
|
+
.then(function (r) {
|
|
97
|
+
if (!r.ok) return r.json().then(function (b) { throw new Error(b.code || ('HTTP ' + r.status)); });
|
|
98
|
+
return r.json();
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
function esc(v) {
|
|
102
|
+
return String(v == null ? '' : v)
|
|
103
|
+
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
104
|
+
}
|
|
105
|
+
function ago(ms) {
|
|
106
|
+
var d = Date.now() - ms;
|
|
107
|
+
if (d < 0) d = 0;
|
|
108
|
+
if (d < 1000) return d + 'ms';
|
|
109
|
+
if (d < 60000) return Math.round(d / 1000) + 's';
|
|
110
|
+
if (d < 3600000) return Math.round(d / 60000) + 'm';
|
|
111
|
+
return Math.round(d / 3600000) + 'h';
|
|
112
|
+
}
|
|
113
|
+
function bytes(n) {
|
|
114
|
+
if (n == null) return '-';
|
|
115
|
+
if (n < 1024) return n + ' B';
|
|
116
|
+
if (n < 1048576) return (n / 1024).toFixed(1) + ' KiB';
|
|
117
|
+
return (n / 1048576).toFixed(1) + ' MiB';
|
|
118
|
+
}
|
|
119
|
+
function table(head, rows) {
|
|
120
|
+
if (!rows.length) return '<div class="empty">none</div>';
|
|
121
|
+
var h = '<table><thead><tr>' + head.map(function (c) { return '<th>' + esc(c) + '</th>'; }).join('') + '</tr></thead><tbody>';
|
|
122
|
+
h += rows.map(function (r) {
|
|
123
|
+
return '<tr>' + r.map(function (c) { return '<td>' + c + '</td>'; }).join('') + '</tr>';
|
|
124
|
+
}).join('');
|
|
125
|
+
return h + '</tbody></table>';
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function renderHorizon(h) {
|
|
129
|
+
el('horizon').innerHTML =
|
|
130
|
+
'<dl class="kv">' +
|
|
131
|
+
'<dt>maxCommitSeq</dt><dd>' + esc(h.maxCommitSeq) + '</dd>' +
|
|
132
|
+
'<dt>horizonSeq</dt><dd>' + esc(h.horizonSeq) + '</dd>' +
|
|
133
|
+
'<dt>retained commits</dt><dd>' + esc(h.retainedCommits) + '</dd>' +
|
|
134
|
+
'<dt>active cursor floor</dt><dd>' + esc(h.activeCursorFloor == null ? 'none' : h.activeCursorFloor) + '</dd>' +
|
|
135
|
+
'<dt>recommended horizon</dt><dd>' + esc(h.recommendedHorizonSeq) + '</dd>' +
|
|
136
|
+
'</dl>';
|
|
137
|
+
var rec = el('horizon-rec');
|
|
138
|
+
if (h.recommendation === 'prune-recommended') {
|
|
139
|
+
rec.innerHTML = '<span class="pill warn">prune recommended</span>';
|
|
140
|
+
} else {
|
|
141
|
+
rec.innerHTML = '<span class="pill ok">up to date</span>';
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
function renderStats(s) {
|
|
145
|
+
var seg = s.segments, blob = s.blobs;
|
|
146
|
+
var rows = '<dl class="kv">';
|
|
147
|
+
if (seg) {
|
|
148
|
+
rows += '<dt>segments</dt><dd>' + esc(seg.count) + ' (' + bytes(seg.bytes) + ')</dd>' +
|
|
149
|
+
'<dt> rows / sqlite</dt><dd>' + esc(seg.rowsSegments) + ' / ' + esc(seg.sqliteSegments) + '</dd>';
|
|
150
|
+
} else { rows += '<dt>segments</dt><dd>n/a</dd>'; }
|
|
151
|
+
if (blob) {
|
|
152
|
+
rows += '<dt>blobs</dt><dd>' + esc(blob.count) + ' (' + bytes(blob.bytes) + ')</dd>';
|
|
153
|
+
} else { rows += '<dt>blobs</dt><dd>n/a</dd>'; }
|
|
154
|
+
el('stats').innerHTML = rows + '</dl>';
|
|
155
|
+
}
|
|
156
|
+
function renderClients(list) {
|
|
157
|
+
el('clients-count').textContent = list.length + ' known';
|
|
158
|
+
el('clients').innerHTML = table(['client', 'actor', 'cursor', 'seen', 'subs', ''],
|
|
159
|
+
list.map(function (c) {
|
|
160
|
+
var pill = c.active ? '<span class="pill ok">active</span>' : '<span class="pill idle">idle</span>';
|
|
161
|
+
return [esc(c.clientId), esc(c.actorId), esc(c.cursor), ago(c.updatedAtMs), esc(c.subscriptions.length), pill];
|
|
162
|
+
}));
|
|
163
|
+
}
|
|
164
|
+
function renderCommits(list) {
|
|
165
|
+
el('commits-count').textContent = list.length + ' shown';
|
|
166
|
+
el('commits').innerHTML = table(['seq', 'actor', 'client', 'changes', 'tables', 'when'],
|
|
167
|
+
list.map(function (c) {
|
|
168
|
+
return [esc(c.commitSeq), esc(c.actorId), esc(c.clientId), esc(c.changeCount),
|
|
169
|
+
'<span class="wrap">' + esc(c.tables.join(', ')) + '</span>', ago(c.createdAtMs)];
|
|
170
|
+
}));
|
|
171
|
+
}
|
|
172
|
+
function renderEvents(res) {
|
|
173
|
+
el('events-count').textContent = res.hasEventStream
|
|
174
|
+
? (res.events.length + ' events')
|
|
175
|
+
: 'no ring buffer wired';
|
|
176
|
+
if (!res.hasEventStream) { el('events').innerHTML = '<div class="empty">no RingBufferEvents wired on this admin</div>'; return; }
|
|
177
|
+
el('events').innerHTML = table(['when', 'type', 'detail'],
|
|
178
|
+
res.events.map(function (e) {
|
|
179
|
+
var detail = Object.keys(e)
|
|
180
|
+
.filter(function (k) { return k !== 'type' && k !== 'atMs'; })
|
|
181
|
+
.map(function (k) { return k + '=' + JSON.stringify(e[k]); })
|
|
182
|
+
.join(' ');
|
|
183
|
+
return [ago(e.atMs), '<span class="evt-type">' + esc(e.type) + '</span>',
|
|
184
|
+
'<span class="wrap">' + esc(detail) + '</span>'];
|
|
185
|
+
}));
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
var loading = false;
|
|
189
|
+
function loadAll() {
|
|
190
|
+
if (loading) return;
|
|
191
|
+
loading = true;
|
|
192
|
+
status.textContent = 'loading…';
|
|
193
|
+
Promise.all([
|
|
194
|
+
get('/horizon').then(function (r) { renderHorizon(r.horizon); }),
|
|
195
|
+
get('/stats').then(function (r) { renderStats(r.stats); }),
|
|
196
|
+
get('/clients').then(function (r) { renderClients(r.clients); }),
|
|
197
|
+
get('/commits').then(function (r) { renderCommits(r.commits); }),
|
|
198
|
+
get('/events').then(renderEvents)
|
|
199
|
+
]).then(function () {
|
|
200
|
+
status.textContent = 'updated ' + new Date().toLocaleTimeString();
|
|
201
|
+
}).catch(function (err) {
|
|
202
|
+
status.textContent = 'error: ' + err.message;
|
|
203
|
+
}).finally(function () { loading = false; });
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
el('refresh').addEventListener('click', loadAll);
|
|
207
|
+
el('partition').addEventListener('change', loadAll);
|
|
208
|
+
var timer = null;
|
|
209
|
+
el('auto').addEventListener('change', function () {
|
|
210
|
+
if (this.checked) { timer = setInterval(loadAll, 2000); loadAll(); }
|
|
211
|
+
else if (timer) { clearInterval(timer); timer = null; }
|
|
212
|
+
});
|
|
213
|
+
loadAll();
|
|
214
|
+
})();
|
|
215
|
+
</script>
|
|
216
|
+
</body>
|
|
217
|
+
</html>`;
|
package/dist/admin.d.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Admin / console HTTP surface (TODO §2.5) — a mountable Hono sub-app over a
|
|
3
|
+
* `SyncularAdmin`. JSON endpoints mirror the read surface; a single static
|
|
4
|
+
* HTML page (zero framework, no build step) at `GET /` renders them.
|
|
5
|
+
*
|
|
6
|
+
* The auth seam is REQUIRED. There is NO default-open admin: the factory
|
|
7
|
+
* refuses to mount without a host-provided `authorize` guard (it throws at
|
|
8
|
+
* construction). Every request runs the guard first; a falsy result is a
|
|
9
|
+
* 401. Admin is a privileged surface (it reads every partition's clients,
|
|
10
|
+
* commit metadata, and scope activity) and SPEC.md deliberately says nothing
|
|
11
|
+
* about it — authorization is entirely the host's, and mandatory.
|
|
12
|
+
*/
|
|
13
|
+
import { type SyncularAdmin } from '@syncular/server';
|
|
14
|
+
import { Hono } from 'hono';
|
|
15
|
+
export interface AdminAuthContext {
|
|
16
|
+
/** The partition the request targets (from the query string / route). */
|
|
17
|
+
readonly partition: string;
|
|
18
|
+
readonly request: Request;
|
|
19
|
+
}
|
|
20
|
+
export interface SyncularAdminRoutesOptions {
|
|
21
|
+
/**
|
|
22
|
+
* REQUIRED host guard. Return `true` (or a truthy value) to allow the
|
|
23
|
+
* request, a falsy value to reject it with 401. Runs before every admin
|
|
24
|
+
* endpoint, including the HTML page. There is no default — omitting it
|
|
25
|
+
* throws, by design (no default-open admin).
|
|
26
|
+
*/
|
|
27
|
+
readonly authorize: (ctx: AdminAuthContext) => boolean | Promise<boolean>;
|
|
28
|
+
/**
|
|
29
|
+
* Default partition for endpoints when the request omits `?partition=`.
|
|
30
|
+
* When unset, `partition` is required on every data endpoint.
|
|
31
|
+
*/
|
|
32
|
+
readonly defaultPartition?: string;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Build the mountable admin sub-app. Mount it under any prefix, e.g.
|
|
36
|
+
* `app.route('/admin', createSyncularAdminRoutes(admin, { authorize }))`.
|
|
37
|
+
* Throws if `authorize` is missing — admin never mounts default-open.
|
|
38
|
+
*/
|
|
39
|
+
export declare function createSyncularAdminRoutes(admin: SyncularAdmin, options: SyncularAdminRoutesOptions): Hono;
|
package/dist/admin.js
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Admin / console HTTP surface (TODO §2.5) — a mountable Hono sub-app over a
|
|
3
|
+
* `SyncularAdmin`. JSON endpoints mirror the read surface; a single static
|
|
4
|
+
* HTML page (zero framework, no build step) at `GET /` renders them.
|
|
5
|
+
*
|
|
6
|
+
* The auth seam is REQUIRED. There is NO default-open admin: the factory
|
|
7
|
+
* refuses to mount without a host-provided `authorize` guard (it throws at
|
|
8
|
+
* construction). Every request runs the guard first; a falsy result is a
|
|
9
|
+
* 401. Admin is a privileged surface (it reads every partition's clients,
|
|
10
|
+
* commit metadata, and scope activity) and SPEC.md deliberately says nothing
|
|
11
|
+
* about it — authorization is entirely the host's, and mandatory.
|
|
12
|
+
*/
|
|
13
|
+
import { errorBody, SyncError, } from '@syncular/server';
|
|
14
|
+
import { Hono } from 'hono';
|
|
15
|
+
import { ADMIN_CONSOLE_HTML } from './admin-page.js';
|
|
16
|
+
function jsonError(error) {
|
|
17
|
+
const sync = error instanceof SyncError
|
|
18
|
+
? error
|
|
19
|
+
: new SyncError('sync.invalid_request', String(error));
|
|
20
|
+
return Response.json(errorBody(sync), { status: sync.httpStatus });
|
|
21
|
+
}
|
|
22
|
+
function intParam(value, fallback) {
|
|
23
|
+
if (value === undefined)
|
|
24
|
+
return fallback;
|
|
25
|
+
const parsed = Number.parseInt(value, 10);
|
|
26
|
+
return Number.isFinite(parsed) ? parsed : fallback;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Build the mountable admin sub-app. Mount it under any prefix, e.g.
|
|
30
|
+
* `app.route('/admin', createSyncularAdminRoutes(admin, { authorize }))`.
|
|
31
|
+
* Throws if `authorize` is missing — admin never mounts default-open.
|
|
32
|
+
*/
|
|
33
|
+
export function createSyncularAdminRoutes(admin, options) {
|
|
34
|
+
if (typeof options.authorize !== 'function') {
|
|
35
|
+
throw new Error('createSyncularAdminRoutes: an `authorize` guard is required — admin never mounts default-open (TODO §2.5)');
|
|
36
|
+
}
|
|
37
|
+
const app = new Hono();
|
|
38
|
+
/** Resolve the request's partition (query, else default) — required. */
|
|
39
|
+
function partitionOf(c) {
|
|
40
|
+
const partition = c.req.query('partition') ?? options.defaultPartition;
|
|
41
|
+
if (partition === undefined || partition.length === 0) {
|
|
42
|
+
throw new SyncError('sync.invalid_request', 'partition is required');
|
|
43
|
+
}
|
|
44
|
+
return partition;
|
|
45
|
+
}
|
|
46
|
+
// Guard every route (page + data) up front.
|
|
47
|
+
app.use('*', async (c, next) => {
|
|
48
|
+
let partition;
|
|
49
|
+
try {
|
|
50
|
+
partition = c.req.query('partition') ?? options.defaultPartition ?? '';
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
partition = '';
|
|
54
|
+
}
|
|
55
|
+
const ok = await options.authorize({ partition, request: c.req.raw });
|
|
56
|
+
if (!ok)
|
|
57
|
+
return jsonError(new SyncError('sync.auth_required'));
|
|
58
|
+
await next();
|
|
59
|
+
});
|
|
60
|
+
// The single static console page (zero framework, no build step).
|
|
61
|
+
app.get('/', (c) => c.html(ADMIN_CONSOLE_HTML, 200, { 'Cache-Control': 'no-store' }));
|
|
62
|
+
app.get('/clients', async (c) => {
|
|
63
|
+
try {
|
|
64
|
+
const clients = await admin.listClients(partitionOf(c));
|
|
65
|
+
return Response.json({ clients });
|
|
66
|
+
}
|
|
67
|
+
catch (error) {
|
|
68
|
+
return jsonError(error);
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
app.get('/commits', async (c) => {
|
|
72
|
+
try {
|
|
73
|
+
const table = c.req.query('table');
|
|
74
|
+
const commits = await admin.listCommits(partitionOf(c), {
|
|
75
|
+
afterSeq: intParam(c.req.query('afterSeq'), 0),
|
|
76
|
+
limit: intParam(c.req.query('limit'), 50),
|
|
77
|
+
...(table !== undefined ? { table } : {}),
|
|
78
|
+
});
|
|
79
|
+
return Response.json({ commits });
|
|
80
|
+
}
|
|
81
|
+
catch (error) {
|
|
82
|
+
return jsonError(error);
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
app.get('/rows/:table/:rowId', async (c) => {
|
|
86
|
+
try {
|
|
87
|
+
const row = await admin.inspectRow(partitionOf(c), c.req.param('table'), c.req.param('rowId'));
|
|
88
|
+
return Response.json({ row });
|
|
89
|
+
}
|
|
90
|
+
catch (error) {
|
|
91
|
+
return jsonError(error);
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
app.get('/scope-activity', async (c) => {
|
|
95
|
+
try {
|
|
96
|
+
const variable = c.req.query('variable');
|
|
97
|
+
const value = c.req.query('value');
|
|
98
|
+
if (variable === undefined || value === undefined) {
|
|
99
|
+
throw new SyncError('sync.invalid_request', 'variable and value are required');
|
|
100
|
+
}
|
|
101
|
+
const activity = await admin.scopeActivity(partitionOf(c), { variable, value }, { limit: intParam(c.req.query('limit'), 50) });
|
|
102
|
+
return Response.json({ activity });
|
|
103
|
+
}
|
|
104
|
+
catch (error) {
|
|
105
|
+
return jsonError(error);
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
app.get('/horizon', async (c) => {
|
|
109
|
+
try {
|
|
110
|
+
const status = await admin.horizonStatus(partitionOf(c));
|
|
111
|
+
return Response.json({ horizon: status });
|
|
112
|
+
}
|
|
113
|
+
catch (error) {
|
|
114
|
+
return jsonError(error);
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
app.get('/stats', async (c) => {
|
|
118
|
+
try {
|
|
119
|
+
const stats = await admin.stats(partitionOf(c));
|
|
120
|
+
return Response.json({ stats });
|
|
121
|
+
}
|
|
122
|
+
catch (error) {
|
|
123
|
+
return jsonError(error);
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
app.get('/events', async (c) => {
|
|
127
|
+
try {
|
|
128
|
+
const type = c.req.query('type');
|
|
129
|
+
const sinceMs = c.req.query('sinceMs');
|
|
130
|
+
const events = admin.events({
|
|
131
|
+
...(type !== undefined ? { type } : {}),
|
|
132
|
+
...(sinceMs !== undefined ? { sinceMs: intParam(sinceMs, 0) } : {}),
|
|
133
|
+
limit: intParam(c.req.query('limit'), 200),
|
|
134
|
+
});
|
|
135
|
+
return Response.json({ events, hasEventStream: admin.hasEventStream });
|
|
136
|
+
}
|
|
137
|
+
catch (error) {
|
|
138
|
+
return jsonError(error);
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
return app;
|
|
142
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
2
|
+
* Hono adapter (REVISE B2): a thin wrapper proving the embed boundary.
|
|
3
|
+
* Hono is a dependency of this adapter only, never of the server core.
|
|
4
|
+
* Mounts the §1.1 routes: POST /sync and GET /segments/:segmentId
|
|
5
|
+
* (realtime upgrades are runtime-specific and stay with the host).
|
|
6
6
|
*/
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
export * from './
|
|
10
|
-
export
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
7
|
+
import { type SyncServerConfig } from '@syncular/server';
|
|
8
|
+
import { Hono } from 'hono';
|
|
9
|
+
export * from './admin.js';
|
|
10
|
+
export interface SyncularHonoOptions {
|
|
11
|
+
readonly config: SyncServerConfig;
|
|
12
|
+
/** Host authentication (§1.1); `null` ⇒ 401 `sync.auth_required`. */
|
|
13
|
+
readonly authenticate: (request: Request) => Promise<{
|
|
14
|
+
actorId: string;
|
|
15
|
+
partition: string;
|
|
16
|
+
} | null>;
|
|
17
|
+
}
|
|
18
|
+
export declare function createSyncularHono(options: SyncularHonoOptions): Hono;
|