diff --git a/lib/client.js b/lib/client.js index f3da622..372c097 100644 --- a/lib/client.js +++ b/lib/client.js @@ -148,7 +148,7 @@ function cancelTimeout(handle) { } /** One JSON request to the host half. */ -function rpc(method, args) { +function rpc(method, args, query) { let timedOut = false; const controller = new AbortController(); const timer = scheduleTimeout(() => { @@ -156,7 +156,10 @@ function rpc(method, args) { controller.abort(); }, RPC_TIMEOUT_MS); const done = () => cancelTimeout(timer); - return fetch(RPC_BASE + '/' + method, { + const suffix = query && Object.keys(query).length > 0 + ? '?' + new URLSearchParams(query).toString() + : ''; + return fetch(RPC_BASE + '/' + method + suffix, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(args ?? {}), @@ -223,16 +226,36 @@ function installStyles() { */ function useSessionId(props) { return React.useMemo(() => { + // Why the fallback chain exists: the host route answers `total: null` for an + // id it does not know, and the pill then stays silent -- so a wrong id here + // looks exactly like "nothing spent yet" unless it is reported. + const seen = []; + const record = (source, value) => seen.push(source + '=' + JSON.stringify(value ?? null)); + let resolved = ''; try { - if (typeof props.sessionId === 'string' && props.sessionId.length > 0) return props.sessionId; - if (props.session && typeof props.session.sessionId === 'string') return props.session.sessionId; - const sessions = runtimeCtx && runtimeCtx.get ? runtimeCtx.get('sessions') : null; - const current = sessions && sessions.list ? sessions.list.getSnapshot().current : null; - if (typeof current === 'string' && current.length > 0) return current; + record('props.sessionId', props.sessionId); + record('props.session.sessionId', props.session && props.session.sessionId); + record('props keys', Object.keys(props).slice(0, 12)); + + if (typeof props.sessionId === 'string' && props.sessionId.length > 0) resolved = props.sessionId; + else if (props.session && typeof props.session.sessionId === 'string') resolved = props.session.sessionId; + else { + const sessions = runtimeCtx && runtimeCtx.get ? runtimeCtx.get('sessions') : null; + const current = sessions && sessions.list ? sessions.list.getSnapshot().current : null; + record('sessions.list.current', current); + if (typeof current === 'string' && current.length > 0) resolved = current; + } } catch (err) { - /* fall through to the empty id, which the host answers with null */ + record('error', String((err && err.message) || err)); } - return ''; + seen.push('resolved=' + JSON.stringify(resolved)); + // Report once per distinct outcome so the console stays readable. + const summary = seen.join(' | '); + if (globalThis.__dshSessionCostId !== summary) { + globalThis.__dshSessionCostId = summary; + console.info('[dsh-session-cost] session id: ' + summary); + } + return resolved; }, [props.sessionId, props.session]); } @@ -244,14 +267,24 @@ function useSessionCost(sessionId) { let timer = null; const load = () => { - rpc('session', { sessionId }) + // The id goes in the query string as well as the body: the route reads + // either, so this keeps working against a host that predates the fix. + rpc('session', { sessionId }, { sessionId }) .then((data) => { if (cancelled) return; setState({ total: data && typeof data.total === 'number' ? data.total : null, loading: false, error: null }); }) - .catch(() => { + .catch((err) => { if (cancelled) return; - setState((prev) => ({ total: prev.total, loading: false, error: 'unavailable' })); + // Say why, once per distinct failure, instead of silently keeping the + // pill empty: "no spend yet" and "cannot reach the host" are + // indistinguishable otherwise, and only one of them is normal. + const why = String((err && err.message) || err); + if (globalThis.__dshSessionCostError !== why) { + globalThis.__dshSessionCostError = why; + console.warn('[dsh-session-cost] cannot read ' + RPC_BASE + '/session: ' + why); + } + setState((prev) => ({ total: prev.total, loading: false, error: why })); }) .finally(() => { if (cancelled) return; diff --git a/lib/index.js b/lib/index.js index e1005a8..e6d6eb5 100644 --- a/lib/index.js +++ b/lib/index.js @@ -149,11 +149,17 @@ export default { for (const [id, cost] of Object.entries(parsed.costs ?? {})) { costByGeneration.set(id, cost); } - for (const [id, total] of Object.entries(parsed.sessions ?? {})) { + // Sessions are stored as objects so the per-purpose split survives a + // restart; older ledgers wrote a bare number, which is still accepted. + for (const [id, value] of Object.entries(parsed.sessions ?? {})) { + if (typeof value === 'number') { + sessionTotals.set(id, { total: finiteAmount(value) ?? 0, calls: 0, byPurpose: {} }); + continue; + } sessionTotals.set(id, { - total: finiteAmount(total) ?? 0, - calls: 0, - byPurpose: {}, + total: finiteAmount(value?.total) ?? 0, + calls: typeof value?.calls === 'number' ? value.calls : 0, + byPurpose: value?.byPurpose && typeof value.byPurpose === 'object' ? { ...value.byPurpose } : {}, }); } for (const id of parsed.settled ?? []) settled.add(id); @@ -172,7 +178,10 @@ export default { version: 1, costs: Object.fromEntries(costByGeneration), sessions: Object.fromEntries( - [...sessionTotals].map(([id, value]) => [id, value.total]), + [...sessionTotals].map(([id, value]) => [ + id, + { total: value.total, calls: value.calls, byPurpose: value.byPurpose }, + ]), ), settled: [...settled], }; @@ -417,6 +426,36 @@ export default { res.end(body); } + /** Read a request body, capped: this route only ever carries a session id. */ + function readJsonBody(req, limit = 16 * 1024) { + return new Promise((resolve) => { + const chunks = []; + let size = 0; + req.on('data', (chunk) => { + size += chunk.length; + if (size > limit) { + resolve({}); + return; + } + chunks.push(chunk); + }); + req.on('end', () => { + const raw = Buffer.concat(chunks).toString('utf8').trim(); + if (raw.length === 0) { + resolve({}); + return; + } + try { + const parsed = JSON.parse(raw); + resolve(parsed && typeof parsed === 'object' ? parsed : {}); + } catch { + resolve({}); + } + }); + req.on('error', () => resolve({})); + }); + } + ctx.inject(['connection', 'webServer'], (webCtx) => { webCtx.effect(() => { const dispose = webCtx.webServer.register({ @@ -435,7 +474,11 @@ export default { respond(res, 404, { error: 'unknown method' }); return; } - const sessionId = url.searchParams.get('sessionId') ?? ''; + // The client posts a JSON body; the query string is kept as a + // fallback so either shape works. + const body = req.method === 'POST' || req.method === 'PUT' ? await readJsonBody(req) : {}; + const raw = body.sessionId ?? url.searchParams.get('sessionId') ?? ''; + const sessionId = typeof raw === 'string' ? raw : String(raw ?? ''); respond(res, 200, tail === '/session' ? snapshot(sessionId) : snapshot('')); } catch (error) { respond(res, 500, { error: String(error?.message ?? error) });