threadforge 0.1.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/LICENSE +21 -0
- package/README.md +152 -0
- package/bin/forge.js +1050 -0
- package/bin/host-commands.js +344 -0
- package/bin/platform-commands.js +570 -0
- package/package.json +71 -0
- package/shared/auth.js +475 -0
- package/src/core/DirectMessageBus.js +364 -0
- package/src/core/EndpointResolver.js +247 -0
- package/src/core/ForgeContext.js +2227 -0
- package/src/core/ForgeHost.js +122 -0
- package/src/core/ForgePlatform.js +145 -0
- package/src/core/Ingress.js +768 -0
- package/src/core/Interceptors.js +420 -0
- package/src/core/MessageBus.js +310 -0
- package/src/core/Prometheus.js +305 -0
- package/src/core/RequestContext.js +413 -0
- package/src/core/RoutingStrategy.js +316 -0
- package/src/core/Supervisor.js +1306 -0
- package/src/core/ThreadAllocator.js +196 -0
- package/src/core/WorkerChannelManager.js +879 -0
- package/src/core/config.js +624 -0
- package/src/core/host-config.js +311 -0
- package/src/core/network-utils.js +166 -0
- package/src/core/platform-config.js +308 -0
- package/src/decorators/ServiceProxy.js +899 -0
- package/src/decorators/index.js +571 -0
- package/src/deploy/NginxGenerator.js +865 -0
- package/src/deploy/PlatformManifestGenerator.js +96 -0
- package/src/deploy/RouteManifestGenerator.js +112 -0
- package/src/deploy/index.js +984 -0
- package/src/frontend/FrontendDevLifecycle.js +65 -0
- package/src/frontend/FrontendPluginOrchestrator.js +187 -0
- package/src/frontend/SiteResolver.js +63 -0
- package/src/frontend/StaticMountRegistry.js +90 -0
- package/src/frontend/index.js +5 -0
- package/src/frontend/plugins/index.js +2 -0
- package/src/frontend/plugins/viteFrontend.js +79 -0
- package/src/frontend/types.js +35 -0
- package/src/index.js +56 -0
- package/src/internals.js +31 -0
- package/src/plugins/PluginManager.js +537 -0
- package/src/plugins/ScopedPostgres.js +192 -0
- package/src/plugins/ScopedRedis.js +142 -0
- package/src/plugins/index.js +1729 -0
- package/src/registry/ServiceRegistry.js +796 -0
- package/src/scaling/ScaleAdvisor.js +442 -0
- package/src/services/Service.js +195 -0
- package/src/services/worker-bootstrap.js +676 -0
- package/src/templates/auth-service.js +65 -0
- package/src/templates/identity-service.js +75 -0
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
import { normalizeErrorCode } from "./Prometheus.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* RPC Interceptors
|
|
5
|
+
*
|
|
6
|
+
* Middleware chain that wraps every proxy call. Interceptors run
|
|
7
|
+
* in order before the call, and in reverse order after.
|
|
8
|
+
*
|
|
9
|
+
* Middleware chain for proxy calls (IPC and HTTP).
|
|
10
|
+
*
|
|
11
|
+
* this.users.getUser('123')
|
|
12
|
+
* → DeadlineInterceptor (propagate remaining time)
|
|
13
|
+
* → LoggingInterceptor (log call + duration)
|
|
14
|
+
* → CircuitBreaker (fail fast if service is down)
|
|
15
|
+
* → RetryInterceptor (retry on transient failures)
|
|
16
|
+
* → actual IPC call
|
|
17
|
+
* ← RetryInterceptor
|
|
18
|
+
* ← CircuitBreaker (track success/failure)
|
|
19
|
+
* ← LoggingInterceptor (log result)
|
|
20
|
+
* ← DeadlineInterceptor
|
|
21
|
+
*
|
|
22
|
+
* Usage in config:
|
|
23
|
+
*
|
|
24
|
+
* static contract = {
|
|
25
|
+
* expose: ['getUser'],
|
|
26
|
+
* interceptors: ['deadline', 'logging', 'circuitBreaker'],
|
|
27
|
+
* };
|
|
28
|
+
*
|
|
29
|
+
* Or per-method:
|
|
30
|
+
*
|
|
31
|
+
* @Expose({ interceptors: ['retry'], retry: { maxAttempts: 3 } })
|
|
32
|
+
* async getUser(id) { ... }
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @typedef {Object} CallContext
|
|
37
|
+
* @property {string} from - Calling service name
|
|
38
|
+
* @property {string} target - Target service name
|
|
39
|
+
* @property {string} method - Method name being called
|
|
40
|
+
* @property {any[]} args - Method arguments
|
|
41
|
+
* @property {number} deadline - Absolute timestamp when the call must complete
|
|
42
|
+
* @property {Map} metadata - Key-value metadata
|
|
43
|
+
* @property {number} attempt - Current retry attempt (0-based)
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Run a call through an interceptor chain.
|
|
48
|
+
*
|
|
49
|
+
* Interceptors MAY call next() multiple times (e.g., the retry interceptor
|
|
50
|
+
* invokes next() on each attempt). Downstream interceptors and the final
|
|
51
|
+
* handler should therefore be idempotent-safe.
|
|
52
|
+
*
|
|
53
|
+
* @param {Function[]} interceptors - Array of interceptor functions
|
|
54
|
+
* @param {CallContext} callCtx - The call context
|
|
55
|
+
* @param {Function} finalHandler - The actual IPC call
|
|
56
|
+
* @returns {Promise<*>}
|
|
57
|
+
*/
|
|
58
|
+
export async function runInterceptorChain(interceptors, callCtx, finalHandler) {
|
|
59
|
+
let index = 0;
|
|
60
|
+
|
|
61
|
+
async function next() {
|
|
62
|
+
if (index >= interceptors.length) {
|
|
63
|
+
return finalHandler(callCtx);
|
|
64
|
+
}
|
|
65
|
+
const interceptor = interceptors[index++];
|
|
66
|
+
return interceptor(callCtx, next);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return next();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ─── Deadline Propagation ───────────────────────────────────
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Propagates deadlines across service boundaries.
|
|
76
|
+
*
|
|
77
|
+
* If gateway sets a 5s timeout, and the users service call takes
|
|
78
|
+
* 2s, the billing service call from users only gets 3s — not a
|
|
79
|
+
* fresh 5s.
|
|
80
|
+
*
|
|
81
|
+
* This prevents cascading timeouts where each service adds its
|
|
82
|
+
* own full timeout on top.
|
|
83
|
+
*/
|
|
84
|
+
export function deadlineInterceptor(defaultTimeoutMs = 5000) {
|
|
85
|
+
return async function deadline(callCtx, next) {
|
|
86
|
+
// If no deadline set yet, create one
|
|
87
|
+
if (!callCtx.deadline) {
|
|
88
|
+
callCtx.deadline = Date.now() + defaultTimeoutMs;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Check if we've already exceeded the deadline
|
|
92
|
+
const remaining = callCtx.deadline - Date.now();
|
|
93
|
+
if (remaining <= 0) {
|
|
94
|
+
const err = new Error(
|
|
95
|
+
`Deadline exceeded before calling ${callCtx.target}.${callCtx.method}` + ` (from ${callCtx.from})`,
|
|
96
|
+
);
|
|
97
|
+
err.code = 'DEADLINE_EXCEEDED';
|
|
98
|
+
throw err;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Pass the remaining time as the timeout for the actual call
|
|
102
|
+
callCtx.timeout = remaining;
|
|
103
|
+
|
|
104
|
+
// Add deadline to metadata so the receiving service knows
|
|
105
|
+
callCtx.metadata.deadline = callCtx.deadline;
|
|
106
|
+
|
|
107
|
+
return next();
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ─── Logging ────────────────────────────────────────────────
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Logs every RPC call with duration and outcome.
|
|
115
|
+
* Integrates with the service's structured logger.
|
|
116
|
+
*/
|
|
117
|
+
export function loggingInterceptor(logger) {
|
|
118
|
+
return async function logging(callCtx, next) {
|
|
119
|
+
const start = performance.now();
|
|
120
|
+
|
|
121
|
+
try {
|
|
122
|
+
const result = await next();
|
|
123
|
+
const duration = performance.now() - start;
|
|
124
|
+
|
|
125
|
+
if (logger) {
|
|
126
|
+
logger.debug("RPC call", {
|
|
127
|
+
target: callCtx.target,
|
|
128
|
+
method: callCtx.method,
|
|
129
|
+
duration: `${duration.toFixed(2)}ms`,
|
|
130
|
+
attempt: callCtx.attempt,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return result;
|
|
135
|
+
} catch (err) {
|
|
136
|
+
const duration = performance.now() - start;
|
|
137
|
+
|
|
138
|
+
if (logger) {
|
|
139
|
+
logger.warn("RPC call failed", {
|
|
140
|
+
target: callCtx.target,
|
|
141
|
+
method: callCtx.method,
|
|
142
|
+
duration: `${duration.toFixed(2)}ms`,
|
|
143
|
+
error: err.message,
|
|
144
|
+
attempt: callCtx.attempt,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
throw err;
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ─── Circuit Breaker ────────────────────────────────────────
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Implements the circuit breaker pattern.
|
|
157
|
+
*
|
|
158
|
+
* States:
|
|
159
|
+
* CLOSED → Normal operation. Tracks failure rate.
|
|
160
|
+
* OPEN → Calls fail immediately. Checked periodically.
|
|
161
|
+
* HALF → Allows one probe call. Success → CLOSED, failure → OPEN.
|
|
162
|
+
*
|
|
163
|
+
* Prevents a failing service from consuming resources on the
|
|
164
|
+
* calling side. Without this, a slow/dead service causes thread
|
|
165
|
+
* starvation as pending requests pile up.
|
|
166
|
+
*/
|
|
167
|
+
export class CircuitBreaker {
|
|
168
|
+
/**
|
|
169
|
+
* @param {Object} options
|
|
170
|
+
* @param {number} [options.failureThreshold=5] - Failures before opening
|
|
171
|
+
* @param {number} [options.resetTimeoutMs=10000] - Time before half-open probe
|
|
172
|
+
* @param {number} [options.successThreshold=2] - Successes in half-open before closing
|
|
173
|
+
*/
|
|
174
|
+
constructor(options = {}) {
|
|
175
|
+
this.failureThreshold = options.failureThreshold ?? 5;
|
|
176
|
+
this.resetTimeoutMs = options.resetTimeoutMs ?? 10000;
|
|
177
|
+
this.successThreshold = options.successThreshold ?? 2;
|
|
178
|
+
/** H-IPC-1: Sliding window duration for failure counting (default 60s) */
|
|
179
|
+
this._windowMs = options.windowMs ?? 60_000;
|
|
180
|
+
|
|
181
|
+
/** @type {'closed'|'open'|'half-open'} */
|
|
182
|
+
this.state = "closed";
|
|
183
|
+
this.failureCount = 0;
|
|
184
|
+
this.successCount = 0;
|
|
185
|
+
this.lastFailureTime = 0;
|
|
186
|
+
this.nextAttemptTime = 0;
|
|
187
|
+
/** H-IPC-1: Sliding window of failure timestamps */
|
|
188
|
+
this._failureWindow = [];
|
|
189
|
+
this._probeInFlight = false;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
createInterceptor() {
|
|
193
|
+
const breaker = this;
|
|
194
|
+
|
|
195
|
+
return async function circuitBreaker(callCtx, next) {
|
|
196
|
+
if (breaker.state === "open") {
|
|
197
|
+
if (Date.now() < breaker.nextAttemptTime) {
|
|
198
|
+
const err = new Error(
|
|
199
|
+
`Circuit breaker OPEN for ${callCtx.target}.${callCtx.method}` +
|
|
200
|
+
` — service appears unavailable. Retry after ${new Date(breaker.nextAttemptTime).toISOString()}`,
|
|
201
|
+
);
|
|
202
|
+
err.code = 'CIRCUIT_OPEN';
|
|
203
|
+
throw err;
|
|
204
|
+
}
|
|
205
|
+
// Only allow one probe request at a time in half-open.
|
|
206
|
+
// Safe in single-threaded JS: no await between check and set,
|
|
207
|
+
// so no other microtask can interleave here.
|
|
208
|
+
if (breaker._probeInFlight) {
|
|
209
|
+
const err = new Error(
|
|
210
|
+
`Circuit breaker OPEN for ${callCtx.target}.${callCtx.method}` +
|
|
211
|
+
` — probe already in flight. Retry after ${new Date(breaker.nextAttemptTime).toISOString()}`,
|
|
212
|
+
);
|
|
213
|
+
err.code = 'CIRCUIT_OPEN';
|
|
214
|
+
throw err;
|
|
215
|
+
}
|
|
216
|
+
breaker._probeInFlight = true;
|
|
217
|
+
breaker.state = "half-open";
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
try {
|
|
221
|
+
const result = await next();
|
|
222
|
+
|
|
223
|
+
// Success
|
|
224
|
+
if (breaker.state === "half-open") {
|
|
225
|
+
breaker.successCount++;
|
|
226
|
+
// Allow next probe through while still in half-open
|
|
227
|
+
breaker._probeInFlight = false;
|
|
228
|
+
if (breaker.successCount >= breaker.successThreshold) {
|
|
229
|
+
breaker.state = "closed";
|
|
230
|
+
breaker.failureCount = 0;
|
|
231
|
+
breaker.successCount = 0;
|
|
232
|
+
breaker._failureWindow.length = 0;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
// H-IPC-1: In closed state, don't reset failureCount on success.
|
|
236
|
+
// Use sliding window — old failures expire naturally.
|
|
237
|
+
|
|
238
|
+
return result;
|
|
239
|
+
} catch (err) {
|
|
240
|
+
// Only count transient/server errors toward circuit breaker
|
|
241
|
+
const isTransient = !err.statusCode || err.statusCode >= 500 ||
|
|
242
|
+
err.code === "ECONNREFUSED" || err.code === "ECONNRESET" ||
|
|
243
|
+
err.message?.includes("timeout") || err.message?.includes("deadline");
|
|
244
|
+
|
|
245
|
+
if (isTransient) {
|
|
246
|
+
// H-IPC-1: Use sliding window for failure counting — prune expired entries
|
|
247
|
+
const now = Date.now();
|
|
248
|
+
breaker._failureWindow.push(now);
|
|
249
|
+
const cutoff = now - breaker._windowMs;
|
|
250
|
+
while (breaker._failureWindow.length > 0 && breaker._failureWindow[0] < cutoff) {
|
|
251
|
+
breaker._failureWindow.shift();
|
|
252
|
+
}
|
|
253
|
+
breaker.failureCount = breaker._failureWindow.length;
|
|
254
|
+
breaker.lastFailureTime = now;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (breaker.state === "half-open") {
|
|
258
|
+
// Reset probe flag so next probe can be attempted
|
|
259
|
+
breaker._probeInFlight = false;
|
|
260
|
+
if (isTransient) {
|
|
261
|
+
// Transient probe failure — back to open
|
|
262
|
+
breaker.state = "open";
|
|
263
|
+
breaker.nextAttemptTime = Date.now() + breaker.resetTimeoutMs * (0.75 + Math.random() * 0.5);
|
|
264
|
+
breaker.successCount = 0;
|
|
265
|
+
}
|
|
266
|
+
// Non-transient errors (4xx) in half-open: stay half-open, allow next probe
|
|
267
|
+
} else if (isTransient && breaker.failureCount >= breaker.failureThreshold) {
|
|
268
|
+
breaker.state = "open";
|
|
269
|
+
breaker.nextAttemptTime = Date.now() + breaker.resetTimeoutMs;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
throw err;
|
|
273
|
+
}
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
get stats() {
|
|
278
|
+
return {
|
|
279
|
+
state: this.state,
|
|
280
|
+
failureCount: this.failureCount,
|
|
281
|
+
successCount: this.successCount,
|
|
282
|
+
nextAttemptTime: this.state === "open" ? new Date(this.nextAttemptTime).toISOString() : null,
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// ─── Retry ──────────────────────────────────────────────────
|
|
288
|
+
|
|
289
|
+
// Errors where the request was never sent (always safe to retry)
|
|
290
|
+
const PRE_SEND_PATTERNS = ["ECONNREFUSED", "no workers"];
|
|
291
|
+
|
|
292
|
+
// Errors where the request may have been partially sent (only retry if idempotent)
|
|
293
|
+
const POST_SEND_PATTERNS = ["ECONNRESET", "EPIPE", "ETIMEDOUT", "timed out"];
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Determine if an error is retryable given an idempotency setting.
|
|
297
|
+
*
|
|
298
|
+
* @param {Error} err
|
|
299
|
+
* @param {boolean|undefined} idempotent - true: always retry transient errors,
|
|
300
|
+
* false: never retry, undefined: retry only pre-send errors
|
|
301
|
+
* @returns {boolean}
|
|
302
|
+
*/
|
|
303
|
+
export function isRetryable(err, idempotent) {
|
|
304
|
+
if (idempotent === false) return false;
|
|
305
|
+
|
|
306
|
+
const msg = err.message?.toLowerCase() ?? "";
|
|
307
|
+
|
|
308
|
+
const isPreSend = PRE_SEND_PATTERNS.some((p) => msg.includes(p.toLowerCase()));
|
|
309
|
+
if (isPreSend) return true;
|
|
310
|
+
|
|
311
|
+
const isPostSend = POST_SEND_PATTERNS.some((p) => msg.includes(p.toLowerCase()));
|
|
312
|
+
if (isPostSend && idempotent === true) return true;
|
|
313
|
+
|
|
314
|
+
// When idempotent is undefined, post-send errors are NOT retryable
|
|
315
|
+
return false;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Retry interceptor with exponential backoff and jitter.
|
|
320
|
+
*
|
|
321
|
+
* @param {object} [options]
|
|
322
|
+
* @param {number} [options.maxAttempts=3]
|
|
323
|
+
* @param {number} [options.baseDelayMs=100]
|
|
324
|
+
* @param {number} [options.maxDelayMs=2000]
|
|
325
|
+
* @param {boolean} [options.idempotent] - passed to isRetryable()
|
|
326
|
+
* @returns {function} interceptor
|
|
327
|
+
*/
|
|
328
|
+
let _retryDeprecationWarned = false;
|
|
329
|
+
export function retryInterceptor(options = {}) {
|
|
330
|
+
if (!_retryDeprecationWarned) {
|
|
331
|
+
_retryDeprecationWarned = true;
|
|
332
|
+
console.warn('[Interceptors] retryInterceptor is deprecated — retry logic is handled by ServiceProxy.createProxiedMethod(). This interceptor is now a no-op passthrough.');
|
|
333
|
+
}
|
|
334
|
+
// Return a passthrough interceptor instead of throwing, so existing
|
|
335
|
+
// chains that include retryInterceptor don't break at startup.
|
|
336
|
+
return async function retryNoop(callCtx, next) {
|
|
337
|
+
return next();
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// ─── Metrics ────────────────────────────────────────────────
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Tracks RPC metrics (call count, latency histogram, error rate).
|
|
345
|
+
*/
|
|
346
|
+
export function metricsInterceptor(metrics) {
|
|
347
|
+
return async function rpcMetrics(callCtx, next) {
|
|
348
|
+
const start = performance.now();
|
|
349
|
+
const labels = {
|
|
350
|
+
target: callCtx.target,
|
|
351
|
+
method: callCtx.method,
|
|
352
|
+
};
|
|
353
|
+
|
|
354
|
+
try {
|
|
355
|
+
const result = await next();
|
|
356
|
+
const duration = performance.now() - start;
|
|
357
|
+
|
|
358
|
+
if (metrics) {
|
|
359
|
+
metrics.counter("rpc_calls_total", 1, { ...labels, status: "success" });
|
|
360
|
+
metrics.histogram("rpc_duration_ms", duration, labels);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
return result;
|
|
364
|
+
} catch (err) {
|
|
365
|
+
const duration = performance.now() - start;
|
|
366
|
+
|
|
367
|
+
if (metrics) {
|
|
368
|
+
metrics.counter("rpc_calls_total", 1, { ...labels, status: "error" });
|
|
369
|
+
metrics.histogram("rpc_duration_ms", duration, labels);
|
|
370
|
+
metrics.counter("rpc_errors_total", 1, { ...labels, error: normalizeErrorCode(err) });
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
throw err;
|
|
374
|
+
}
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// ─── Bulkhead ────────────────────────────────────────────────
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Limits concurrent outgoing calls per service to prevent
|
|
382
|
+
* cascade failures. Uses a simple counter — no external deps.
|
|
383
|
+
*
|
|
384
|
+
* @param {Object} [options]
|
|
385
|
+
* @param {number} [options.maxConcurrent=100] - Max concurrent calls per service
|
|
386
|
+
* @returns {function} interceptor
|
|
387
|
+
*/
|
|
388
|
+
export function bulkheadInterceptor(options = {}) {
|
|
389
|
+
const maxConcurrent = options.maxConcurrent ?? 100;
|
|
390
|
+
/** @type {Map<string, number>} service name → in-flight count */
|
|
391
|
+
const inFlight = new Map();
|
|
392
|
+
|
|
393
|
+
return async function bulkhead(callCtx, next) {
|
|
394
|
+
const target = callCtx.target;
|
|
395
|
+
const current = inFlight.get(target) ?? 0;
|
|
396
|
+
|
|
397
|
+
if (current >= maxConcurrent) {
|
|
398
|
+
const err = new Error(
|
|
399
|
+
`Bulkhead limit reached for service "${target}": ${current}/${maxConcurrent} concurrent calls. ` +
|
|
400
|
+
`Rejecting ${callCtx.method}.`,
|
|
401
|
+
);
|
|
402
|
+
err.code = 'BULKHEAD_FULL';
|
|
403
|
+
err.statusCode = 503;
|
|
404
|
+
throw err;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
inFlight.set(target, current + 1);
|
|
408
|
+
try {
|
|
409
|
+
const result = await next();
|
|
410
|
+
return result;
|
|
411
|
+
} finally {
|
|
412
|
+
const count = inFlight.get(target) ?? 1;
|
|
413
|
+
if (count <= 1) {
|
|
414
|
+
inFlight.delete(target);
|
|
415
|
+
} else {
|
|
416
|
+
inFlight.set(target, count - 1);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
};
|
|
420
|
+
}
|