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.
Files changed (51) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +152 -0
  3. package/bin/forge.js +1050 -0
  4. package/bin/host-commands.js +344 -0
  5. package/bin/platform-commands.js +570 -0
  6. package/package.json +71 -0
  7. package/shared/auth.js +475 -0
  8. package/src/core/DirectMessageBus.js +364 -0
  9. package/src/core/EndpointResolver.js +247 -0
  10. package/src/core/ForgeContext.js +2227 -0
  11. package/src/core/ForgeHost.js +122 -0
  12. package/src/core/ForgePlatform.js +145 -0
  13. package/src/core/Ingress.js +768 -0
  14. package/src/core/Interceptors.js +420 -0
  15. package/src/core/MessageBus.js +310 -0
  16. package/src/core/Prometheus.js +305 -0
  17. package/src/core/RequestContext.js +413 -0
  18. package/src/core/RoutingStrategy.js +316 -0
  19. package/src/core/Supervisor.js +1306 -0
  20. package/src/core/ThreadAllocator.js +196 -0
  21. package/src/core/WorkerChannelManager.js +879 -0
  22. package/src/core/config.js +624 -0
  23. package/src/core/host-config.js +311 -0
  24. package/src/core/network-utils.js +166 -0
  25. package/src/core/platform-config.js +308 -0
  26. package/src/decorators/ServiceProxy.js +899 -0
  27. package/src/decorators/index.js +571 -0
  28. package/src/deploy/NginxGenerator.js +865 -0
  29. package/src/deploy/PlatformManifestGenerator.js +96 -0
  30. package/src/deploy/RouteManifestGenerator.js +112 -0
  31. package/src/deploy/index.js +984 -0
  32. package/src/frontend/FrontendDevLifecycle.js +65 -0
  33. package/src/frontend/FrontendPluginOrchestrator.js +187 -0
  34. package/src/frontend/SiteResolver.js +63 -0
  35. package/src/frontend/StaticMountRegistry.js +90 -0
  36. package/src/frontend/index.js +5 -0
  37. package/src/frontend/plugins/index.js +2 -0
  38. package/src/frontend/plugins/viteFrontend.js +79 -0
  39. package/src/frontend/types.js +35 -0
  40. package/src/index.js +56 -0
  41. package/src/internals.js +31 -0
  42. package/src/plugins/PluginManager.js +537 -0
  43. package/src/plugins/ScopedPostgres.js +192 -0
  44. package/src/plugins/ScopedRedis.js +142 -0
  45. package/src/plugins/index.js +1729 -0
  46. package/src/registry/ServiceRegistry.js +796 -0
  47. package/src/scaling/ScaleAdvisor.js +442 -0
  48. package/src/services/Service.js +195 -0
  49. package/src/services/worker-bootstrap.js +676 -0
  50. package/src/templates/auth-service.js +65 -0
  51. 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
+ }