@juspay/neurolink 10.1.1 → 10.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 +13 -0
- package/dist/browser/neurolink.min.js +389 -389
- package/dist/cli/commands/proxy.js +85 -14
- package/dist/lib/proxy/proxyPaths.d.ts +1 -0
- package/dist/lib/proxy/proxyPaths.js +5 -0
- package/dist/lib/proxy/usageStats.d.ts +58 -4
- package/dist/lib/proxy/usageStats.js +639 -79
- package/dist/lib/types/proxy.d.ts +39 -0
- package/dist/lib/utils/imageCache.js +93 -25
- package/dist/proxy/proxyPaths.d.ts +1 -0
- package/dist/proxy/proxyPaths.js +5 -0
- package/dist/proxy/usageStats.d.ts +58 -4
- package/dist/proxy/usageStats.js +639 -79
- package/dist/types/proxy.d.ts +39 -0
- package/dist/utils/imageCache.js +93 -25
- package/package.json +5 -3
|
@@ -700,6 +700,40 @@ export type ProxyStats = {
|
|
|
700
700
|
totalQuotaRateLimits: number;
|
|
701
701
|
accounts: Record<string, AccountStats>;
|
|
702
702
|
};
|
|
703
|
+
/** Durability and reconciliation state for the proxy usage counters. */
|
|
704
|
+
export type ProxyStatsPersistenceStatus = {
|
|
705
|
+
enabled: boolean;
|
|
706
|
+
filePath: string | null;
|
|
707
|
+
revision: number;
|
|
708
|
+
pendingMutations: number;
|
|
709
|
+
inFlightMutations: number;
|
|
710
|
+
unpersistedMutations: number;
|
|
711
|
+
lastFlushedAt: number | null;
|
|
712
|
+
lastReconciledAt: number | null;
|
|
713
|
+
lastRecoveryAt: number | null;
|
|
714
|
+
lastError: string | null;
|
|
715
|
+
};
|
|
716
|
+
/** Versioned on-disk snapshot shared by overlapping proxy workers. */
|
|
717
|
+
export type PersistedProxyStatsSnapshot = {
|
|
718
|
+
schemaVersion: 1;
|
|
719
|
+
revision: number;
|
|
720
|
+
updatedAt: number;
|
|
721
|
+
stats: ProxyStats;
|
|
722
|
+
};
|
|
723
|
+
/** Ownership metadata for the proxy statistics cross-process lock. */
|
|
724
|
+
export type ProxyStatsLockOwner = {
|
|
725
|
+
token: string;
|
|
726
|
+
pid: number;
|
|
727
|
+
acquiredAt: number;
|
|
728
|
+
};
|
|
729
|
+
/** Construction options for an isolated proxy statistics store. */
|
|
730
|
+
export type ProxyUsageStatsStoreOptions = {
|
|
731
|
+
filePath?: string;
|
|
732
|
+
flushIntervalMs?: number;
|
|
733
|
+
lockTimeoutMs?: number;
|
|
734
|
+
staleLockMs?: number;
|
|
735
|
+
now?: () => number;
|
|
736
|
+
};
|
|
703
737
|
export type RefreshableAccount = {
|
|
704
738
|
token: string;
|
|
705
739
|
refreshToken?: string;
|
|
@@ -874,6 +908,8 @@ export type ProxyPaths = {
|
|
|
874
908
|
quotaFile: string;
|
|
875
909
|
/** account-cooldowns.json — restart-safe account cooldown state */
|
|
876
910
|
cooldownFile: string;
|
|
911
|
+
/** proxy-usage-stats.json — restart- and handoff-safe usage counters */
|
|
912
|
+
statsFile?: string;
|
|
877
913
|
/** Whether this is a dev-mode isolated instance */
|
|
878
914
|
isDev: boolean;
|
|
879
915
|
};
|
|
@@ -1876,6 +1912,7 @@ export type ProxyStartApp = {
|
|
|
1876
1912
|
};
|
|
1877
1913
|
/** Stats shape consumed by the proxy status printer. */
|
|
1878
1914
|
export type StatusStats = {
|
|
1915
|
+
startedAt?: number;
|
|
1879
1916
|
totalAttempts?: number;
|
|
1880
1917
|
totalAttemptErrors?: number;
|
|
1881
1918
|
totalRequests: number;
|
|
@@ -1896,7 +1933,9 @@ export type StatusStats = {
|
|
|
1896
1933
|
transientRateLimits?: number;
|
|
1897
1934
|
quotaRateLimits?: number;
|
|
1898
1935
|
cooling: boolean;
|
|
1936
|
+
status?: "active" | "cooling" | "disabled" | "excluded" | "removed";
|
|
1899
1937
|
}[];
|
|
1938
|
+
persistence?: ProxyStatsPersistenceStatus;
|
|
1900
1939
|
};
|
|
1901
1940
|
/** Sub-action of the `proxy telemetry` CLI command. */
|
|
1902
1941
|
export type ProxyTelemetryAction = "setup" | "start" | "stop" | "status" | "logs" | "import-dashboard";
|
|
@@ -23,7 +23,11 @@ import { normalizeUrlForCache, redactUrlForError } from "./logSanitize.js";
|
|
|
23
23
|
*/
|
|
24
24
|
export class ImageCache {
|
|
25
25
|
cache = new Map();
|
|
26
|
-
|
|
26
|
+
// contentHash -> set of normalized urls whose cache entry holds that
|
|
27
|
+
// content. A Set (not a single url) so that evicting/deleting one url
|
|
28
|
+
// doesn't drop the dedup mapping for other urls still caching the same
|
|
29
|
+
// content (#1213).
|
|
30
|
+
contentHashIndex = new Map();
|
|
27
31
|
maxSize;
|
|
28
32
|
ttlMs;
|
|
29
33
|
maxImageSize;
|
|
@@ -164,11 +168,37 @@ export class ImageCache {
|
|
|
164
168
|
* Useful for deduplication when the same image is accessed via different URLs
|
|
165
169
|
*/
|
|
166
170
|
getByContentHash(contentHash) {
|
|
167
|
-
const
|
|
168
|
-
if (!
|
|
171
|
+
const urlSet = this.contentHashIndex.get(contentHash);
|
|
172
|
+
if (!urlSet) {
|
|
169
173
|
return null;
|
|
170
174
|
}
|
|
171
|
-
|
|
175
|
+
// Find the first url whose cache entry is still live, pruning any url
|
|
176
|
+
// whose entry no longer exists in `this.cache` along the way. Entries
|
|
177
|
+
// that exist but are expired are left in the set (not "gone") - the
|
|
178
|
+
// normal TTL/get() path is responsible for reaping those.
|
|
179
|
+
let liveUrl = null;
|
|
180
|
+
// Iterate a copy so reaping below can mutate the set safely.
|
|
181
|
+
for (const url of [...urlSet]) {
|
|
182
|
+
const entry = this.cache.get(url);
|
|
183
|
+
if (!entry) {
|
|
184
|
+
urlSet.delete(url);
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
if (this.isExpired(entry)) {
|
|
188
|
+
// Reap expired entries here too (matching get() semantics) so stale
|
|
189
|
+
// urls don't accumulate when callers primarily use getByContentHash.
|
|
190
|
+
this.cache.delete(url);
|
|
191
|
+
urlSet.delete(url);
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
if (liveUrl === null) {
|
|
195
|
+
liveUrl = url;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
if (urlSet.size === 0) {
|
|
199
|
+
this.contentHashIndex.delete(contentHash);
|
|
200
|
+
}
|
|
201
|
+
return liveUrl ? this.get(liveUrl) : null;
|
|
172
202
|
}
|
|
173
203
|
/**
|
|
174
204
|
* Store an image in the cache
|
|
@@ -192,21 +222,42 @@ export class ImageCache {
|
|
|
192
222
|
}
|
|
193
223
|
// Generate content hash
|
|
194
224
|
const contentHash = this.generateContentHash(imageData);
|
|
195
|
-
// Check if same content already exists under different URL
|
|
196
|
-
const
|
|
197
|
-
if (
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
this.
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
225
|
+
// Check if same content already exists under a different URL
|
|
226
|
+
const existingUrls = this.contentHashIndex.get(contentHash);
|
|
227
|
+
if (existingUrls) {
|
|
228
|
+
for (const existingUrl of existingUrls) {
|
|
229
|
+
if (existingUrl === normalizedUrl) {
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
// Content already cached under a different URL - create a shallow copy
|
|
233
|
+
const existingEntry = this.cache.get(existingUrl);
|
|
234
|
+
if (existingEntry && !this.isExpired(existingEntry)) {
|
|
235
|
+
// A dedup hit still adds a cache entry for the new URL, so enforce
|
|
236
|
+
// capacity first — otherwise many URLs sharing one content hash could
|
|
237
|
+
// grow the cache past maxSize.
|
|
238
|
+
while (this.cache.size >= this.maxSize && this.cache.size > 0) {
|
|
239
|
+
this.evictOldest();
|
|
240
|
+
}
|
|
241
|
+
// Create a shallow copy for the new URL to avoid shared reference issues
|
|
242
|
+
this.cache.set(normalizedUrl, { ...existingEntry });
|
|
243
|
+
// Re-fetch (or re-create) the hash's URL set: the eviction above may
|
|
244
|
+
// have removed this hash's last url, emptying the set and deleting it
|
|
245
|
+
// from contentHashIndex — reusing the stale `existingUrls` reference
|
|
246
|
+
// would add the new url to an orphaned set that the index no longer
|
|
247
|
+
// points to, leaving a live entry unreachable by content hash
|
|
248
|
+
// (the exact #1213 failure mode). Never replace an existing mapping.
|
|
249
|
+
let hashUrls = this.contentHashIndex.get(contentHash);
|
|
250
|
+
if (!hashUrls) {
|
|
251
|
+
hashUrls = new Set();
|
|
252
|
+
this.contentHashIndex.set(contentHash, hashUrls);
|
|
253
|
+
}
|
|
254
|
+
hashUrls.add(normalizedUrl);
|
|
255
|
+
logger.debug("Image cache dedup hit", {
|
|
256
|
+
newUrl: redactUrlForError(normalizedUrl),
|
|
257
|
+
existingUrl: redactUrlForError(existingUrl),
|
|
258
|
+
});
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
210
261
|
}
|
|
211
262
|
}
|
|
212
263
|
// Evict if at capacity
|
|
@@ -224,7 +275,12 @@ export class ImageCache {
|
|
|
224
275
|
accessCount: 1,
|
|
225
276
|
};
|
|
226
277
|
this.cache.set(normalizedUrl, entry);
|
|
227
|
-
this.contentHashIndex.
|
|
278
|
+
let urlSet = this.contentHashIndex.get(contentHash);
|
|
279
|
+
if (!urlSet) {
|
|
280
|
+
urlSet = new Set();
|
|
281
|
+
this.contentHashIndex.set(contentHash, urlSet);
|
|
282
|
+
}
|
|
283
|
+
urlSet.add(normalizedUrl);
|
|
228
284
|
logger.debug("Image cached", {
|
|
229
285
|
url: redactUrlForError(normalizedUrl),
|
|
230
286
|
size,
|
|
@@ -239,9 +295,14 @@ export class ImageCache {
|
|
|
239
295
|
const normalizedUrl = this.normalizeUrl(url);
|
|
240
296
|
const entry = this.cache.get(normalizedUrl);
|
|
241
297
|
if (entry) {
|
|
242
|
-
// Remove from content hash
|
|
243
|
-
|
|
244
|
-
|
|
298
|
+
// Remove this URL from its content hash's URL set; only drop the
|
|
299
|
+
// hash entry once no URL maps to that content anymore (#1213).
|
|
300
|
+
const urlSet = this.contentHashIndex.get(entry.contentHash);
|
|
301
|
+
if (urlSet) {
|
|
302
|
+
urlSet.delete(normalizedUrl);
|
|
303
|
+
if (urlSet.size === 0) {
|
|
304
|
+
this.contentHashIndex.delete(entry.contentHash);
|
|
305
|
+
}
|
|
245
306
|
}
|
|
246
307
|
this.cache.delete(normalizedUrl);
|
|
247
308
|
return true;
|
|
@@ -257,8 +318,15 @@ export class ImageCache {
|
|
|
257
318
|
if (oldestKey !== undefined) {
|
|
258
319
|
const entry = this.cache.get(oldestKey);
|
|
259
320
|
if (entry) {
|
|
260
|
-
|
|
261
|
-
|
|
321
|
+
// Remove only the evicted URL from its content hash's URL set; the
|
|
322
|
+
// hash entry is dropped only once no URL maps to it anymore
|
|
323
|
+
// (#1213).
|
|
324
|
+
const urlSet = this.contentHashIndex.get(entry.contentHash);
|
|
325
|
+
if (urlSet) {
|
|
326
|
+
urlSet.delete(oldestKey);
|
|
327
|
+
if (urlSet.size === 0) {
|
|
328
|
+
this.contentHashIndex.delete(entry.contentHash);
|
|
329
|
+
}
|
|
262
330
|
}
|
|
263
331
|
}
|
|
264
332
|
this.cache.delete(oldestKey);
|
package/dist/proxy/proxyPaths.js
CHANGED
|
@@ -22,6 +22,7 @@ export function resolveProxyPaths(dev) {
|
|
|
22
22
|
logsDir: join(base, "logs"),
|
|
23
23
|
quotaFile: join(base, "account-quotas.json"),
|
|
24
24
|
cooldownFile: join(base, "account-cooldowns.json"),
|
|
25
|
+
statsFile: join(base, "proxy-usage-stats.json"),
|
|
25
26
|
isDev: true,
|
|
26
27
|
};
|
|
27
28
|
}
|
|
@@ -31,6 +32,10 @@ export function resolveProxyPaths(dev) {
|
|
|
31
32
|
logsDir: join(base, "logs"),
|
|
32
33
|
quotaFile: join(base, "account-quotas.json"),
|
|
33
34
|
cooldownFile: join(base, "account-cooldowns.json"),
|
|
35
|
+
statsFile: join(base, "proxy-usage-stats.json"),
|
|
34
36
|
isDev: false,
|
|
35
37
|
};
|
|
36
38
|
}
|
|
39
|
+
export function resolveProxyUsageStatsPath(paths) {
|
|
40
|
+
return paths.statsFile ?? join(paths.stateDir, "proxy-usage-stats.json");
|
|
41
|
+
}
|
|
@@ -1,13 +1,67 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Proxy
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* Proxy usage statistics with restart- and handoff-safe persistence.
|
|
3
|
+
*
|
|
4
|
+
* Request handlers update memory synchronously. Small deltas are merged into a
|
|
5
|
+
* shared snapshot asynchronously, under a cross-process lock, so overlapping
|
|
6
|
+
* rolling workers cannot overwrite each other's counters.
|
|
5
7
|
*/
|
|
6
|
-
import type { AccountStats, ProxyStats } from "../types/index.js";
|
|
8
|
+
import type { AccountStats, ProxyStats, ProxyStatsPersistenceStatus, ProxyUsageStatsStoreOptions } from "../types/index.js";
|
|
9
|
+
export declare class ProxyUsageStatsStore {
|
|
10
|
+
private readonly now;
|
|
11
|
+
private readonly flushIntervalMs;
|
|
12
|
+
private readonly lockTimeoutMs;
|
|
13
|
+
private readonly staleLockMs;
|
|
14
|
+
private filePath?;
|
|
15
|
+
private stats;
|
|
16
|
+
private pending;
|
|
17
|
+
private pendingMutations;
|
|
18
|
+
private inFlightMutations;
|
|
19
|
+
private revision;
|
|
20
|
+
private flushTimer;
|
|
21
|
+
private readonly flushMutex;
|
|
22
|
+
private lastFlushedAt?;
|
|
23
|
+
private lastReconciledAt?;
|
|
24
|
+
private lastRecoveryAt?;
|
|
25
|
+
private lastError?;
|
|
26
|
+
constructor(options?: ProxyUsageStatsStoreOptions);
|
|
27
|
+
initialize(filePath?: string): Promise<void>;
|
|
28
|
+
recordAttempt(accountLabel: string, accountType: string): void;
|
|
29
|
+
recordFinalSuccess(accountLabel?: string, accountType?: string): void;
|
|
30
|
+
recordAttemptError(accountLabel: string, accountType: string, status: number, rateLimitKind?: "transient" | "quota"): void;
|
|
31
|
+
recordFinalError(_status: number, accountLabel?: string, accountType?: string): void;
|
|
32
|
+
getStats(): ProxyStats;
|
|
33
|
+
getAccountStats(label: string): AccountStats | undefined;
|
|
34
|
+
getPersistenceStatus(): ProxyStatsPersistenceStatus;
|
|
35
|
+
flush(): Promise<void>;
|
|
36
|
+
reconcile(): Promise<ProxyStats>;
|
|
37
|
+
resetMemory(): void;
|
|
38
|
+
resetForTests(): Promise<void>;
|
|
39
|
+
private markMutation;
|
|
40
|
+
private applyAttempt;
|
|
41
|
+
private applyFinalSuccess;
|
|
42
|
+
private applyAttemptError;
|
|
43
|
+
private applyFinalError;
|
|
44
|
+
private ensureAccount;
|
|
45
|
+
private scheduleFlush;
|
|
46
|
+
private cancelFlushTimer;
|
|
47
|
+
private readSnapshot;
|
|
48
|
+
private applySnapshot;
|
|
49
|
+
private recoverCorruptSnapshot;
|
|
50
|
+
private quarantineCorruptSnapshot;
|
|
51
|
+
private pruneCorruptSnapshots;
|
|
52
|
+
private describeError;
|
|
53
|
+
}
|
|
54
|
+
export declare function initUsageStats(filePath: string): Promise<void>;
|
|
7
55
|
export declare function recordAttempt(accountLabel: string, accountType: string): void;
|
|
8
56
|
export declare function recordFinalSuccess(accountLabel?: string, accountType?: string): void;
|
|
9
57
|
export declare function recordAttemptError(accountLabel: string, accountType: string, status: number, rateLimitKind?: "transient" | "quota"): void;
|
|
10
58
|
export declare function recordFinalError(_status: number, accountLabel?: string, accountType?: string): void;
|
|
11
59
|
export declare function getStats(): ProxyStats;
|
|
60
|
+
export declare function getReconciledStats(): Promise<ProxyStats>;
|
|
12
61
|
export declare function getAccountStats(label: string): AccountStats | undefined;
|
|
62
|
+
export declare function getUsageStatsPersistenceStatus(): ProxyStatsPersistenceStatus;
|
|
63
|
+
export declare function flushUsageStats(): Promise<void>;
|
|
64
|
+
/** Reset process-local counters while intentionally preserving durable state. */
|
|
13
65
|
export declare function resetStats(): void;
|
|
66
|
+
/** Disconnect persistence and clear singleton state between isolated tests. */
|
|
67
|
+
export declare function resetUsageStatsForTests(): Promise<void>;
|