@vimoxshah/tokenflow 1.1.1 → 1.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/CHANGELOG.md +228 -0
- package/Dockerfile.team +20 -0
- package/README.md +30 -11
- package/bin/tokenflow.js +147 -12
- package/design/tokens.yaml +330 -0
- package/docs/architecture.md +5 -4
- package/docs/cli.md +204 -0
- package/docs/configuration.md +117 -2
- package/docs/design-system.md +187 -0
- package/docs/exports-and-budgets.md +85 -0
- package/docs/guard-codex.md +132 -0
- package/docs/ledger.md +144 -0
- package/docs/live-mode.md +40 -0
- package/docs/media/overview-aurora-dark.png +0 -0
- package/docs/media/receipts-aurora-dark.png +0 -0
- package/docs/providers-otel.md +179 -0
- package/docs/providers.md +54 -1
- package/docs/receipt-schema.md +74 -0
- package/docs/roadmap.md +182 -0
- package/docs/team-server.md +170 -0
- package/docs/ui-views.md +322 -0
- package/package.json +7 -2
- package/schemas/receipt.v0.json +160 -0
- package/scripts/build-dmg.sh +11 -2
- package/scripts/build-menubar-app.sh +58 -7
- package/scripts/design-build.js +475 -0
- package/src/analytics/anatomy.js +467 -0
- package/src/analytics/branch-compare.js +159 -0
- package/src/analytics/cache-health.js +141 -0
- package/src/analytics/live-view.js +266 -0
- package/src/analytics/receipt-schema.js +214 -0
- package/src/analytics/receipt.js +709 -0
- package/src/analytics/rhythm.js +184 -0
- package/src/analytics/whatif.js +263 -0
- package/src/commands/budget-scopes.js +133 -0
- package/src/commands/doctor-checks.js +400 -0
- package/src/commands/guard.js +531 -0
- package/src/commands/hooks.js +238 -0
- package/src/commands/pricing-diff.js +316 -0
- package/src/commands/receipt.js +226 -0
- package/src/commands/team-serve.js +407 -0
- package/src/commands/week.js +86 -0
- package/src/core/annotations.js +97 -0
- package/src/core/budget.js +33 -0
- package/src/core/bundle.js +45 -2
- package/src/core/ingest.js +33 -0
- package/src/core/live-status.js +227 -2
- package/src/core/policy.js +103 -0
- package/src/core/receipt-note.js +123 -0
- package/src/core/repo.js +64 -0
- package/src/core/sync.js +163 -26
- package/src/core/team.js +0 -0
- package/src/export/html-snapshot.js +28 -1
- package/src/export/menubar.js +21 -0
- package/src/export/receipt-card.js +210 -0
- package/src/export/week-card.js +185 -0
- package/src/providers/mock/index.js +383 -52
- package/src/providers/openai/index.js +31 -1
- package/src/providers/otel/index.js +656 -0
- package/src/server/routes/annotations.js +42 -0
- package/src/server/routes/cache-health.js +95 -0
- package/src/server/routes/index.js +54 -0
- package/src/server/routes/session.js +157 -0
- package/src/server/server.js +47 -1
- package/src/ui/app.js +541 -308
- package/src/ui/charts.js +95 -0
- package/src/ui/first-run.js +144 -0
- package/src/ui/index.html +4 -1
- package/src/ui/palette.js +335 -0
- package/src/ui/styles/anatomy.css +117 -0
- package/src/ui/styles/annotations.css +40 -0
- package/src/ui/styles/branches.css +99 -0
- package/src/ui/styles/cache.css +6 -0
- package/src/ui/styles/first-run.css +31 -0
- package/src/ui/styles/live.css +100 -0
- package/src/ui/styles/palette.css +85 -0
- package/src/ui/styles/rhythm.css +8 -0
- package/src/ui/styles/whatif.css +55 -0
- package/src/ui/styles.css +303 -196
- package/src/ui/views/anatomy.js +567 -0
- package/src/ui/views/annotations.js +121 -0
- package/src/ui/views/branches.js +304 -0
- package/src/ui/views/cache.js +232 -0
- package/src/ui/views/index.js +85 -0
- package/src/ui/views/live.js +683 -0
- package/src/ui/views/rhythm.js +206 -0
- package/src/ui/views/whatif.js +196 -0
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Day annotations API.
|
|
3
|
+
*
|
|
4
|
+
* GET returns the whole file; POST adds or removes one entry. Validation and
|
|
5
|
+
* storage live in src/core/annotations.js — this file only shapes the HTTP
|
|
6
|
+
* request/response and turns a validation Error into a 400.
|
|
7
|
+
*/
|
|
8
|
+
import { readAnnotations, addAnnotation, removeAnnotation } from '../../core/annotations.js';
|
|
9
|
+
|
|
10
|
+
/** @type {import('./index.js').Route[]} */
|
|
11
|
+
export const ANNOTATIONS_ROUTES = [
|
|
12
|
+
{
|
|
13
|
+
method: 'GET',
|
|
14
|
+
path: '/api/annotations',
|
|
15
|
+
handler: (req, res, url, ctx) => ctx.json(readAnnotations()),
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
method: 'POST',
|
|
19
|
+
path: '/api/annotations',
|
|
20
|
+
handler: async (req, res, url, ctx) => {
|
|
21
|
+
let body;
|
|
22
|
+
try {
|
|
23
|
+
body = JSON.parse((await ctx.readBody()) || '{}');
|
|
24
|
+
} catch {
|
|
25
|
+
return ctx.json({ error: 'invalid JSON body' }, 400);
|
|
26
|
+
}
|
|
27
|
+
try {
|
|
28
|
+
if (body.op === 'add') {
|
|
29
|
+
const item = addAnnotation({ date: body.date, text: body.text });
|
|
30
|
+
return ctx.json({ ok: true, item, ...readAnnotations() });
|
|
31
|
+
}
|
|
32
|
+
if (body.op === 'remove') {
|
|
33
|
+
const removed = removeAnnotation(body.id);
|
|
34
|
+
return ctx.json({ ok: true, removed, ...readAnnotations() });
|
|
35
|
+
}
|
|
36
|
+
return ctx.json({ error: `unknown op "${body.op}"` }, 400);
|
|
37
|
+
} catch (err) {
|
|
38
|
+
return ctx.json({ error: err.message }, 400);
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
];
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GET /api/cache-health?from=YYYY-MM-DD&to=YYYY-MM-DD
|
|
3
|
+
*
|
|
4
|
+
* Churn detection: scans primary records in the given window, groups them by
|
|
5
|
+
* session in timestamp order, and reports every turn that invalidated and
|
|
6
|
+
* rewrote the prompt cache at a premium (src/analytics/cache-health.js has
|
|
7
|
+
* the exact rule). This needs request-level records, which never leave the
|
|
8
|
+
* machine and never ship in a snapshot, so it is a live-dashboard-only route.
|
|
9
|
+
*
|
|
10
|
+
* A record with no session_id cannot be placed in a turn sequence, so it is
|
|
11
|
+
* left out of every group rather than guessed at.
|
|
12
|
+
*/
|
|
13
|
+
import { Store, decodeRecord, readJson } from '../../core/store.js';
|
|
14
|
+
import { buildPriceBook } from '../../core/pricing.js';
|
|
15
|
+
import { MEASUREMENT } from '../../core/schema.js';
|
|
16
|
+
import { detectChurn, summarize } from '../../analytics/cache-health.js';
|
|
17
|
+
|
|
18
|
+
/** Hard ceiling on how many records one request scans. */
|
|
19
|
+
const MAX_SCAN_RECORDS = 200000;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* `YYYY-MM` for every month between `from` and `to`, inclusive. Returns
|
|
23
|
+
* `undefined` (no restriction) when either bound is missing, matching how
|
|
24
|
+
* `Store#scanRecords`'s `months` option treats a falsy value.
|
|
25
|
+
* @param {string|null} from
|
|
26
|
+
* @param {string|null} to
|
|
27
|
+
*/
|
|
28
|
+
function monthsBetween(from, to) {
|
|
29
|
+
if (!from || !to) return undefined;
|
|
30
|
+
const out = [];
|
|
31
|
+
let y = Number(from.slice(0, 4));
|
|
32
|
+
let m = Number(from.slice(5, 7));
|
|
33
|
+
const ey = Number(to.slice(0, 4));
|
|
34
|
+
const em = Number(to.slice(5, 7));
|
|
35
|
+
let guard = 0;
|
|
36
|
+
while ((y < ey || (y === ey && m <= em)) && guard++ < 600) {
|
|
37
|
+
out.push(`${y}-${String(m).padStart(2, '0')}`);
|
|
38
|
+
m++;
|
|
39
|
+
if (m > 12) { m = 1; y++; }
|
|
40
|
+
}
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function handleCacheHealth(req, res, url, ctx) {
|
|
45
|
+
const from = url.searchParams.get('from') || null;
|
|
46
|
+
const to = url.searchParams.get('to') || null;
|
|
47
|
+
|
|
48
|
+
const store = new Store();
|
|
49
|
+
const book = buildPriceBook(readJson(ctx.paths.pricing, {}));
|
|
50
|
+
const months = monthsBetween(from, to);
|
|
51
|
+
|
|
52
|
+
const bySession = new Map();
|
|
53
|
+
let scanned = 0;
|
|
54
|
+
let truncated = false;
|
|
55
|
+
store.scanRecords((o) => {
|
|
56
|
+
scanned++;
|
|
57
|
+
if (scanned > MAX_SCAN_RECORDS) { truncated = true; return false; }
|
|
58
|
+
if (from && o.d < from) return;
|
|
59
|
+
if (to && o.d > to) return;
|
|
60
|
+
if (o.ms !== MEASUREMENT.PRIMARY) return;
|
|
61
|
+
if (!o.s) return; // no session id: cannot be placed in a turn sequence
|
|
62
|
+
const r = decodeRecord(o);
|
|
63
|
+
let group = bySession.get(r.session_id);
|
|
64
|
+
if (!group) { group = []; bySession.set(r.session_id, group); }
|
|
65
|
+
group.push(r);
|
|
66
|
+
}, { months });
|
|
67
|
+
|
|
68
|
+
const events = [];
|
|
69
|
+
for (const group of bySession.values()) events.push(...detectChurn(group, book));
|
|
70
|
+
// Highest estimated premium first; unpriced events (premiumUsd: null) sink
|
|
71
|
+
// to the bottom rather than sorting arbitrarily.
|
|
72
|
+
events.sort((a, b) => {
|
|
73
|
+
if (a.premiumUsd === null && b.premiumUsd === null) return 0;
|
|
74
|
+
if (a.premiumUsd === null) return 1;
|
|
75
|
+
if (b.premiumUsd === null) return -1;
|
|
76
|
+
return b.premiumUsd - a.premiumUsd;
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
ctx.json({
|
|
80
|
+
from,
|
|
81
|
+
to,
|
|
82
|
+
scanned,
|
|
83
|
+
truncated,
|
|
84
|
+
events,
|
|
85
|
+
summary: summarize(events),
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export const CACHE_HEALTH_ROUTES = [
|
|
90
|
+
{
|
|
91
|
+
method: 'GET',
|
|
92
|
+
path: '/api/cache-health',
|
|
93
|
+
handler: handleCacheHealth,
|
|
94
|
+
},
|
|
95
|
+
];
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The dashboard server's route registry.
|
|
3
|
+
*
|
|
4
|
+
* A registered route is an API endpoint that lives outside server.js. The
|
|
5
|
+
* server dispatches to a matching entry before its own inline chain, so a new
|
|
6
|
+
* tab can ship its own endpoint without anyone editing server.js — the same
|
|
7
|
+
* reason src/ui/views/ exists for the UI side.
|
|
8
|
+
*
|
|
9
|
+
* A handler is called as `handler(req, res, url, ctx)` and owns the response
|
|
10
|
+
* from that point on: nothing after it runs. Registered GET routes get exactly
|
|
11
|
+
* the protection inline GET routes get today, which is loopback binding and
|
|
12
|
+
* nothing else; a route that changes state must be POST, so it is covered by
|
|
13
|
+
* the same-origin/token check server.js applies to every non-GET request.
|
|
14
|
+
*
|
|
15
|
+
* The full contract is in docs/ui-views.md under "Server routes".
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The helpers a route handler is given. `config` and `paths` are lazy: they are
|
|
20
|
+
* only read from disk if the handler actually asks for them.
|
|
21
|
+
*
|
|
22
|
+
* @typedef {object} RouteContext
|
|
23
|
+
* @property {(body:any, code?:number)=>void} json respond with JSON.
|
|
24
|
+
* @property {(code:number, type:string, body:any)=>void} send respond with anything else.
|
|
25
|
+
* @property {(file:string)=>void} sendFile respond with a file from disk.
|
|
26
|
+
* @property {()=>Promise<string>} readBody read the request body as text.
|
|
27
|
+
* @property {object} config the loaded config (lazy, cached per request).
|
|
28
|
+
* @property {object} paths the resolved store paths (lazy, cached per request).
|
|
29
|
+
* @property {(opt?:{config?:object, receipts?:boolean})=>any} buildBundle build the aggregate bundle the UI loads. `receipts: false` skips the whole-store receipt scan, which costs seconds a route that only needs sessions should not pay.
|
|
30
|
+
* @property {(query:object)=>object} queryRecords the record query behind /api/records.
|
|
31
|
+
* @property {string} root the repository root.
|
|
32
|
+
* @property {string} host the bound host.
|
|
33
|
+
* @property {number} port the bound port (correct even when the server bound port 0).
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* One endpoint. `path` is matched exactly; there is no pattern syntax, because
|
|
38
|
+
* a query string is a better fit for everything this dashboard needs.
|
|
39
|
+
*
|
|
40
|
+
* @typedef {object} Route
|
|
41
|
+
* @property {string} method 'GET', 'POST', … matched case-insensitively.
|
|
42
|
+
* @property {string} path e.g. '/api/receipts'.
|
|
43
|
+
* @property {(req: import('node:http').IncomingMessage, res: import('node:http').ServerResponse, url: URL, ctx: RouteContext) => any} handler
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
import { ANNOTATIONS_ROUTES } from './annotations.js';
|
|
47
|
+
import { CACHE_HEALTH_ROUTES } from './cache-health.js';
|
|
48
|
+
import { SESSION_ROUTE } from './session.js';
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Every registered route. Order does not matter: paths are matched exactly.
|
|
52
|
+
* @type {Route[]}
|
|
53
|
+
*/
|
|
54
|
+
export const ROUTES = [...ANNOTATIONS_ROUTES, ...CACHE_HEALTH_ROUTES, SESSION_ROUTE];
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GET /api/session?id=<sessionId>
|
|
3
|
+
*
|
|
4
|
+
* One session's primary records, in timestamp order, for the Session anatomy
|
|
5
|
+
* tab. The cube has no notion of a turn, so this is the only way to see where
|
|
6
|
+
* a single session's money went — and request-level records never ship in the
|
|
7
|
+
* bundle or the snapshot, which is why this is a live-dashboard-only route.
|
|
8
|
+
*
|
|
9
|
+
* Two things this route deliberately does not do:
|
|
10
|
+
*
|
|
11
|
+
* - It never returns prompt or code content. The stored records hold none,
|
|
12
|
+
* and the projection below is an allow-list rather than a delete-list so
|
|
13
|
+
* that a future metadata field cannot leak in by being added upstream.
|
|
14
|
+
* - It never scans the whole store. The session's own date range comes from
|
|
15
|
+
* the bundle's session rows, and only the months that range touches are
|
|
16
|
+
* read (widened by a day at each end, because a session row's date is a
|
|
17
|
+
* local calendar date and a record's timestamp is UTC).
|
|
18
|
+
*
|
|
19
|
+
* One field is served beyond the set this route was specified with:
|
|
20
|
+
* `service_tier`. The browser prices each turn itself from the price book, and
|
|
21
|
+
* the tier is a multiplier on that price (OpenAI priority and fast are 4x,
|
|
22
|
+
* batch is 0.5x). Without it the anatomy tab would quietly disagree with the
|
|
23
|
+
* Cost tab on exactly the sessions that cost the most. It is a billing
|
|
24
|
+
* dimension the cube already carries, not content.
|
|
25
|
+
*/
|
|
26
|
+
import { Store } from '../../core/store.js';
|
|
27
|
+
import { MEASUREMENT } from '../../core/schema.js';
|
|
28
|
+
import { addDays } from '../../analytics/aggregate.js';
|
|
29
|
+
|
|
30
|
+
/** Most turns one response will carry. Reported in the body, never silent. */
|
|
31
|
+
export const RECORD_CAP = 5000;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The key `Store#upsertSession` filed a record under. A source with no session
|
|
35
|
+
* id gets a synthetic one, so matching on `o.s` alone would never find those
|
|
36
|
+
* sessions.
|
|
37
|
+
* @param {object} o an encoded record
|
|
38
|
+
*/
|
|
39
|
+
function sessionKeyOf(o) {
|
|
40
|
+
return o.s || `${o.so}:${o.d}:${o.pj || 'unknown'}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** `YYYY-MM` for every month between two dates, inclusive. */
|
|
44
|
+
function monthsBetween(from, to) {
|
|
45
|
+
const out = [];
|
|
46
|
+
let y = Number(from.slice(0, 4));
|
|
47
|
+
let m = Number(from.slice(5, 7));
|
|
48
|
+
const ey = Number(to.slice(0, 4));
|
|
49
|
+
const em = Number(to.slice(5, 7));
|
|
50
|
+
let guard = 0;
|
|
51
|
+
while ((y < ey || (y === ey && m <= em)) && guard++ < 600) {
|
|
52
|
+
out.push(`${y}-${String(m).padStart(2, '0')}`);
|
|
53
|
+
m++;
|
|
54
|
+
if (m > 12) { m = 1; y++; }
|
|
55
|
+
}
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The calendar date part of an ISO timestamp, or null. */
|
|
60
|
+
function dateOf(iso) {
|
|
61
|
+
return typeof iso === 'string' && iso.length >= 10 ? iso.slice(0, 10) : null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The fields the anatomy view is allowed to see. Everything else — metadata,
|
|
66
|
+
* cwd, titles, ids that identify a machine or a person — stays on disk.
|
|
67
|
+
* @param {object} o an encoded record
|
|
68
|
+
*/
|
|
69
|
+
function slim(o) {
|
|
70
|
+
const meta = o.x || {};
|
|
71
|
+
return {
|
|
72
|
+
ts: o.ts ?? null,
|
|
73
|
+
model: o.m ?? null,
|
|
74
|
+
provider: o.p ?? null,
|
|
75
|
+
source: o.so ?? null,
|
|
76
|
+
input_tokens: o.in ?? null,
|
|
77
|
+
cache_read_tokens: o.cr ?? null,
|
|
78
|
+
cache_write_tokens: o.cw ?? null,
|
|
79
|
+
cache_refresh_tokens: o.cf ?? null,
|
|
80
|
+
output_tokens: o.ou ?? null,
|
|
81
|
+
reasoning_tokens: o.rs ?? null,
|
|
82
|
+
// The billing tier changes what an identical turn costs (OpenAI priority
|
|
83
|
+
// is 4x), so pricing in the browser needs it to agree with the Cost tab.
|
|
84
|
+
service_tier: o.tr ?? null,
|
|
85
|
+
category: o.k ?? null,
|
|
86
|
+
request_id: o.rq ?? null,
|
|
87
|
+
// No adapter records a parent link today: Claude Code marks a sidechain and
|
|
88
|
+
// OpenCode/Hermes fold their parent id into `category`. The pair is carried
|
|
89
|
+
// anyway so the fan-out becomes a real tree the day one does.
|
|
90
|
+
agent: meta.agent ?? meta.agent_role ?? null,
|
|
91
|
+
parent: meta.parent_id ?? meta.parent_session_id ?? null,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function handleSession(req, res, url, ctx) {
|
|
96
|
+
const id = url.searchParams.get('id');
|
|
97
|
+
if (!id) return ctx.json({ error: 'id is required' }, 400);
|
|
98
|
+
|
|
99
|
+
// Session rows only; the receipt scan behind the full bundle costs seconds
|
|
100
|
+
// and this route never looks at it.
|
|
101
|
+
const sessions = ctx.buildBundle({ receipts: false }).sessions || [];
|
|
102
|
+
const row = sessions.find((s) => s.id === id);
|
|
103
|
+
if (!row) return ctx.json({ error: `no session "${id}"` }, 404);
|
|
104
|
+
|
|
105
|
+
const first = row.d || dateOf(row.start);
|
|
106
|
+
const last = dateOf(row.end) || first;
|
|
107
|
+
if (!first || !last) return ctx.json({ error: `session "${id}" has no date range` }, 404);
|
|
108
|
+
const from = addDays(first < last ? first : last, -1);
|
|
109
|
+
const to = addDays(first > last ? first : last, 1);
|
|
110
|
+
const months = monthsBetween(from, to);
|
|
111
|
+
|
|
112
|
+
const store = new Store();
|
|
113
|
+
const matched = [];
|
|
114
|
+
let scanned = 0;
|
|
115
|
+
store.scanRecords((o) => {
|
|
116
|
+
scanned++;
|
|
117
|
+
if (o.ms !== MEASUREMENT.PRIMARY) return;
|
|
118
|
+
if (sessionKeyOf(o) !== id) return;
|
|
119
|
+
matched.push(o);
|
|
120
|
+
}, { months });
|
|
121
|
+
|
|
122
|
+
matched.sort((a, b) => (a.ts === b.ts ? 0 : (a.ts ?? '') < (b.ts ?? '') ? -1 : 1));
|
|
123
|
+
const records = matched.slice(0, RECORD_CAP).map(slim);
|
|
124
|
+
|
|
125
|
+
ctx.json({
|
|
126
|
+
id,
|
|
127
|
+
session: {
|
|
128
|
+
id: row.id,
|
|
129
|
+
source: row.so ?? null,
|
|
130
|
+
project: row.pj ?? null,
|
|
131
|
+
repository: row.rp ?? null,
|
|
132
|
+
branch: row.br ?? null,
|
|
133
|
+
model: row.m ?? null,
|
|
134
|
+
provider: row.p ?? null,
|
|
135
|
+
client: row.c ?? null,
|
|
136
|
+
interface: row.i ?? null,
|
|
137
|
+
start: row.start ?? null,
|
|
138
|
+
end: row.end ?? null,
|
|
139
|
+
date: row.d ?? null,
|
|
140
|
+
requests: row.req ?? null,
|
|
141
|
+
},
|
|
142
|
+
months,
|
|
143
|
+
scanned,
|
|
144
|
+
total: matched.length,
|
|
145
|
+
returned: records.length,
|
|
146
|
+
cap: RECORD_CAP,
|
|
147
|
+
truncated: matched.length > RECORD_CAP,
|
|
148
|
+
records,
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** @type {import('./index.js').Route} */
|
|
153
|
+
export const SESSION_ROUTE = {
|
|
154
|
+
method: 'GET',
|
|
155
|
+
path: '/api/session',
|
|
156
|
+
handler: handleSession,
|
|
157
|
+
};
|
package/src/server/server.js
CHANGED
|
@@ -22,6 +22,7 @@ import { PROVIDER_REGIONS, resolveMyLocation } from '../core/geo.js';
|
|
|
22
22
|
import { normalizeLimits } from '../analytics/capacity.js';
|
|
23
23
|
import { streamRecordsCsv, exportFilename } from '../export/csv.js';
|
|
24
24
|
import { writeJson, readJson } from '../core/store.js';
|
|
25
|
+
import { ROUTES } from './routes/index.js';
|
|
25
26
|
|
|
26
27
|
const MIME = {
|
|
27
28
|
'.html': 'text/html; charset=utf-8',
|
|
@@ -41,6 +42,44 @@ export async function startServer({ port = 7799, host = '127.0.0.1', open = fals
|
|
|
41
42
|
const authToken = token === false ? null : token || randomBytes(12).toString('base64url');
|
|
42
43
|
|
|
43
44
|
let refreshing = false;
|
|
45
|
+
// The real port is only known after listen() when the caller asked for 0.
|
|
46
|
+
let boundPort = port;
|
|
47
|
+
|
|
48
|
+
// Paths the inline chain below owns. A registered route may not take one:
|
|
49
|
+
// registered routes are dispatched first, so the shadowing would be silent
|
|
50
|
+
// and would look nothing like "my new route is wrong".
|
|
51
|
+
const RESERVED = new Set([
|
|
52
|
+
'/', '/index.html', '/api/bundle', '/api/health', '/api/ping', '/api/providers',
|
|
53
|
+
'/api/records', '/api/live', '/api/geo', '/api/config', '/api/export.csv',
|
|
54
|
+
'/api/refresh', '/api/pricing', '/api/prefs',
|
|
55
|
+
]);
|
|
56
|
+
for (const r of ROUTES) {
|
|
57
|
+
if (r && (RESERVED.has(r.path) || String(r.path || '').startsWith('/src/'))) {
|
|
58
|
+
console.warn(`routes: ${r.method} ${r.path} is a built-in path and will shadow it`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The helpers a registered route handler gets. Built per request; config and
|
|
64
|
+
* paths are read lazily so a handler that never asks never pays.
|
|
65
|
+
*/
|
|
66
|
+
function routeContext(req, res) {
|
|
67
|
+
let cfg = null;
|
|
68
|
+
let pth = null;
|
|
69
|
+
return {
|
|
70
|
+
json: (body, code) => json(res, body, code),
|
|
71
|
+
send: (code, type, body) => send(res, code, type, body),
|
|
72
|
+
sendFile: (file) => sendFile(res, file),
|
|
73
|
+
readBody: () => readBody(req),
|
|
74
|
+
get config() { return (cfg ||= loadConfig()); },
|
|
75
|
+
get paths() { return (pth ||= paths()); },
|
|
76
|
+
buildBundle,
|
|
77
|
+
queryRecords,
|
|
78
|
+
root: ROOT,
|
|
79
|
+
host,
|
|
80
|
+
get port() { return boundPort; },
|
|
81
|
+
};
|
|
82
|
+
}
|
|
44
83
|
|
|
45
84
|
const server = http.createServer(async (req, res) => {
|
|
46
85
|
const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
|
|
@@ -54,6 +93,13 @@ export async function startServer({ port = 7799, host = '127.0.0.1', open = fals
|
|
|
54
93
|
if (!sameSite && given !== authToken) return send(res, 403, 'text/plain', 'forbidden');
|
|
55
94
|
}
|
|
56
95
|
|
|
96
|
+
// Registered routes, after the token check and before the inline chain,
|
|
97
|
+
// so a tab added under src/ui/views/ can ship its endpoint without this
|
|
98
|
+
// file growing a branch for it. See src/server/routes/index.js.
|
|
99
|
+
const route = ROUTES.find((r) => r && r.path === p
|
|
100
|
+
&& String(r.method || 'GET').toUpperCase() === req.method);
|
|
101
|
+
if (route) return await route.handler(req, res, url, routeContext(req, res));
|
|
102
|
+
|
|
57
103
|
if (p === '/' || p === '/index.html') {
|
|
58
104
|
return sendFile(res, path.join(UI, 'index.html'));
|
|
59
105
|
}
|
|
@@ -227,7 +273,7 @@ export async function startServer({ port = 7799, host = '127.0.0.1', open = fals
|
|
|
227
273
|
});
|
|
228
274
|
// address() is `string` for a pipe/socket and AddressInfo for TCP.
|
|
229
275
|
const bound = server.address();
|
|
230
|
-
|
|
276
|
+
boundPort = typeof bound === 'object' && bound !== null ? bound.port : port;
|
|
231
277
|
const addr = `http://${host}:${boundPort}`;
|
|
232
278
|
return { server, url: addr, token: authToken, close: () => new Promise((r) => server.close(r)) };
|
|
233
279
|
}
|