@kylindc/ccxray 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.
@@ -0,0 +1,141 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('crypto');
4
+ const config = require('./config');
5
+ const store = require('./store');
6
+ const { calculateCost } = require('./pricing');
7
+ const { extractAgentType, splitB2IntoBlocks } = require('./system-prompt');
8
+
9
+ // ── Lazy-load req/res from disk on demand ────────────────────────────
10
+
11
+ function loadEntryReqRes(entry) {
12
+ if (entry._loaded) return Promise.resolve();
13
+ if (entry._loadingPromise) return entry._loadingPromise;
14
+ entry._loadingPromise = (async () => {
15
+ if (entry._writePromise) await entry._writePromise.catch(() => {});
16
+ try {
17
+ const stripped = JSON.parse(await config.storage.read(entry.id, '_req.json'));
18
+ const sys = stripped.sysHash
19
+ ? await config.storage.readShared(`sys_${stripped.sysHash}.json`).then(JSON.parse).catch(() => null)
20
+ : null;
21
+ const tools = stripped.toolsHash
22
+ ? await config.storage.readShared(`tools_${stripped.toolsHash}.json`).then(JSON.parse).catch(() => null)
23
+ : null;
24
+ entry.req = { ...stripped, system: sys, tools };
25
+ delete entry.req.sysHash;
26
+ delete entry.req.toolsHash;
27
+ } catch { entry.req = null; }
28
+ try {
29
+ const raw = await config.storage.read(entry.id, '_res.json');
30
+ try { entry.res = JSON.parse(raw); } catch { entry.res = raw; }
31
+ } catch { entry.res = null; }
32
+ entry._loaded = true;
33
+ entry._loadingPromise = null;
34
+ })();
35
+ return entry._loadingPromise;
36
+ }
37
+
38
+ // ── Restore entries from index.ndjson on startup ─────────────────────
39
+
40
+ async function restoreFromLogs() {
41
+ console.time('restore:total');
42
+
43
+ // 1. Read the lightweight index (one file read for all metadata)
44
+ console.time('restore:index');
45
+ const indexContent = await config.storage.readIndex();
46
+ console.timeEnd('restore:index');
47
+
48
+ if (!indexContent) {
49
+ console.timeEnd('restore:total');
50
+ return;
51
+ }
52
+
53
+ // 2. Parse index lines and filter by RESTORE_DAYS
54
+ console.time('restore:parse');
55
+ let cutoffStr = null;
56
+ if (config.RESTORE_DAYS > 0) {
57
+ const cutoff = new Date();
58
+ cutoff.setDate(cutoff.getDate() - config.RESTORE_DAYS);
59
+ cutoffStr = cutoff.toLocaleString('sv-SE', { timeZone: 'Asia/Taipei' }).slice(0, 10);
60
+ }
61
+
62
+ const lines = indexContent.split('\n').filter(Boolean);
63
+ let restored = 0;
64
+
65
+ for (const line of lines) {
66
+ let meta;
67
+ try { meta = JSON.parse(line); } catch { continue; }
68
+
69
+ if (cutoffStr && meta.id.slice(0, 10) < cutoffStr) continue;
70
+
71
+ store.entries.push({ ...meta, req: null, res: null, _loaded: false });
72
+
73
+ // Track earliest timestamp per sysHash for version dating
74
+ if (meta.sysHash && meta.id) {
75
+ const existing = store._sysHashDates && store._sysHashDates.get(meta.sysHash);
76
+ if (!existing || meta.id < existing) {
77
+ if (!store._sysHashDates) store._sysHashDates = new Map();
78
+ store._sysHashDates.set(meta.sysHash, meta.id.slice(0, 10));
79
+ }
80
+ }
81
+
82
+ if (meta.sessionId) {
83
+ if (!store.sessionMeta[meta.sessionId]) store.sessionMeta[meta.sessionId] = {};
84
+ if (meta.cwd) store.sessionMeta[meta.sessionId].cwd = meta.cwd;
85
+ if (meta.receivedAt) store.sessionMeta[meta.sessionId].lastSeenAt = meta.receivedAt;
86
+ }
87
+ if (meta.cost?.cost != null && meta.sessionId) {
88
+ store.sessionCosts.set(meta.sessionId, (store.sessionCosts.get(meta.sessionId) || 0) + meta.cost.cost);
89
+ }
90
+ restored++;
91
+ }
92
+ store.trimEntries();
93
+ console.timeEnd('restore:parse');
94
+
95
+ // 3. Build version index from shared/ system prompts (scans a handful of small files)
96
+ console.time('restore:versions');
97
+ await buildVersionIndex();
98
+ console.timeEnd('restore:versions');
99
+
100
+ console.timeEnd('restore:total');
101
+ if (restored) {
102
+ const msg = config.RESTORE_DAYS > 0
103
+ ? `Restored ${restored} entries from last ${config.RESTORE_DAYS} days`
104
+ : `Restored ${restored} entries from index`;
105
+ console.log(`\x1b[90m ${msg}\x1b[0m`);
106
+ }
107
+ }
108
+
109
+ async function buildVersionIndex() {
110
+ let sharedFiles;
111
+ try { sharedFiles = await config.storage.listShared(); } catch { return; }
112
+
113
+ for (const filename of sharedFiles) {
114
+ if (!filename.startsWith('sys_')) continue;
115
+ try {
116
+ const sys = JSON.parse(await config.storage.readShared(filename));
117
+ if (!Array.isArray(sys) || sys.length < 3) continue;
118
+ const b0 = sys[0]?.text || '';
119
+ const b2 = sys[2]?.text || '';
120
+ const m = b0.match(/cc_version=(\S+?)[; ]/);
121
+ const ver = m ? m[1] : null;
122
+ const { key: agentKey, label: agentLabel } = extractAgentType(sys);
123
+ if (ver && b2.length >= 500) {
124
+ const idxKey = `${agentKey}::${ver}`;
125
+ const existing = store.versionIndex.get(idxKey);
126
+ if (!existing || b2.length > existing.b2Len) {
127
+ const coreText = splitB2IntoBlocks(b2).coreInstructions || '';
128
+ const coreLen = coreText.length;
129
+ const coreHash = crypto.createHash('md5').update(coreText).digest('hex').slice(0, 12);
130
+ store.versionIndex.set(idxKey, {
131
+ reqId: null, sharedFile: filename, b2Len: b2.length, coreLen, coreHash,
132
+ firstSeen: filename.slice(4, 14) || '?',
133
+ agentKey, agentLabel, version: ver,
134
+ });
135
+ }
136
+ }
137
+ } catch {}
138
+ }
139
+ }
140
+
141
+ module.exports = { loadEntryReqRes, restoreFromLogs };
@@ -0,0 +1,123 @@
1
+ 'use strict';
2
+
3
+ const config = require('../config');
4
+ const store = require('../store');
5
+ const { summarizeEntry } = require('../sse-broadcast');
6
+ const { loadEntryReqRes } = require('../restore');
7
+ const { tokenizeRequest } = require('../helpers');
8
+ const { computeBlockDiff } = require('../system-prompt');
9
+
10
+ function handleApiRoutes(clientReq, clientRes) {
11
+ if (clientReq.url === '/_api/config') {
12
+ clientRes.writeHead(200, { 'Content-Type': 'application/json' });
13
+ clientRes.end(JSON.stringify({ bedrockMode: !!config.IS_BEDROCK_MODE }));
14
+ return true;
15
+ }
16
+
17
+ if (clientReq.url === '/_api/entries') {
18
+ clientRes.writeHead(200, { 'Content-Type': 'application/json' });
19
+ clientRes.end(JSON.stringify(store.entries.map(summarizeEntry)));
20
+ return true;
21
+ }
22
+
23
+ if (clientReq.url.startsWith('/_api/sysprompt/versions')) {
24
+ const urlParams = new URLSearchParams(clientReq.url.split('?')[1] || '');
25
+ const filterAgent = urlParams.get('agent') || null;
26
+ const allAgents = [...new Set([...store.versionIndex.values()].map(v => v.agentKey))].sort();
27
+ const vEntries = [...store.versionIndex.values()]
28
+ .filter(v => !filterAgent || v.agentKey === filterAgent)
29
+ .sort((a, b) => b.version.localeCompare(a.version));
30
+ const versions = vEntries.map(({ version, reqId, b2Len, coreLen, firstSeen, agentKey, agentLabel }) => ({ version, reqId, b2Len, coreLen, firstSeen, agentKey, agentLabel }));
31
+ clientRes.writeHead(200, { 'Content-Type': 'application/json' });
32
+ clientRes.end(JSON.stringify({ versions, agents: allAgents.map(k => ({ key: k, label: store.versionIndex.get([...store.versionIndex.keys()].find(ik => ik.startsWith(k + '::')))?.agentLabel || k })) }));
33
+ return true;
34
+ }
35
+
36
+ const diffMatch = clientReq.url.match(/^\/_api\/sysprompt\/diff\?(.+)$/);
37
+ if (diffMatch) {
38
+ const params = new URLSearchParams(diffMatch[1]);
39
+ const verA = params.get('a'), verB = params.get('b');
40
+ const agentKey = params.get('agent') || 'claude-code';
41
+ const entA = store.versionIndex.get(`${agentKey}::${verA}`), entB = store.versionIndex.get(`${agentKey}::${verB}`);
42
+ if (!entA || !entB) {
43
+ clientRes.writeHead(404, { 'Content-Type': 'application/json' });
44
+ clientRes.end(JSON.stringify({ error: 'version not found' }));
45
+ return true;
46
+ }
47
+ const loadB2 = async (ent) => {
48
+ // Try reqId first, fallback to shared file
49
+ if (ent.reqId) {
50
+ try {
51
+ const raw = await config.storage.read(ent.reqId, '_req.json');
52
+ const body = JSON.parse(raw);
53
+ return Array.isArray(body.system) && body.system[2] ? (body.system[2].text || '') : '';
54
+ } catch {}
55
+ }
56
+ if (ent.sharedFile) {
57
+ try {
58
+ const sys = JSON.parse(await config.storage.readShared(ent.sharedFile));
59
+ return Array.isArray(sys) && sys[2] ? (sys[2].text || '') : '';
60
+ } catch {}
61
+ }
62
+ return '';
63
+ };
64
+ (async () => {
65
+ const [b2A, b2B] = await Promise.all([loadB2(entA), loadB2(entB)]);
66
+ const blockDiff = computeBlockDiff(b2A, b2B, verA, verB);
67
+ clientRes.writeHead(200, { 'Content-Type': 'application/json' });
68
+ clientRes.end(JSON.stringify({
69
+ a: { version: verA, b2Len: entA.b2Len },
70
+ b: { version: verB, b2Len: entB.b2Len },
71
+ blockDiff
72
+ }));
73
+ })().catch(e => {
74
+ if (!clientRes.headersSent) clientRes.writeHead(500);
75
+ clientRes.end(JSON.stringify({ error: e.message }));
76
+ });
77
+ return true;
78
+ }
79
+
80
+ // Full entry data (req + res) — lazy loaded
81
+ const entryMatch = clientReq.url.match(/^\/_api\/entry\/(.+)$/);
82
+ if (entryMatch) {
83
+ const id = decodeURIComponent(entryMatch[1]);
84
+ const entry = store.entries.find(e => e.id === id);
85
+ if (!entry) { clientRes.writeHead(404); clientRes.end('Not found'); return true; }
86
+ (async () => {
87
+ await loadEntryReqRes(entry);
88
+ const snapshot = { req: entry.req, res: entry.res, receivedAt: entry.receivedAt || null };
89
+ if (entry.elapsed === '?') { entry.req = null; entry.res = null; entry._loaded = false; }
90
+ clientRes.writeHead(200, { 'Content-Type': 'application/json' });
91
+ clientRes.end(JSON.stringify(snapshot));
92
+ })().catch(e => {
93
+ if (!clientRes.headersSent) clientRes.writeHead(500);
94
+ clientRes.end(JSON.stringify({ error: e.message }));
95
+ });
96
+ return true;
97
+ }
98
+
99
+ // Lazy tokenization endpoint
100
+ const tokMatch = clientReq.url.match(/^\/_api\/tokens\/(.+)$/);
101
+ if (tokMatch) {
102
+ const id = decodeURIComponent(tokMatch[1]);
103
+ const entry = store.entries.find(e => e.id === id);
104
+ if (!entry) { clientRes.writeHead(404); clientRes.end('Not found'); return true; }
105
+ (async () => {
106
+ if (!entry.tokens) {
107
+ await loadEntryReqRes(entry);
108
+ if (entry.req) entry.tokens = tokenizeRequest(entry.req);
109
+ if (entry.elapsed === '?') { entry.req = null; entry.res = null; entry._loaded = false; }
110
+ }
111
+ clientRes.writeHead(200, { 'Content-Type': 'application/json' });
112
+ clientRes.end(JSON.stringify(entry.tokens));
113
+ })().catch(e => {
114
+ if (!clientRes.headersSent) clientRes.writeHead(500);
115
+ clientRes.end(JSON.stringify({ error: e.message }));
116
+ });
117
+ return true;
118
+ }
119
+
120
+ return false;
121
+ }
122
+
123
+ module.exports = { handleApiRoutes };
@@ -0,0 +1,124 @@
1
+ 'use strict';
2
+
3
+ const store = require('../store');
4
+ const { getCostsCacheOrNull, calculateBurnRate, TOKEN_LIMIT } = require('../cost-budget');
5
+ const { pricingTable } = require('../pricing');
6
+
7
+ // Helper: return loading response if cache not ready (triggers background computation)
8
+ function sendLoadingOrData(clientRes, dataFn) {
9
+ const data = getCostsCacheOrNull();
10
+ if (!data) {
11
+ clientRes.writeHead(202, { 'Content-Type': 'application/json' });
12
+ clientRes.end(JSON.stringify({ loading: true }));
13
+ return;
14
+ }
15
+ dataFn(data);
16
+ }
17
+
18
+ function handleCostRoutes(clientReq, clientRes) {
19
+ if (clientReq.url === '/_api/costs/current-block') {
20
+ sendLoadingOrData(clientRes, data => {
21
+ const now = Date.now();
22
+ const activeBlock = data.blocks.find(b => b.isActive);
23
+ if (!activeBlock) {
24
+ const lastBlock = data.blocks[data.blocks.length - 1];
25
+ if (!lastBlock) {
26
+ clientRes.writeHead(200, { 'Content-Type': 'application/json' });
27
+ clientRes.end(JSON.stringify({ active: false }));
28
+ return;
29
+ }
30
+ clientRes.writeHead(200, { 'Content-Type': 'application/json' });
31
+ clientRes.end(JSON.stringify({
32
+ active: false,
33
+ lastBlock: {
34
+ startTime: lastBlock.startTime,
35
+ endTime: lastBlock.endTime,
36
+ totalTokens: lastBlock.totalTokens,
37
+ costUSD: Math.round(lastBlock.costUSD * 100) / 100,
38
+ models: lastBlock.models,
39
+ minutesAgo: Math.round((now - lastBlock._lastTs) / 60000),
40
+ },
41
+ }));
42
+ return;
43
+ }
44
+ const br = calculateBurnRate(activeBlock);
45
+ const minutesRemaining = Math.round((activeBlock._endMs - now) / 60_000);
46
+ const rawRateLimitState = store.getRateLimitState();
47
+ // Ignore stale rate limit data: must be within current block and <10min old
48
+ const rateLimitState = rawRateLimitState
49
+ && rawRateLimitState.updatedAt > activeBlock._startMs
50
+ && (now - rawRateLimitState.updatedAt) < 600_000
51
+ ? rawRateLimitState : null;
52
+ const liveLimit = rateLimitState && rateLimitState.tokensLimit;
53
+ const liveRemaining = rateLimitState && rateLimitState.tokensRemaining;
54
+ const tokenLimit = liveLimit || TOKEN_LIMIT;
55
+ const tokensUsed = liveLimit ? (liveLimit - liveRemaining) : activeBlock.totalTokens;
56
+ const percentUsed = Math.round((tokensUsed / tokenLimit) * 1000) / 10;
57
+ const resetTime = rateLimitState && rateLimitState.inputReset || activeBlock.endTime;
58
+ const windowStartMs = activeBlock._startMs;
59
+ const windowEndMs = activeBlock._endMs;
60
+ const windowDurationMs = windowEndMs - windowStartMs;
61
+ const minutesElapsed = Math.round((now - windowStartMs) / 60_000);
62
+ const timePct = Math.round(Math.min(100, Math.max(0, (now - windowStartMs) / windowDurationMs * 100) * 10)) / 10;
63
+ const resp = {
64
+ active: true,
65
+ startTime: activeBlock.startTime,
66
+ endTime: resetTime,
67
+ totalTokens: tokensUsed,
68
+ tokenLimit,
69
+ percentUsed,
70
+ costUSD: Math.round(activeBlock.costUSD * 100) / 100,
71
+ models: activeBlock.models,
72
+ burnRate: br ? br.burnRate : null,
73
+ projection: br ? br.projection : null,
74
+ minutesRemaining: rateLimitState && rateLimitState.inputReset
75
+ ? Math.round((new Date(rateLimitState.inputReset).getTime() - now) / 60_000)
76
+ : minutesRemaining,
77
+ minutesElapsed,
78
+ timePct,
79
+ source: rateLimitState ? 'live' : 'jsonl',
80
+ };
81
+ clientRes.writeHead(200, { 'Content-Type': 'application/json' });
82
+ clientRes.end(JSON.stringify(resp));
83
+ });
84
+ return true;
85
+ }
86
+
87
+ if (clientReq.url === '/_api/costs/daily') {
88
+ sendLoadingOrData(clientRes, data => {
89
+ clientRes.writeHead(200, { 'Content-Type': 'application/json' });
90
+ clientRes.end(JSON.stringify(data.daily));
91
+ });
92
+ return true;
93
+ }
94
+
95
+ if (clientReq.url === '/_api/costs/monthly') {
96
+ sendLoadingOrData(clientRes, data => {
97
+ const now = new Date();
98
+ const currentMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
99
+ const currentMonthData = data.monthly.find(m => m.month === currentMonth) || { month: currentMonth, totalTokens: 0, costUSD: 0, models: [] };
100
+ clientRes.writeHead(200, { 'Content-Type': 'application/json' });
101
+ clientRes.end(JSON.stringify({ monthly: data.monthly, currentMonth: currentMonthData }));
102
+ });
103
+ return true;
104
+ }
105
+
106
+ if (clientReq.url === '/_api/pricing') {
107
+ const result = {};
108
+ for (const [model, rates] of Object.entries(pricingTable)) {
109
+ result[model] = {
110
+ input_cost_per_mtok: rates.input,
111
+ output_cost_per_mtok: rates.output,
112
+ cache_read_cost_per_mtok: rates.cache_read,
113
+ cache_creation_cost_per_mtok: rates.cache_create,
114
+ };
115
+ }
116
+ clientRes.writeHead(200, { 'Content-Type': 'application/json' });
117
+ clientRes.end(JSON.stringify(result));
118
+ return true;
119
+ }
120
+
121
+ return false;
122
+ }
123
+
124
+ module.exports = { handleCostRoutes };
@@ -0,0 +1,89 @@
1
+ 'use strict';
2
+
3
+ const store = require('../store');
4
+ const { taipeiTime } = require('../helpers');
5
+ const { broadcastInterceptToggle, broadcastInterceptRemoved, broadcastSessionStatus } = require('../sse-broadcast');
6
+ const { forwardRequest } = require('../forward');
7
+
8
+ function handleInterceptRoutes(clientReq, clientRes) {
9
+ if (clientReq.url === '/_api/intercept/toggle' && clientReq.method === 'POST') {
10
+ const chunks = []; clientReq.on('data', c => chunks.push(c));
11
+ clientReq.on('end', () => {
12
+ try {
13
+ const { sessionId } = JSON.parse(Buffer.concat(chunks).toString());
14
+ if (!sessionId) { clientRes.writeHead(400); clientRes.end('missing sessionId'); return; }
15
+ const enabled = !store.interceptSessions.has(sessionId);
16
+ if (enabled) store.interceptSessions.add(sessionId); else store.interceptSessions.delete(sessionId);
17
+ broadcastInterceptToggle(sessionId, enabled);
18
+ console.log(`\x1b[33m${enabled ? '⏸' : '▶'} INTERCEPT ${enabled ? 'ON' : 'OFF'} for session ${sessionId.slice(0, 8)}\x1b[0m`);
19
+ clientRes.writeHead(200, { 'Content-Type': 'application/json' });
20
+ clientRes.end(JSON.stringify({ sessionId, enabled }));
21
+ } catch { clientRes.writeHead(400); clientRes.end('bad json'); }
22
+ });
23
+ return true;
24
+ }
25
+
26
+ const approveMatch = clientReq.url.match(/^\/_api\/intercept\/(.+)\/approve$/);
27
+ if (approveMatch && clientReq.method === 'POST') {
28
+ const reqId = decodeURIComponent(approveMatch[1]);
29
+ const pending = store.pendingRequests.get(reqId);
30
+ if (!pending) { clientRes.writeHead(404); clientRes.end('not found'); return true; }
31
+ const chunks = []; clientReq.on('data', c => chunks.push(c));
32
+ clientReq.on('end', () => {
33
+ clearTimeout(pending.timer);
34
+ store.pendingRequests.delete(reqId);
35
+ broadcastInterceptRemoved(reqId);
36
+ try {
37
+ const payload = Buffer.concat(chunks).toString();
38
+ if (payload) {
39
+ const { body } = JSON.parse(payload);
40
+ if (body) { pending.parsedBody = body; pending.bodyModified = true; }
41
+ }
42
+ } catch {}
43
+ console.log(`\x1b[32m✓ INTERCEPT APPROVED [${taipeiTime()}] ${reqId}\x1b[0m`);
44
+ forwardRequest(pending);
45
+ clientRes.writeHead(200, { 'Content-Type': 'application/json' });
46
+ clientRes.end(JSON.stringify({ ok: true }));
47
+ });
48
+ return true;
49
+ }
50
+
51
+ const rejectMatch = clientReq.url.match(/^\/_api\/intercept\/(.+)\/reject$/);
52
+ if (rejectMatch && clientReq.method === 'POST') {
53
+ const reqId = decodeURIComponent(rejectMatch[1]);
54
+ const pending = store.pendingRequests.get(reqId);
55
+ if (!pending) { clientRes.writeHead(404); clientRes.end('not found'); return true; }
56
+ clearTimeout(pending.timer);
57
+ store.pendingRequests.delete(reqId);
58
+ broadcastInterceptRemoved(reqId);
59
+ if (pending.reqSessionId) {
60
+ store.activeRequests[pending.reqSessionId] = Math.max(0, (store.activeRequests[pending.reqSessionId] || 1) - 1);
61
+ broadcastSessionStatus(pending.reqSessionId);
62
+ }
63
+ console.log(`\x1b[31m✕ INTERCEPT REJECTED [${taipeiTime()}] ${reqId}\x1b[0m`);
64
+ if (!pending.clientRes.headersSent) {
65
+ pending.clientRes.writeHead(499, { 'Content-Type': 'application/json' });
66
+ }
67
+ pending.clientRes.end(JSON.stringify({ error: 'request_rejected', message: 'Request rejected by dashboard' }));
68
+ clientRes.writeHead(200, { 'Content-Type': 'application/json' });
69
+ clientRes.end(JSON.stringify({ ok: true }));
70
+ return true;
71
+ }
72
+
73
+ if (clientReq.url === '/_api/intercept/timeout' && clientReq.method === 'POST') {
74
+ const chunks = []; clientReq.on('data', c => chunks.push(c));
75
+ clientReq.on('end', () => {
76
+ try {
77
+ const { timeout } = JSON.parse(Buffer.concat(chunks).toString());
78
+ store.setInterceptTimeout(Math.max(30, Math.min(180, Number(timeout) || 120)));
79
+ clientRes.writeHead(200, { 'Content-Type': 'application/json' });
80
+ clientRes.end(JSON.stringify({ timeout: store.getInterceptTimeout() }));
81
+ } catch { clientRes.writeHead(400); clientRes.end('bad json'); }
82
+ });
83
+ return true;
84
+ }
85
+
86
+ return false;
87
+ }
88
+
89
+ module.exports = { handleInterceptRoutes };
@@ -0,0 +1,44 @@
1
+ 'use strict';
2
+
3
+ const store = require('../store');
4
+
5
+ function handleSSERoute(clientReq, clientRes) {
6
+ if (clientReq.url !== '/_events') return false;
7
+
8
+ clientRes.writeHead(200, {
9
+ 'Content-Type': 'text/event-stream',
10
+ 'Cache-Control': 'no-cache',
11
+ 'Connection': 'keep-alive',
12
+ });
13
+ clientRes.write(':\n\n');
14
+
15
+ // Send current session statuses
16
+ for (const [sid, meta] of Object.entries(store.sessionMeta)) {
17
+ if (meta.lastSeenAt || (store.activeRequests[sid] || 0) > 0) {
18
+ const active = (store.activeRequests[sid] || 0) > 0;
19
+ const data = JSON.stringify({ _type: 'session_status', sessionId: sid, active, lastSeenAt: meta.lastSeenAt || null });
20
+ clientRes.write(`data: ${data}\n\n`);
21
+ }
22
+ }
23
+
24
+ // Send intercept state
25
+ for (const sid of store.interceptSessions) {
26
+ clientRes.write(`data: ${JSON.stringify({ _type: 'intercept_toggled', sessionId: sid, enabled: true })}\n\n`);
27
+ }
28
+ for (const [reqId, pending] of store.pendingRequests) {
29
+ clientRes.write(`data: ${JSON.stringify({ _type: 'pending_request', requestId: reqId, sessionId: pending.reqSessionId, body: pending.parsedBody })}\n\n`);
30
+ }
31
+
32
+ // Send current interceptTimeout
33
+ clientRes.write(`data: ${JSON.stringify({ _type: 'intercept_timeout', timeout: store.getInterceptTimeout() })}\n\n`);
34
+
35
+ store.sseClients.push(clientRes);
36
+ clientReq.on('close', () => {
37
+ const idx = store.sseClients.indexOf(clientRes);
38
+ if (idx >= 0) store.sseClients.splice(idx, 1);
39
+ });
40
+
41
+ return true;
42
+ }
43
+
44
+ module.exports = { handleSSERoute };
@@ -0,0 +1,104 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('crypto');
4
+
5
+ function hmac(key, data, enc) {
6
+ return crypto.createHmac('sha256', key).update(data).digest(enc || undefined);
7
+ }
8
+
9
+ function sha256hex(data) {
10
+ return crypto.createHash('sha256').update(data).digest('hex');
11
+ }
12
+
13
+ // Sign a request with AWS Signature Version 4.
14
+ // Returns extra headers to add: { authorization, 'x-amz-date', ['x-amz-security-token'] }
15
+ //
16
+ // Parameters:
17
+ // method - HTTP method (e.g. 'POST')
18
+ // urlStr - Full URL string (e.g. 'https://bedrock-runtime.us-east-1.amazonaws.com/model/...')
19
+ // headers - Headers object to be forwarded (host/x-amz-date will be added from here for signing)
20
+ // body - Request body (Buffer or string)
21
+ // credentials - { accessKeyId, secretAccessKey, sessionToken }
22
+ // region - AWS region (e.g. 'us-east-1')
23
+ // service - AWS service name (e.g. 'bedrock')
24
+ function sign(method, urlStr, headers, body, credentials, region, service) {
25
+ const url = new URL(urlStr);
26
+
27
+ const now = new Date();
28
+ const amzDate = now.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}Z$/, 'Z');
29
+ const dateStamp = amzDate.slice(0, 8);
30
+
31
+ // Build the set of headers to sign (lowercase names)
32
+ const signingHeaders = {};
33
+ for (const [k, v] of Object.entries(headers)) {
34
+ const lk = k.toLowerCase();
35
+ // Exclude pseudo-headers that we're replacing
36
+ if (lk === 'authorization' || lk === 'x-amz-date' || lk === 'x-amz-security-token') continue;
37
+ signingHeaders[lk] = String(v).trim();
38
+ }
39
+ signingHeaders['host'] = url.hostname;
40
+ signingHeaders['x-amz-date'] = amzDate;
41
+ if (credentials.sessionToken) {
42
+ signingHeaders['x-amz-security-token'] = credentials.sessionToken;
43
+ }
44
+
45
+ // Sorted unique lowercase header keys
46
+ const sortedKeys = [...new Set(Object.keys(signingHeaders).sort())];
47
+ const canonicalHeaders = sortedKeys.map(k => `${k}:${signingHeaders[k]}\n`).join('');
48
+ const signedHeaders = sortedKeys.join(';');
49
+
50
+ // Canonical URI: percent-encode each path segment (unreserved chars stay as-is)
51
+ const canonicalUri = url.pathname
52
+ .split('/')
53
+ .map(seg => encodeURIComponent(decodeURIComponent(seg)))
54
+ .join('/') || '/';
55
+
56
+ // Canonical query string: sorted key=value pairs
57
+ const queryEntries = [...url.searchParams.entries()]
58
+ .sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
59
+ const canonicalQueryString = queryEntries
60
+ .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
61
+ .join('&');
62
+
63
+ const payloadHash = sha256hex(body || '');
64
+
65
+ const canonicalRequest = [
66
+ method.toUpperCase(),
67
+ canonicalUri,
68
+ canonicalQueryString,
69
+ canonicalHeaders,
70
+ signedHeaders,
71
+ payloadHash,
72
+ ].join('\n');
73
+
74
+ const credentialScope = `${dateStamp}/${region}/${service}/aws4_request`;
75
+ const stringToSign = [
76
+ 'AWS4-HMAC-SHA256',
77
+ amzDate,
78
+ credentialScope,
79
+ sha256hex(canonicalRequest),
80
+ ].join('\n');
81
+
82
+ // Derive signing key: HMAC chain AWS4+secret → date → region → service → 'aws4_request'
83
+ const signingKey = hmac(
84
+ hmac(
85
+ hmac(
86
+ hmac(`AWS4${credentials.secretAccessKey}`, dateStamp),
87
+ region
88
+ ),
89
+ service
90
+ ),
91
+ 'aws4_request'
92
+ );
93
+
94
+ const signature = hmac(signingKey, stringToSign, 'hex');
95
+ const authorization = `AWS4-HMAC-SHA256 Credential=${credentials.accessKeyId}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`;
96
+
97
+ return {
98
+ authorization,
99
+ 'x-amz-date': amzDate,
100
+ ...(credentials.sessionToken ? { 'x-amz-security-token': credentials.sessionToken } : {}),
101
+ };
102
+ }
103
+
104
+ module.exports = { sign };