@hienlh/ppm 0.8.63 → 0.8.64

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 CHANGED
@@ -1,5 +1,14 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.8.64] - 2026-03-27
4
+
5
+ ### Fixed
6
+ - **Usage polling dedup**: Concurrent `pollOnce` calls now share a single in-flight fetch instead of hammering the API
7
+ - **429 cooldown floor**: Minimum 60s cooldown on 429 responses, even if `retry-after` header is lower
8
+
9
+ ### Added
10
+ - **Browser preview tests**: Unit tests (12) for tunnel route validation and lifecycle; integration test (6) verifying real Cloudflare tunnel creation and access
11
+
3
12
  ## [0.8.63] - 2026-03-27
4
13
 
5
14
  ### Changed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hienlh/ppm",
3
- "version": "0.8.63",
3
+ "version": "0.8.64",
4
4
  "description": "Personal Project Manager — mobile-first web IDE with AI assistance",
5
5
  "author": "hienlh",
6
6
  "license": "MIT",
@@ -21,8 +21,8 @@ interface ActiveTunnel {
21
21
  startedAt: number;
22
22
  }
23
23
 
24
- /** Active tunnels keyed by port */
25
- const activeTunnels = new Map<number, ActiveTunnel>();
24
+ /** Active tunnels keyed by port — exported for testing */
25
+ export const activeTunnels = new Map<number, ActiveTunnel>();
26
26
 
27
27
  /** Start a tunnel for a localhost port */
28
28
  browserPreviewRoutes.post("/tunnel", async (c) => {
@@ -51,6 +51,10 @@ let pollTimer: ReturnType<typeof setInterval> | null = null;
51
51
 
52
52
  // Per-token cooldown map: token prefix → earliest allowed fetch time
53
53
  const tokenCooldowns = new Map<string, number>();
54
+ const MIN_COOLDOWN_MS = 60_000; // floor: at least 60s cooldown on 429
55
+
56
+ // Dedup: if a poll is already in-flight, reuse the same promise
57
+ let inflightPoll: Promise<void> | null = null;
54
58
 
55
59
  // Legacy: Keychain token cache for users without accounts in DB
56
60
  let tokenCache: { token: string; timestamp: number } | null = null;
@@ -113,9 +117,10 @@ async function fetchUsageForToken(token: string): Promise<ClaudeUsage> {
113
117
  });
114
118
  if (res.status === 429) {
115
119
  const retryAfter = parseInt(res.headers.get("retry-after") ?? "60", 10);
120
+ const cooldownMs = Math.max(retryAfter * 1000, MIN_COOLDOWN_MS);
116
121
  const cooldownKey = token.substring(0, 20);
117
- tokenCooldowns.set(cooldownKey, Date.now() + retryAfter * 1000);
118
- throw new Error(`Usage API 429 — cooldown ${retryAfter}s`);
122
+ tokenCooldowns.set(cooldownKey, Date.now() + cooldownMs);
123
+ throw new Error(`Usage API 429 — cooldown ${Math.ceil(cooldownMs / 1000)}s`);
119
124
  }
120
125
  if (!res.ok) throw new Error(`Usage API returned ${res.status}`);
121
126
  const raw = (await res.json()) as Record<string, any>;
@@ -228,7 +233,7 @@ async function fetchLegacySingleAccount(): Promise<void> {
228
233
  } catch {}
229
234
  }
230
235
 
231
- async function pollOnce(): Promise<void> {
236
+ async function pollOnceInternal(): Promise<void> {
232
237
  try {
233
238
  const hasAccounts = accountService.list().length > 0;
234
239
  if (hasAccounts) {
@@ -241,6 +246,13 @@ async function pollOnce(): Promise<void> {
241
246
  }
242
247
  }
243
248
 
249
+ /** Deduped: concurrent callers share a single in-flight fetch */
250
+ async function pollOnce(): Promise<void> {
251
+ if (inflightPoll) return inflightPoll;
252
+ inflightPoll = pollOnceInternal().finally(() => { inflightPoll = null; });
253
+ return inflightPoll;
254
+ }
255
+
244
256
  // ---------------------------------------------------------------------------
245
257
  // Public API
246
258
  // ---------------------------------------------------------------------------