claude-code-limiter-server 1.0.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/Caddyfile +12 -0
- package/Dockerfile +57 -0
- package/LICENSE +21 -0
- package/bin/server.js +51 -0
- package/docker-compose.yml +46 -0
- package/package.json +27 -0
- package/src/dashboard/css/style.css +1374 -0
- package/src/dashboard/index.html +118 -0
- package/src/dashboard/js/app.js +1650 -0
- package/src/dashboard/js/charts.js +388 -0
- package/src/dashboard/js/ws.js +172 -0
- package/src/server/db.js +484 -0
- package/src/server/index.js +100 -0
- package/src/server/routes/admin-api.js +386 -0
- package/src/server/routes/hook-api.js +312 -0
- package/src/server/services/auth.js +174 -0
- package/src/server/services/limiter.js +226 -0
- package/src/server/services/usage.js +87 -0
- package/src/server/ws.js +74 -0
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* charts.js — Canvas-based charts for the Claude Code Limiter dashboard.
|
|
3
|
+
* No external libraries. Uses Canvas 2D API directly.
|
|
4
|
+
*
|
|
5
|
+
* Exports on window.Charts:
|
|
6
|
+
* horizontalBar(canvas, data)
|
|
7
|
+
* creditGauge(canvas, used, total)
|
|
8
|
+
* trendLine(canvas, dataPoints)
|
|
9
|
+
*/
|
|
10
|
+
(function () {
|
|
11
|
+
'use strict';
|
|
12
|
+
|
|
13
|
+
/* ---- Color helpers matching the dark theme ---- */
|
|
14
|
+
var COLORS = {
|
|
15
|
+
bg: '#0d1117',
|
|
16
|
+
cardBg: '#161b22',
|
|
17
|
+
border: '#30363d',
|
|
18
|
+
textPrimary: '#e6edf3',
|
|
19
|
+
textMuted: '#6e7681',
|
|
20
|
+
green: '#3fb950',
|
|
21
|
+
yellow: '#d29922',
|
|
22
|
+
red: '#f85149',
|
|
23
|
+
blue: '#58a6ff',
|
|
24
|
+
purple: '#bc8cff',
|
|
25
|
+
orange: '#f0883e',
|
|
26
|
+
track: '#1c2129',
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
var MODEL_COLORS = {
|
|
30
|
+
opus: COLORS.purple,
|
|
31
|
+
sonnet: COLORS.blue,
|
|
32
|
+
haiku: COLORS.green,
|
|
33
|
+
default: COLORS.orange,
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Get device pixel ratio for crisp rendering.
|
|
38
|
+
* @returns {number}
|
|
39
|
+
*/
|
|
40
|
+
function dpr() {
|
|
41
|
+
return window.devicePixelRatio || 1;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Set up a canvas for high-DPI rendering. Returns the 2D context.
|
|
46
|
+
* @param {HTMLCanvasElement} canvas
|
|
47
|
+
* @param {number} cssW - CSS width
|
|
48
|
+
* @param {number} cssH - CSS height
|
|
49
|
+
* @returns {CanvasRenderingContext2D}
|
|
50
|
+
*/
|
|
51
|
+
function setupCanvas(canvas, cssW, cssH) {
|
|
52
|
+
var ratio = dpr();
|
|
53
|
+
canvas.width = cssW * ratio;
|
|
54
|
+
canvas.height = cssH * ratio;
|
|
55
|
+
canvas.style.width = cssW + 'px';
|
|
56
|
+
canvas.style.height = cssH + 'px';
|
|
57
|
+
var ctx = canvas.getContext('2d');
|
|
58
|
+
ctx.scale(ratio, ratio);
|
|
59
|
+
return ctx;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Pick a color for a usage percentage.
|
|
64
|
+
* @param {number} pct - 0..1
|
|
65
|
+
* @returns {string}
|
|
66
|
+
*/
|
|
67
|
+
function usageColor(pct) {
|
|
68
|
+
if (pct >= 0.9) return COLORS.red;
|
|
69
|
+
if (pct >= 0.7) return COLORS.yellow;
|
|
70
|
+
return COLORS.green;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Draw a rounded rectangle path.
|
|
75
|
+
* @param {CanvasRenderingContext2D} ctx
|
|
76
|
+
* @param {number} x
|
|
77
|
+
* @param {number} y
|
|
78
|
+
* @param {number} w
|
|
79
|
+
* @param {number} h
|
|
80
|
+
* @param {number} r - corner radius
|
|
81
|
+
*/
|
|
82
|
+
function roundRect(ctx, x, y, w, h, r) {
|
|
83
|
+
if (w < 0) w = 0;
|
|
84
|
+
if (r > h / 2) r = h / 2;
|
|
85
|
+
if (r > w / 2) r = w / 2;
|
|
86
|
+
ctx.beginPath();
|
|
87
|
+
ctx.moveTo(x + r, y);
|
|
88
|
+
ctx.lineTo(x + w - r, y);
|
|
89
|
+
ctx.arcTo(x + w, y, x + w, y + r, r);
|
|
90
|
+
ctx.lineTo(x + w, y + h - r);
|
|
91
|
+
ctx.arcTo(x + w, y + h, x + w - r, y + h, r);
|
|
92
|
+
ctx.lineTo(x + r, y + h);
|
|
93
|
+
ctx.arcTo(x, y + h, x, y + h - r, r);
|
|
94
|
+
ctx.lineTo(x, y + r);
|
|
95
|
+
ctx.arcTo(x, y, x + r, y, r);
|
|
96
|
+
ctx.closePath();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/* ================================================================
|
|
100
|
+
horizontalBar(canvas, data)
|
|
101
|
+
Renders per-model usage bars with limit markers.
|
|
102
|
+
data: [{ label, value, limit, color? }]
|
|
103
|
+
================================================================ */
|
|
104
|
+
function horizontalBar(canvas, data) {
|
|
105
|
+
if (!canvas || !data || data.length === 0) return;
|
|
106
|
+
|
|
107
|
+
var barHeight = 22;
|
|
108
|
+
var barGap = 32;
|
|
109
|
+
var labelWidth = 70;
|
|
110
|
+
var valueWidth = 80;
|
|
111
|
+
var padding = { top: 8, right: 12, bottom: 8, left: 4 };
|
|
112
|
+
var totalH = padding.top + data.length * (barHeight + barGap) - barGap + padding.bottom;
|
|
113
|
+
var cssW = canvas.parentElement ? canvas.parentElement.clientWidth : 400;
|
|
114
|
+
if (cssW < 200) cssW = 400;
|
|
115
|
+
var cssH = totalH;
|
|
116
|
+
|
|
117
|
+
var ctx = setupCanvas(canvas, cssW, cssH);
|
|
118
|
+
var barAreaW = cssW - labelWidth - valueWidth - padding.left - padding.right;
|
|
119
|
+
|
|
120
|
+
// Find max for scale
|
|
121
|
+
var maxVal = 0;
|
|
122
|
+
for (var i = 0; i < data.length; i++) {
|
|
123
|
+
var cmp = data[i].limit > 0 ? data[i].limit : data[i].value;
|
|
124
|
+
if (cmp > maxVal) maxVal = cmp;
|
|
125
|
+
}
|
|
126
|
+
if (maxVal === 0) maxVal = 1;
|
|
127
|
+
|
|
128
|
+
for (var j = 0; j < data.length; j++) {
|
|
129
|
+
var item = data[j];
|
|
130
|
+
var y = padding.top + j * (barHeight + barGap);
|
|
131
|
+
var barX = padding.left + labelWidth;
|
|
132
|
+
|
|
133
|
+
// Label
|
|
134
|
+
ctx.fillStyle = COLORS.textMuted;
|
|
135
|
+
ctx.font = '500 12px system-ui, sans-serif';
|
|
136
|
+
ctx.textAlign = 'right';
|
|
137
|
+
ctx.textBaseline = 'middle';
|
|
138
|
+
ctx.fillText(item.label, barX - 10, y + barHeight / 2);
|
|
139
|
+
|
|
140
|
+
// Track
|
|
141
|
+
roundRect(ctx, barX, y, barAreaW, barHeight, 4);
|
|
142
|
+
ctx.fillStyle = COLORS.track;
|
|
143
|
+
ctx.fill();
|
|
144
|
+
|
|
145
|
+
// Fill
|
|
146
|
+
var pct = item.limit > 0 ? item.value / item.limit : (item.value > 0 ? 1 : 0);
|
|
147
|
+
if (pct > 1) pct = 1;
|
|
148
|
+
var fillW = barAreaW * pct;
|
|
149
|
+
if (fillW > 0) {
|
|
150
|
+
roundRect(ctx, barX, y, fillW, barHeight, 4);
|
|
151
|
+
var grad = ctx.createLinearGradient(barX, 0, barX + fillW, 0);
|
|
152
|
+
var barColor = item.color || MODEL_COLORS[item.label.toLowerCase()] || COLORS.blue;
|
|
153
|
+
grad.addColorStop(0, barColor);
|
|
154
|
+
grad.addColorStop(1, adjustAlpha(barColor, 0.7));
|
|
155
|
+
ctx.fillStyle = grad;
|
|
156
|
+
ctx.fill();
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Limit marker
|
|
160
|
+
if (item.limit > 0) {
|
|
161
|
+
var markerX = barX + barAreaW * (item.limit / maxVal);
|
|
162
|
+
if (markerX > barX + barAreaW) markerX = barX + barAreaW;
|
|
163
|
+
ctx.strokeStyle = COLORS.textPrimary;
|
|
164
|
+
ctx.lineWidth = 1.5;
|
|
165
|
+
ctx.beginPath();
|
|
166
|
+
ctx.moveTo(markerX, y - 3);
|
|
167
|
+
ctx.lineTo(markerX, y + barHeight + 3);
|
|
168
|
+
ctx.stroke();
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Value text
|
|
172
|
+
ctx.fillStyle = COLORS.textPrimary;
|
|
173
|
+
ctx.font = '600 12px "SF Mono", "Fira Code", monospace';
|
|
174
|
+
ctx.textAlign = 'left';
|
|
175
|
+
ctx.textBaseline = 'middle';
|
|
176
|
+
var valStr = item.value + (item.limit > 0 ? '/' + item.limit : (item.limit === -1 ? '/inf' : ''));
|
|
177
|
+
ctx.fillText(valStr, barX + barAreaW + 10, y + barHeight / 2);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/* ================================================================
|
|
182
|
+
creditGauge(canvas, used, total)
|
|
183
|
+
Renders a circular progress indicator showing credit usage.
|
|
184
|
+
================================================================ */
|
|
185
|
+
function creditGauge(canvas, used, total) {
|
|
186
|
+
if (!canvas) return;
|
|
187
|
+
|
|
188
|
+
var size = 180;
|
|
189
|
+
var ctx = setupCanvas(canvas, size, size);
|
|
190
|
+
var cx = size / 2;
|
|
191
|
+
var cy = size / 2;
|
|
192
|
+
var radius = 68;
|
|
193
|
+
var lineWidth = 14;
|
|
194
|
+
|
|
195
|
+
var pct = total > 0 ? used / total : 0;
|
|
196
|
+
if (pct > 1) pct = 1;
|
|
197
|
+
var remaining = total - used;
|
|
198
|
+
if (remaining < 0) remaining = 0;
|
|
199
|
+
|
|
200
|
+
// Start angle at top (-PI/2), go clockwise
|
|
201
|
+
var startAngle = -Math.PI / 2;
|
|
202
|
+
var endAngle = startAngle + 2 * Math.PI * pct;
|
|
203
|
+
|
|
204
|
+
// Track
|
|
205
|
+
ctx.beginPath();
|
|
206
|
+
ctx.arc(cx, cy, radius, 0, 2 * Math.PI);
|
|
207
|
+
ctx.strokeStyle = COLORS.track;
|
|
208
|
+
ctx.lineWidth = lineWidth;
|
|
209
|
+
ctx.lineCap = 'round';
|
|
210
|
+
ctx.stroke();
|
|
211
|
+
|
|
212
|
+
// Filled arc
|
|
213
|
+
if (pct > 0) {
|
|
214
|
+
ctx.beginPath();
|
|
215
|
+
ctx.arc(cx, cy, radius, startAngle, endAngle);
|
|
216
|
+
var color = usageColor(pct);
|
|
217
|
+
ctx.strokeStyle = color;
|
|
218
|
+
ctx.lineWidth = lineWidth;
|
|
219
|
+
ctx.lineCap = 'round';
|
|
220
|
+
ctx.stroke();
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Center text — remaining credits
|
|
224
|
+
ctx.fillStyle = COLORS.textPrimary;
|
|
225
|
+
ctx.font = '700 28px system-ui, sans-serif';
|
|
226
|
+
ctx.textAlign = 'center';
|
|
227
|
+
ctx.textBaseline = 'middle';
|
|
228
|
+
ctx.fillText(String(remaining), cx, cy - 6);
|
|
229
|
+
|
|
230
|
+
ctx.fillStyle = COLORS.textMuted;
|
|
231
|
+
ctx.font = '500 11px system-ui, sans-serif';
|
|
232
|
+
ctx.fillText('credits left', cx, cy + 16);
|
|
233
|
+
|
|
234
|
+
// Bottom label
|
|
235
|
+
ctx.fillStyle = COLORS.textMuted;
|
|
236
|
+
ctx.font = '500 10px system-ui, sans-serif';
|
|
237
|
+
ctx.fillText(used + ' / ' + total + ' used', cx, cy + 36);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/* ================================================================
|
|
241
|
+
trendLine(canvas, dataPoints)
|
|
242
|
+
Renders a 30-day daily usage sparkline.
|
|
243
|
+
dataPoints: [{ day: 'YYYY-MM-DD', value: number }]
|
|
244
|
+
================================================================ */
|
|
245
|
+
function trendLine(canvas, dataPoints) {
|
|
246
|
+
if (!canvas || !dataPoints || dataPoints.length === 0) return;
|
|
247
|
+
|
|
248
|
+
var cssW = canvas.parentElement ? canvas.parentElement.clientWidth : 400;
|
|
249
|
+
if (cssW < 200) cssW = 400;
|
|
250
|
+
var cssH = 140;
|
|
251
|
+
var ctx = setupCanvas(canvas, cssW, cssH);
|
|
252
|
+
|
|
253
|
+
var padding = { top: 16, right: 16, bottom: 28, left: 40 };
|
|
254
|
+
var plotW = cssW - padding.left - padding.right;
|
|
255
|
+
var plotH = cssH - padding.top - padding.bottom;
|
|
256
|
+
|
|
257
|
+
// Find max
|
|
258
|
+
var maxVal = 0;
|
|
259
|
+
for (var i = 0; i < dataPoints.length; i++) {
|
|
260
|
+
if (dataPoints[i].value > maxVal) maxVal = dataPoints[i].value;
|
|
261
|
+
}
|
|
262
|
+
if (maxVal === 0) maxVal = 1;
|
|
263
|
+
// Round up to nice number
|
|
264
|
+
var gridMax = Math.ceil(maxVal / 5) * 5;
|
|
265
|
+
if (gridMax < 5) gridMax = 5;
|
|
266
|
+
|
|
267
|
+
// Horizontal grid lines
|
|
268
|
+
var gridLines = 4;
|
|
269
|
+
ctx.strokeStyle = COLORS.border;
|
|
270
|
+
ctx.lineWidth = 0.5;
|
|
271
|
+
ctx.fillStyle = COLORS.textMuted;
|
|
272
|
+
ctx.font = '500 10px system-ui, sans-serif';
|
|
273
|
+
ctx.textAlign = 'right';
|
|
274
|
+
ctx.textBaseline = 'middle';
|
|
275
|
+
|
|
276
|
+
for (var g = 0; g <= gridLines; g++) {
|
|
277
|
+
var gy = padding.top + plotH - (g / gridLines) * plotH;
|
|
278
|
+
ctx.beginPath();
|
|
279
|
+
ctx.moveTo(padding.left, gy);
|
|
280
|
+
ctx.lineTo(padding.left + plotW, gy);
|
|
281
|
+
ctx.stroke();
|
|
282
|
+
var gridVal = Math.round((g / gridLines) * gridMax);
|
|
283
|
+
ctx.fillText(String(gridVal), padding.left - 6, gy);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Plot points
|
|
287
|
+
var pts = [];
|
|
288
|
+
for (var k = 0; k < dataPoints.length; k++) {
|
|
289
|
+
var px = padding.left + (k / (dataPoints.length - 1 || 1)) * plotW;
|
|
290
|
+
var py = padding.top + plotH - (dataPoints[k].value / gridMax) * plotH;
|
|
291
|
+
pts.push({ x: px, y: py });
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Fill area under line
|
|
295
|
+
if (pts.length > 1) {
|
|
296
|
+
ctx.beginPath();
|
|
297
|
+
ctx.moveTo(pts[0].x, padding.top + plotH);
|
|
298
|
+
for (var m = 0; m < pts.length; m++) {
|
|
299
|
+
ctx.lineTo(pts[m].x, pts[m].y);
|
|
300
|
+
}
|
|
301
|
+
ctx.lineTo(pts[pts.length - 1].x, padding.top + plotH);
|
|
302
|
+
ctx.closePath();
|
|
303
|
+
var areaGrad = ctx.createLinearGradient(0, padding.top, 0, padding.top + plotH);
|
|
304
|
+
areaGrad.addColorStop(0, 'rgba(88,166,255,0.2)');
|
|
305
|
+
areaGrad.addColorStop(1, 'rgba(88,166,255,0.02)');
|
|
306
|
+
ctx.fillStyle = areaGrad;
|
|
307
|
+
ctx.fill();
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Line
|
|
311
|
+
if (pts.length > 1) {
|
|
312
|
+
ctx.beginPath();
|
|
313
|
+
ctx.moveTo(pts[0].x, pts[0].y);
|
|
314
|
+
for (var n = 1; n < pts.length; n++) {
|
|
315
|
+
ctx.lineTo(pts[n].x, pts[n].y);
|
|
316
|
+
}
|
|
317
|
+
ctx.strokeStyle = COLORS.blue;
|
|
318
|
+
ctx.lineWidth = 2;
|
|
319
|
+
ctx.lineJoin = 'round';
|
|
320
|
+
ctx.lineCap = 'round';
|
|
321
|
+
ctx.stroke();
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// Dots
|
|
325
|
+
for (var d = 0; d < pts.length; d++) {
|
|
326
|
+
ctx.beginPath();
|
|
327
|
+
ctx.arc(pts[d].x, pts[d].y, 3, 0, 2 * Math.PI);
|
|
328
|
+
ctx.fillStyle = COLORS.blue;
|
|
329
|
+
ctx.fill();
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// X-axis labels (show first, middle, last)
|
|
333
|
+
ctx.fillStyle = COLORS.textMuted;
|
|
334
|
+
ctx.font = '500 9px system-ui, sans-serif';
|
|
335
|
+
ctx.textBaseline = 'top';
|
|
336
|
+
var labelY = padding.top + plotH + 8;
|
|
337
|
+
|
|
338
|
+
if (dataPoints.length > 0) {
|
|
339
|
+
ctx.textAlign = 'left';
|
|
340
|
+
ctx.fillText(formatDay(dataPoints[0].day), pts[0].x, labelY);
|
|
341
|
+
}
|
|
342
|
+
if (dataPoints.length > 2) {
|
|
343
|
+
var midIdx = Math.floor(dataPoints.length / 2);
|
|
344
|
+
ctx.textAlign = 'center';
|
|
345
|
+
ctx.fillText(formatDay(dataPoints[midIdx].day), pts[midIdx].x, labelY);
|
|
346
|
+
}
|
|
347
|
+
if (dataPoints.length > 1) {
|
|
348
|
+
ctx.textAlign = 'right';
|
|
349
|
+
ctx.fillText(formatDay(dataPoints[dataPoints.length - 1].day), pts[pts.length - 1].x, labelY);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/* ---- Helpers ---- */
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Adjust a hex color's alpha. Returns an rgba() string.
|
|
357
|
+
* @param {string} hex
|
|
358
|
+
* @param {number} alpha
|
|
359
|
+
* @returns {string}
|
|
360
|
+
*/
|
|
361
|
+
function adjustAlpha(hex, alpha) {
|
|
362
|
+
var r = parseInt(hex.slice(1, 3), 16);
|
|
363
|
+
var g = parseInt(hex.slice(3, 5), 16);
|
|
364
|
+
var b = parseInt(hex.slice(5, 7), 16);
|
|
365
|
+
return 'rgba(' + r + ',' + g + ',' + b + ',' + alpha + ')';
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Format a YYYY-MM-DD string as a short label.
|
|
370
|
+
* @param {string} day
|
|
371
|
+
* @returns {string}
|
|
372
|
+
*/
|
|
373
|
+
function formatDay(day) {
|
|
374
|
+
if (!day) return '';
|
|
375
|
+
var parts = day.split('-');
|
|
376
|
+
if (parts.length < 3) return day;
|
|
377
|
+
return parts[1] + '/' + parts[2];
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/* ---- Public API ---- */
|
|
381
|
+
window.Charts = {
|
|
382
|
+
horizontalBar: horizontalBar,
|
|
383
|
+
creditGauge: creditGauge,
|
|
384
|
+
trendLine: trendLine,
|
|
385
|
+
COLORS: COLORS,
|
|
386
|
+
MODEL_COLORS: MODEL_COLORS,
|
|
387
|
+
};
|
|
388
|
+
})();
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ws.js — WebSocket client for the Claude Code Limiter dashboard.
|
|
3
|
+
* Auto-reconnect with exponential backoff (max 30s).
|
|
4
|
+
*
|
|
5
|
+
* Exports on window.WS:
|
|
6
|
+
* connect()
|
|
7
|
+
* disconnect()
|
|
8
|
+
* onEvent(callback) — register handler for all server events
|
|
9
|
+
* isConnected()
|
|
10
|
+
*/
|
|
11
|
+
(function () {
|
|
12
|
+
'use strict';
|
|
13
|
+
|
|
14
|
+
var socket = null;
|
|
15
|
+
var listeners = [];
|
|
16
|
+
var reconnectTimer = null;
|
|
17
|
+
var reconnectDelay = 1000; // ms, grows exponentially
|
|
18
|
+
var MAX_RECONNECT_DELAY = 30000;
|
|
19
|
+
var intentionalClose = false;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Build the WebSocket URL based on the current page location.
|
|
23
|
+
* @returns {string}
|
|
24
|
+
*/
|
|
25
|
+
function buildUrl() {
|
|
26
|
+
var proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
27
|
+
return proto + '//' + location.host + '/ws';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Update the connection indicator dots in the UI.
|
|
32
|
+
* @param {'connected'|'connecting'|'disconnected'} state
|
|
33
|
+
*/
|
|
34
|
+
function setIndicator(state) {
|
|
35
|
+
var dots = document.querySelectorAll('.ws-dot');
|
|
36
|
+
for (var i = 0; i < dots.length; i++) {
|
|
37
|
+
dots[i].className = 'ws-dot ' + state;
|
|
38
|
+
}
|
|
39
|
+
var label = document.getElementById('ws-label');
|
|
40
|
+
if (label) {
|
|
41
|
+
var labels = {
|
|
42
|
+
connected: 'Live',
|
|
43
|
+
connecting: 'Reconnecting...',
|
|
44
|
+
disconnected: 'Disconnected',
|
|
45
|
+
};
|
|
46
|
+
label.textContent = labels[state] || state;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Notify all registered listeners of an event.
|
|
52
|
+
* @param {object} event
|
|
53
|
+
*/
|
|
54
|
+
function emit(event) {
|
|
55
|
+
for (var i = 0; i < listeners.length; i++) {
|
|
56
|
+
try {
|
|
57
|
+
listeners[i](event);
|
|
58
|
+
} catch (err) {
|
|
59
|
+
console.error('[WS] Listener error:', err);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Open a WebSocket connection. Automatically reconnects on close.
|
|
66
|
+
*/
|
|
67
|
+
function connect() {
|
|
68
|
+
if (socket && (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING)) {
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
intentionalClose = false;
|
|
73
|
+
setIndicator('connecting');
|
|
74
|
+
|
|
75
|
+
try {
|
|
76
|
+
socket = new WebSocket(buildUrl());
|
|
77
|
+
} catch (err) {
|
|
78
|
+
console.error('[WS] Failed to create WebSocket:', err);
|
|
79
|
+
scheduleReconnect();
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
socket.onopen = function () {
|
|
84
|
+
reconnectDelay = 1000; // reset backoff on successful connection
|
|
85
|
+
setIndicator('connected');
|
|
86
|
+
emit({ type: 'ws_connected', timestamp: new Date().toISOString() });
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
socket.onmessage = function (evt) {
|
|
90
|
+
var data;
|
|
91
|
+
try {
|
|
92
|
+
data = JSON.parse(evt.data);
|
|
93
|
+
} catch (err) {
|
|
94
|
+
console.warn('[WS] Non-JSON message:', evt.data);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
emit(data);
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
socket.onclose = function (evt) {
|
|
101
|
+
socket = null;
|
|
102
|
+
if (!intentionalClose) {
|
|
103
|
+
setIndicator('disconnected');
|
|
104
|
+
emit({ type: 'ws_disconnected', timestamp: new Date().toISOString() });
|
|
105
|
+
scheduleReconnect();
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
socket.onerror = function () {
|
|
110
|
+
// onclose will fire after onerror, so reconnect is handled there
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Schedule a reconnection attempt with exponential backoff.
|
|
116
|
+
*/
|
|
117
|
+
function scheduleReconnect() {
|
|
118
|
+
if (reconnectTimer) return;
|
|
119
|
+
setIndicator('connecting');
|
|
120
|
+
reconnectTimer = setTimeout(function () {
|
|
121
|
+
reconnectTimer = null;
|
|
122
|
+
connect();
|
|
123
|
+
}, reconnectDelay);
|
|
124
|
+
// Exponential backoff, capped at MAX_RECONNECT_DELAY
|
|
125
|
+
reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_DELAY);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Intentionally close the WebSocket (e.g. on logout). No auto-reconnect.
|
|
130
|
+
*/
|
|
131
|
+
function disconnect() {
|
|
132
|
+
intentionalClose = true;
|
|
133
|
+
if (reconnectTimer) {
|
|
134
|
+
clearTimeout(reconnectTimer);
|
|
135
|
+
reconnectTimer = null;
|
|
136
|
+
}
|
|
137
|
+
if (socket) {
|
|
138
|
+
socket.close();
|
|
139
|
+
socket = null;
|
|
140
|
+
}
|
|
141
|
+
setIndicator('disconnected');
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Register a callback for all WebSocket events from the server.
|
|
146
|
+
* @param {function(object):void} callback
|
|
147
|
+
* @returns {function} unsubscribe function
|
|
148
|
+
*/
|
|
149
|
+
function onEvent(callback) {
|
|
150
|
+
listeners.push(callback);
|
|
151
|
+
return function unsubscribe() {
|
|
152
|
+
var idx = listeners.indexOf(callback);
|
|
153
|
+
if (idx !== -1) listeners.splice(idx, 1);
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Check if the WebSocket is currently open.
|
|
159
|
+
* @returns {boolean}
|
|
160
|
+
*/
|
|
161
|
+
function isConnected() {
|
|
162
|
+
return socket !== null && socket.readyState === WebSocket.OPEN;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/* ---- Public API ---- */
|
|
166
|
+
window.WS = {
|
|
167
|
+
connect: connect,
|
|
168
|
+
disconnect: disconnect,
|
|
169
|
+
onEvent: onEvent,
|
|
170
|
+
isConnected: isConnected,
|
|
171
|
+
};
|
|
172
|
+
})();
|