clawmatrix 0.2.11 → 0.4.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.
@@ -1,6 +1,6 @@
1
1
  import * as Automerge from "@automerge/automerge";
2
2
  import { watch, type FSWatcher } from "node:fs";
3
- import { readdir, readFile, stat as fsStat, writeFile, mkdir, rename } from "node:fs/promises";
3
+ import { readdir, readFile, stat as fsStat, writeFile, mkdir, rename, unlink } from "node:fs/promises";
4
4
  import path from "node:path";
5
5
  import ignore, { type Ignore } from "ignore";
6
6
  import picomatch from "picomatch";
@@ -87,6 +87,20 @@ async function pMap<T, R>(items: T[], fn: (item: T) => Promise<R>, concurrency:
87
87
  return results;
88
88
  }
89
89
 
90
+ /** Race a promise against a timeout; calls onTimeout before rejecting. */
91
+ function withTimeout<T>(promise: Promise<T>, ms: number, onTimeout?: () => void): Promise<T> {
92
+ return new Promise((resolve, reject) => {
93
+ const timer = setTimeout(() => {
94
+ onTimeout?.();
95
+ reject(new Error(`Timed out after ${ms}ms`));
96
+ }, ms);
97
+ promise.then(
98
+ (v) => { clearTimeout(timer); resolve(v); },
99
+ (e) => { clearTimeout(timer); reject(e); },
100
+ );
101
+ });
102
+ }
103
+
90
104
  async function streamToString(stream: ReadableStream | null): Promise<string> {
91
105
  if (!stream) return "";
92
106
  const reader = stream.getReader();
@@ -131,7 +145,7 @@ export class KnowledgeSync {
131
145
  private localChangesRunning = false;
132
146
  private localChangesQueued = false;
133
147
  /** Paths recently written by exportFileToFs — suppress watcher re-trigger. Stores {content, timestamp}. */
134
- private writtenByExport = new Map<string, { content: string; ts: number }>();
148
+ private writtenByExport = new Map<string, { content: string | null; ts: number }>();
135
149
  /** Deferred git commit timer — batches multiple remote syncs into one commit. */
136
150
  private gitCommitTimer: ReturnType<typeof setTimeout> | null = null;
137
151
  private pendingGitSources = new Set<string>();
@@ -326,10 +340,20 @@ export class KnowledgeSync {
326
340
  await this.saveAutomergeDoc(this.registryPath, this.registry);
327
341
 
328
342
  // Discover new files from registry and initiate their sync
343
+ const deletedPaths: string[] = [];
329
344
  for (const [relPath, meta] of Object.entries(newDoc.files ?? {})) {
330
345
  if (meta.deleted) {
331
- // Clean up sync states for deleted files
346
+ // Clean up sync states, in-memory doc, persisted doc, and local file
332
347
  this.cleanupDeletedFileSyncStates(relPath);
348
+ if (this.fileDocs.has(relPath)) {
349
+ this.fileDocs.delete(relPath);
350
+ deletedPaths.push(relPath);
351
+ // Remove persisted automerge doc
352
+ const docPath = path.join(this.docsDir, docFileName(relPath));
353
+ await rename(docPath, docPath + ".deleted").catch(() => {});
354
+ // Delete local file from workspace
355
+ await this.deleteLocalFile(relPath);
356
+ }
333
357
  continue;
334
358
  }
335
359
  if (!this.fileDocs.has(relPath)) {
@@ -339,6 +363,12 @@ export class KnowledgeSync {
339
363
  this.syncDocWithPeer(peerId, relPath);
340
364
  }
341
365
 
366
+ // Commit remote deletions to git
367
+ if (deletedPaths.length > 0) {
368
+ debug(TAG, `remote deletion from ${peerId}: ${deletedPaths.join(", ")}`);
369
+ this.schedulePendingGitCommit(peerId);
370
+ }
371
+
342
372
  this.sendSyncMessage(peerId, REGISTRY_DOC_ID);
343
373
  }
344
374
 
@@ -846,6 +876,30 @@ export class KnowledgeSync {
846
876
  }
847
877
  }
848
878
 
879
+ /** Delete a local file from workspace (triggered by remote deletion). */
880
+ private async deleteLocalFile(relPath: string) {
881
+ const absPath = path.resolve(this.opts.workspacePath, relPath);
882
+
883
+ // Prevent path traversal
884
+ if (!absPath.startsWith(this.opts.workspacePath + path.sep) && absPath !== this.opts.workspacePath) {
885
+ debug(TAG, `blocked path traversal on delete: ${relPath}`);
886
+ return;
887
+ }
888
+
889
+ // Mark as our own deletion so the watcher doesn't re-process it.
890
+ // handleLocalChangesInner sees currentContent === null === marker.content and skips.
891
+ this.writtenByExport.set(relPath, { content: null, ts: Date.now() });
892
+ try {
893
+ await unlink(absPath);
894
+ debug(TAG, `deleted local file: ${relPath}`);
895
+ } catch (err) {
896
+ // File may not exist locally — that's fine
897
+ if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
898
+ debug(TAG, `failed to delete local file ${relPath}: ${err}`);
899
+ }
900
+ }
901
+ }
902
+
849
903
  /** Read all workspace files matching whitelist. */
850
904
  private async readWhitelistedFiles(): Promise<Record<string, string>> {
851
905
  const files: Record<string, string> = {};
@@ -954,9 +1008,7 @@ export class KnowledgeSync {
954
1008
  }
955
1009
 
956
1010
  let doc = Automerge.init<FileDoc>();
957
- doc = Automerge.change(doc, (d) => {
958
- (d as FileDoc).content = content;
959
- });
1011
+ doc = changeFileContent(doc, content);
960
1012
  this.fileDocs.set(relPath, doc);
961
1013
 
962
1014
  this.registry = Automerge.change(this.registry, (d) => {
@@ -1036,20 +1088,20 @@ export class KnowledgeSync {
1036
1088
  }
1037
1089
 
1038
1090
  private async gitCommit(message: string) {
1091
+ const GIT_TIMEOUT_MS = 3000;
1039
1092
  try {
1040
1093
  const add = spawnProcess(["git", "add", "-A"], {
1041
1094
  cwd: this.opts.workspacePath,
1042
1095
  stdout: "pipe",
1043
1096
  stderr: "pipe",
1044
1097
  });
1045
- await add.exited;
1046
-
1098
+ await withTimeout(add.exited, GIT_TIMEOUT_MS, () => add.kill());
1047
1099
  const diff = spawnProcess(["git", "diff", "--cached", "--quiet"], {
1048
1100
  cwd: this.opts.workspacePath,
1049
1101
  stdout: "pipe",
1050
1102
  stderr: "pipe",
1051
1103
  });
1052
- const diffCode = await diff.exited;
1104
+ const diffCode = await withTimeout(diff.exited, GIT_TIMEOUT_MS, () => diff.kill());
1053
1105
  if (diffCode === 0) return;
1054
1106
 
1055
1107
  const commit = spawnProcess(
@@ -1060,7 +1112,7 @@ export class KnowledgeSync {
1060
1112
  stderr: "pipe",
1061
1113
  },
1062
1114
  );
1063
- const commitCode = await commit.exited;
1115
+ const commitCode = await withTimeout(commit.exited, GIT_TIMEOUT_MS, () => commit.kill());
1064
1116
  if (commitCode !== 0) {
1065
1117
  const stderr = await streamToString(commit.stderr);
1066
1118
  debug(TAG, `git commit failed (exit ${commitCode}): ${stderr}`);
@@ -362,6 +362,11 @@ export class ModelProxy {
362
362
  this.cacheCleanupTimer = null;
363
363
  }
364
364
  if (this.httpServer) {
365
+ // Force-close all keep-alive connections so the port is released immediately
366
+ const server = this.httpServer as typeof this.httpServer & { closeAllConnections?: () => void };
367
+ if (typeof server.closeAllConnections === "function") {
368
+ server.closeAllConnections();
369
+ }
365
370
  this.httpServer.close();
366
371
  this.httpServer = null;
367
372
  }
@@ -754,7 +759,7 @@ export class ModelProxy {
754
759
  let currentId = requestId;
755
760
  let currentTarget = targetNodeId;
756
761
  let currentFrame = frame;
757
- let remaining = failoverCandidates;
762
+ let failoverIdx = 0; // index into failoverCandidates (avoids slice allocations)
758
763
  const maxAttempts = failoverCandidates.length + 1;
759
764
 
760
765
  for (let attempt = 0; attempt < maxAttempts; attempt++) {
@@ -763,13 +768,13 @@ export class ModelProxy {
763
768
 
764
769
  if (!result.success) {
765
770
  // Upstream error — try failover if available
766
- if (remaining.length > 0 && buildFrame) {
767
- const next = remaining[0]!;
768
- debug("proxy", `failover: remote error "${result.error}" → trying ${next.routeNodeId} (${remaining.length - 1} left)`);
771
+ if (failoverIdx < failoverCandidates.length && buildFrame) {
772
+ const next = failoverCandidates[failoverIdx]!;
773
+ debug("proxy", `failover: remote error "${result.error}" → trying ${next.routeNodeId} (${failoverCandidates.length - failoverIdx - 1} left)`);
774
+ failoverIdx++;
769
775
  currentId = crypto.randomUUID();
770
776
  currentFrame = buildFrame(next, currentId);
771
777
  currentTarget = next.routeNodeId;
772
- remaining = remaining.slice(1);
773
778
  continue;
774
779
  }
775
780
  return {
@@ -782,13 +787,13 @@ export class ModelProxy {
782
787
  return this.formatNonStreamResult(result, currentId, currentFrame, responseFormat);
783
788
  } catch (err) {
784
789
  // Timeout or send failure — try failover
785
- if (remaining.length > 0 && buildFrame) {
786
- const next = remaining[0]!;
787
- debug("proxy", `failover: ${err instanceof Error ? err.message : String(err)} → trying ${next.routeNodeId} (${remaining.length - 1} left)`);
790
+ if (failoverIdx < failoverCandidates.length && buildFrame) {
791
+ const next = failoverCandidates[failoverIdx]!;
792
+ debug("proxy", `failover: ${err instanceof Error ? err.message : String(err)} → trying ${next.routeNodeId} (${failoverCandidates.length - failoverIdx - 1} left)`);
793
+ failoverIdx++;
788
794
  currentId = crypto.randomUUID();
789
795
  currentFrame = buildFrame(next, currentId);
790
796
  currentTarget = next.routeNodeId;
791
- remaining = remaining.slice(1);
792
797
  continue;
793
798
  }
794
799
  return {
@@ -980,6 +985,24 @@ export class ModelProxy {
980
985
  const pending = this.pending.get(frame.id);
981
986
  if (!pending?.stream || !pending.controller || !pending.encoder) return;
982
987
 
988
+ // Reset activity timer — keeps long-running streams alive and detects
989
+ // stalled connections within modelTimeout of the last received chunk.
990
+ clearTimeout(pending.timer);
991
+ if (!frame.payload.done) {
992
+ pending.timer = setTimeout(() => {
993
+ // Capture references before cleanup removes pending from the map
994
+ const { stableStreamId, responseFormat, controller, encoder, model, failoverCandidates, buildFrame } = pending;
995
+ this.cleanupRequest(frame.id);
996
+ this.peerManager.router.markFailed(frame.id);
997
+ this.tryStreamFailover(
998
+ stableStreamId ?? frame.id, responseFormat,
999
+ controller!, encoder!, model ?? "",
1000
+ failoverCandidates ?? [], buildFrame,
1001
+ `stream stalled (no data for ${this.modelTimeout / 1000}s)`,
1002
+ );
1003
+ }, this.modelTimeout);
1004
+ }
1005
+
983
1006
  try {
984
1007
  if (pending.responseFormat === "responses") {
985
1008
  this.handleModelStreamResponses(frame, pending);
@@ -1305,7 +1328,14 @@ export class ModelProxy {
1305
1328
  let chatFallbackResult: Awaited<ReturnType<ModelProxy["retryWithChatCompletions"]>> = null;
1306
1329
  try {
1307
1330
  result = JSON.parse(responseText);
1308
- } catch {
1331
+ // Detect error objects in 200 OK responses (some APIs return HTTP 200 with error body)
1332
+ if (result.error && typeof result.error === "object" && !result.choices && !result.output) {
1333
+ const errMsg = (result.error as { message?: string }).message ?? JSON.stringify(result.error);
1334
+ throw new Error(`Upstream error (200 OK): ${String(errMsg).slice(0, 200)}`);
1335
+ }
1336
+ } catch (parseErr) {
1337
+ // Re-throw non-parse errors (e.g. upstream error detection above)
1338
+ if (!(parseErr instanceof SyntaxError)) throw parseErr;
1309
1339
  // Upstream returned non-JSON (e.g. SSE in non-stream mode) — try chat completions fallback
1310
1340
  if (!cachedApi && isResponsesApi) {
1311
1341
  debug("model_req", `responses API returned non-JSON for "${model.id}", retrying with chat completions`);
@@ -93,6 +93,8 @@ export class PeerManager extends EventEmitter<PeerManagerEvents> {
93
93
  private wss: WebSocketServer | null = null;
94
94
  private reconnectTimers = new Map<string, ReturnType<typeof setTimeout>>();
95
95
  private reconnectAttempts = new Map<string, number>();
96
+ /** Deferred disconnect timers — grace period before broadcasting peer_leave. */
97
+ private disconnectGraceTimers = new Map<string, ReturnType<typeof setTimeout>>();
96
98
  private stopped = false;
97
99
  /** Map from ws WebSocket to Connection for inbound connections. */
98
100
  private inboundConnections = new Map<WsWebSocket, Connection>();
@@ -165,6 +167,17 @@ export class PeerManager extends EventEmitter<PeerManagerEvents> {
165
167
  }
166
168
  }
167
169
 
170
+ /** Update the local tool proxy catalog and re-broadcast to all peers. */
171
+ updateToolCatalog(catalog: import("./types.ts").ToolCatalogEntry[]) {
172
+ if (this.localCapabilities.toolProxy) {
173
+ this.localCapabilities.toolProxy = { ...this.localCapabilities.toolProxy, catalog };
174
+ }
175
+ this.router.updateLocalToolCatalog(catalog);
176
+ for (const conn of this.router.getDirectConnections()) {
177
+ this.sendPeerSync(conn);
178
+ }
179
+ }
180
+
168
181
  // ── Lifecycle ──────────────────────────────────────────────────
169
182
  async start() {
170
183
  await this.approvalManager.load();
@@ -190,6 +203,12 @@ export class PeerManager extends EventEmitter<PeerManagerEvents> {
190
203
  clearTimeout(timer);
191
204
  }
192
205
  this.reconnectTimers.clear();
206
+ // Flush all disconnect grace timers (execute leave immediately on shutdown)
207
+ for (const [nodeId, timer] of this.disconnectGraceTimers) {
208
+ clearTimeout(timer);
209
+ this.executePeerLeave(nodeId);
210
+ }
211
+ this.disconnectGraceTimers.clear();
193
212
 
194
213
  this.router.broadcast({
195
214
  type: "peer_leave",
@@ -202,18 +221,47 @@ export class PeerManager extends EventEmitter<PeerManagerEvents> {
202
221
  conn.close(1000, "shutdown");
203
222
  }
204
223
 
224
+ this.closeServers();
225
+
226
+ this.rateLimiter.destroy();
227
+ this.approvalManager.destroy();
228
+ this.router.destroy();
229
+ }
230
+
231
+ /** Force-stop without broadcasting or waiting — used when graceful stop times out. */
232
+ forceStop() {
233
+ this.stopped = true;
234
+ for (const timer of this.reconnectTimers.values()) clearTimeout(timer);
235
+ this.reconnectTimers.clear();
236
+ for (const [, timer] of this.disconnectGraceTimers) clearTimeout(timer);
237
+ this.disconnectGraceTimers.clear();
238
+ if (this.gossipDebounceTimer) {
239
+ clearTimeout(this.gossipDebounceTimer);
240
+ this.gossipDebounceTimer = null;
241
+ }
242
+ for (const conn of this.router.getDirectConnections()) {
243
+ try { conn.close(1000, "shutdown"); } catch { /* best effort */ }
244
+ }
245
+ this.closeServers();
246
+ this.rateLimiter.destroy();
247
+ this.approvalManager.destroy();
248
+ this.router.destroy();
249
+ }
250
+
251
+ private closeServers() {
205
252
  if (this.wss) {
206
253
  this.wss.close();
207
254
  this.wss = null;
208
255
  }
209
256
  if (this.httpServer) {
257
+ // Force-close all keep-alive connections so the port is released immediately
258
+ const server = this.httpServer as typeof this.httpServer & { closeAllConnections?: () => void };
259
+ if (typeof server.closeAllConnections === "function") {
260
+ server.closeAllConnections();
261
+ }
210
262
  this.httpServer.close();
211
263
  this.httpServer = null;
212
264
  }
213
-
214
- this.rateLimiter.destroy();
215
- this.approvalManager.destroy();
216
- this.router.destroy();
217
265
  }
218
266
 
219
267
  /** Set an HTTP request handler for non-WebSocket requests (e.g. web dashboard). */
@@ -461,9 +509,6 @@ export class PeerManager extends EventEmitter<PeerManagerEvents> {
461
509
  if (this.pendingApprovalConns.has(nodeId)) {
462
510
  debug("approval", `reusing pending approval for ${nodeId}, updating conn ref`);
463
511
  this.pendingApprovalConns.set(nodeId, { conn, caps });
464
- if (this.config.peerApproval?.mode === "required") {
465
- conn.on("close", () => this.onPeerDisconnected(conn));
466
- }
467
512
  return;
468
513
  }
469
514
 
@@ -495,10 +540,12 @@ export class PeerManager extends EventEmitter<PeerManagerEvents> {
495
540
  );
496
541
  }
497
542
  });
498
- // In required mode, don't complete the join yet
543
+ // In required mode, don't complete the join yet.
544
+ // No close handler needed here: the peer was never added to the router,
545
+ // so onPeerDisconnected would broadcast a spurious peer_leave.
546
+ // If the conn drops before approval resolves, the .then() handler sees
547
+ // activeConn.isOpen === false and skips all actions.
499
548
  if (this.config.peerApproval?.mode === "required") {
500
- // Wire up close handler to clean up if connection drops while pending
501
- conn.on("close", () => this.onPeerDisconnected(conn));
502
549
  return;
503
550
  }
504
551
  // In notify mode, requestApproval resolves immediately, but
@@ -515,6 +562,9 @@ export class PeerManager extends EventEmitter<PeerManagerEvents> {
515
562
  private completePeerJoin(conn: Connection, caps: NodeCapabilities) {
516
563
  const nodeId = conn.remoteNodeId!;
517
564
 
565
+ // Cancel disconnect grace timer if the peer is reconnecting
566
+ const wasInGrace = this.cancelDisconnectGrace(nodeId);
567
+
518
568
  // If there's an existing connection for this nodeId (e.g. peer reconnected
519
569
  // while old TCP hadn't closed yet), close it AFTER overwriting the route so
520
570
  // the stale-close guard in onPeerDisconnected correctly skips cleanup.
@@ -585,15 +635,58 @@ export class PeerManager extends EventEmitter<PeerManagerEvents> {
585
635
  return;
586
636
  }
587
637
 
638
+ // Grace period: defer peer_leave broadcast to allow quick reconnection
639
+ // (e.g. iOS WiFi ↔ cellular handoff, brief audio interruption).
640
+ // If the peer reconnects within the grace window, completePeerJoin
641
+ // will cancel this timer via cancelDisconnectGrace.
642
+ const graceMs = this.config.disconnectGrace ?? 30_000;
643
+ if (graceMs <= 0) {
644
+ this.executePeerLeave(nodeId, conn);
645
+ return;
646
+ }
647
+ debug("peer", `onPeerDisconnected(${nodeId}): starting ${graceMs / 1000}s grace period`);
648
+
649
+ // Clear any existing grace timer for this node (shouldn't happen, but be safe)
650
+ this.cancelDisconnectGrace(nodeId);
651
+
652
+ this.disconnectGraceTimers.set(nodeId, setTimeout(() => {
653
+ this.disconnectGraceTimers.delete(nodeId);
654
+ this.executePeerLeave(nodeId, conn);
655
+ }, graceMs));
656
+ }
657
+
658
+ /** Cancel a pending disconnect grace timer (called when peer reconnects quickly). */
659
+ private cancelDisconnectGrace(nodeId: string): boolean {
660
+ const timer = this.disconnectGraceTimers.get(nodeId);
661
+ if (timer) {
662
+ clearTimeout(timer);
663
+ this.disconnectGraceTimers.delete(nodeId);
664
+ debug("peer", `cancelDisconnectGrace(${nodeId}): peer reconnected within grace period`);
665
+ return true;
666
+ }
667
+ return false;
668
+ }
669
+
670
+ /** Execute the actual peer leave (after grace period expires or immediate for shutdown). */
671
+ private executePeerLeave(nodeId: string, conn?: Connection) {
672
+ // Double-check the route hasn't been replaced by a new connection during grace
673
+ if (conn) {
674
+ const currentRoute = this.router.getRoute(nodeId);
675
+ if (currentRoute?.connection && currentRoute.connection !== conn) {
676
+ debug("peer", `executePeerLeave(${nodeId}): route replaced during grace — skipping`);
677
+ return;
678
+ }
679
+ }
680
+
588
681
  audit("peer_leave", { nodeId });
589
682
  this.router.removePeer(nodeId);
590
683
 
591
684
  // Remove satellite contexts that were only reachable via this peer
592
- this.satelliteContexts = this.satelliteContexts.filter(s => {
593
- // Keep satellites that are not associated with the disconnected peer
594
- // (satellite nodeIds typically differ from mesh peer nodeIds)
595
- return s.nodeId !== nodeId;
596
- });
685
+ for (let i = this.satelliteContexts.length - 1; i >= 0; i--) {
686
+ if (this.satelliteContexts[i].nodeId === nodeId) {
687
+ this.satelliteContexts.splice(i, 1);
688
+ }
689
+ }
597
690
 
598
691
  this.router.broadcast({
599
692
  type: "peer_leave",
@@ -748,13 +841,17 @@ export class PeerManager extends EventEmitter<PeerManagerEvents> {
748
841
  const prev = this.router.getRoute(peer.nodeId);
749
842
  const hadAgents = prev?.agents.length ?? 0;
750
843
  const hadDirectPeers = prev?.directPeers.length ?? 0;
751
- const hadToolProxy = JSON.stringify(prev?.toolProxy);
752
844
  const hadDeviceInfo = prev?.deviceInfo?.hostname;
753
845
  const hadAcpAgents = prev?.acpAgents?.length ?? 0;
846
+ const hadToolProxyEnabled = prev?.toolProxy?.enabled;
847
+ const hadToolProxyCatalogLen = prev?.toolProxy?.catalog?.length ?? 0;
848
+ const hadToolProxyAllowLen = prev?.toolProxy?.allow?.length ?? 0;
754
849
  this.router.updatePeerCapabilities(peer.nodeId, peer);
755
850
  if (peer.agents.length !== hadAgents || peer.models.length !== (prev?.models.length ?? 0)
756
851
  || (peer.directPeers?.length ?? 0) !== hadDirectPeers
757
- || JSON.stringify(peer.toolProxy) !== hadToolProxy
852
+ || peer.toolProxy?.enabled !== hadToolProxyEnabled
853
+ || (peer.toolProxy?.catalog?.length ?? 0) !== hadToolProxyCatalogLen
854
+ || (peer.toolProxy?.allow?.length ?? 0) !== hadToolProxyAllowLen
758
855
  || peer.deviceInfo?.hostname !== hadDeviceInfo
759
856
  || (peer.acpAgents?.length ?? 0) !== hadAcpAgents) {
760
857
  changed = true;
@@ -33,19 +33,20 @@ export class RateLimiter {
33
33
 
34
34
  let timestamps = this.attempts.get(ip);
35
35
  if (timestamps) {
36
- // Remove expired entries
37
- timestamps = timestamps.filter((t) => t > cutoff);
36
+ // In-place pruning: find first non-expired index and splice
37
+ let firstValid = 0;
38
+ while (firstValid < timestamps.length && timestamps[firstValid] <= cutoff) firstValid++;
39
+ if (firstValid > 0) timestamps.splice(0, firstValid);
38
40
  } else {
39
41
  timestamps = [];
42
+ this.attempts.set(ip, timestamps);
40
43
  }
41
44
 
42
45
  if (timestamps.length >= this.config.maxAttempts) {
43
- this.attempts.set(ip, timestamps);
44
46
  return false;
45
47
  }
46
48
 
47
49
  timestamps.push(now);
48
- this.attempts.set(ip, timestamps);
49
50
  return true;
50
51
  }
51
52
 
@@ -61,19 +62,24 @@ export class RateLimiter {
61
62
  /** Get remaining attempts for an IP. */
62
63
  remaining(ip: string): number {
63
64
  const cutoff = Date.now() - this.config.windowMs;
64
- const timestamps = this.attempts.get(ip) ?? [];
65
- const active = timestamps.filter((t) => t > cutoff).length;
65
+ const timestamps = this.attempts.get(ip);
66
+ if (!timestamps) return this.config.maxAttempts;
67
+ let active = 0;
68
+ for (let i = timestamps.length - 1; i >= 0; i--) {
69
+ if (timestamps[i] > cutoff) active++; else break;
70
+ }
66
71
  return Math.max(0, this.config.maxAttempts - active);
67
72
  }
68
73
 
69
74
  private gc() {
70
75
  const cutoff = Date.now() - this.config.windowMs;
71
76
  for (const [ip, timestamps] of this.attempts) {
72
- const active = timestamps.filter((t) => t > cutoff);
73
- if (active.length === 0) {
77
+ let firstValid = 0;
78
+ while (firstValid < timestamps.length && timestamps[firstValid] <= cutoff) firstValid++;
79
+ if (firstValid === timestamps.length) {
74
80
  this.attempts.delete(ip);
75
- } else {
76
- this.attempts.set(ip, active);
81
+ } else if (firstValid > 0) {
82
+ timestamps.splice(0, firstValid);
77
83
  }
78
84
  }
79
85
  }