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,316 @@
1
+ /**
2
+ * Routing Strategies
3
+ *
4
+ * When a service has multiple workers, the proxy needs to decide
5
+ * which worker handles each request. Different strategies suit
6
+ * different service patterns.
7
+ *
8
+ * Strategies:
9
+ *
10
+ * round-robin Default. Cycles through workers sequentially.
11
+ * Best for: stateless services, uniform workloads.
12
+ *
13
+ * hash Routes by a key (e.g., userId). Same key always
14
+ * goes to the same worker. Uses consistent hashing
15
+ * so adding/removing workers only remaps ~1/N keys.
16
+ * Best for: stateful services, in-memory caches,
17
+ * services that benefit from data locality.
18
+ *
19
+ * least-pending Routes to the worker with fewest in-flight requests.
20
+ * Best for: variable-latency services (DB queries,
21
+ * external API calls).
22
+ *
23
+ * broadcast Sends to ALL workers. Returns the first response.
24
+ * Best for: search/aggregation across sharded data.
25
+ *
26
+ * primary Always routes to worker 0. Others are standbys.
27
+ * Best for: singleton services (cron scheduler,
28
+ * leader election).
29
+ */
30
+
31
+ import { createHmac, randomBytes } from "node:crypto";
32
+
33
+ const HASH_SALT = randomBytes(16).toString("hex");
34
+
35
+ /**
36
+ * @typedef {Object} WorkerEntry
37
+ * @property {string} key - "serviceName:workerId"
38
+ * @property {Object} socket - UDS socket or local reference
39
+ * @property {number} pending - Number of in-flight requests
40
+ */
41
+
42
+ // ─── Round-Robin ────────────────────────────────────────────
43
+
44
+ export class RoundRobinStrategy {
45
+ constructor() {
46
+ this.index = 0;
47
+ }
48
+
49
+ /**
50
+ * @param {WorkerEntry[]} workers
51
+ * @param {Object} _callContext - Ignored by round-robin
52
+ * @returns {WorkerEntry}
53
+ */
54
+ pick(workers, _callContext) {
55
+ if (workers.length === 0) return null;
56
+ const worker = workers[this.index % workers.length];
57
+ this.index = (this.index + 1) % 1_000_000_000;
58
+ return worker;
59
+ }
60
+
61
+ get name() {
62
+ return "round-robin";
63
+ }
64
+ }
65
+
66
+ // ─── Hash-Based (Consistent Hashing) ────────────────────────
67
+
68
+ export class HashStrategy {
69
+ /**
70
+ * @param {Object} options
71
+ * @param {string} options.key - The field name to hash on (e.g., 'userId')
72
+ * If the call payload has this field, it's used.
73
+ * Otherwise falls back to round-robin.
74
+ * @param {number} [options.vnodes=150] - Virtual nodes per worker for
75
+ * consistent hash ring.
76
+ */
77
+ constructor(options = {}) {
78
+ this.keyField = options.key ?? null;
79
+ this.vnodes = options.vnodes ?? 150;
80
+ this._ring = null;
81
+ this._workerCacheKey = '';
82
+ }
83
+
84
+ pick(workers, callContext) {
85
+ if (workers.length === 0) return null;
86
+ if (workers.length === 1) return workers[0];
87
+
88
+ // Extract the hash key from the call context
89
+ const hashKey = this._extractKey(callContext);
90
+ if (!hashKey) {
91
+ // No key available — fall back to round-robin-ish
92
+ return workers[Math.abs(this._simpleHash(Date.now().toString())) % workers.length];
93
+ }
94
+
95
+ // Rebuild ring when worker identities change (not just count).
96
+ // This ensures the ring is correct when workers restart with new keys.
97
+ const currentCacheKey = workers.map(w => w.key).sort().join(',');
98
+ if (this._workerCacheKey !== currentCacheKey) {
99
+ this._buildRing(workers);
100
+ }
101
+
102
+ // Find the worker on the ring
103
+ const hash = this._simpleHash(hashKey);
104
+ return this._findOnRing(hash, workers);
105
+ }
106
+
107
+ _extractKey(callContext) {
108
+ if (!callContext) return null;
109
+
110
+ // If a key field is specified, look for it in the call args
111
+ if (this.keyField) {
112
+ const args = callContext.__forge_args ?? [];
113
+ // Check first arg (usually the main parameter)
114
+ if (args.length > 0) {
115
+ if (typeof args[0] === "string" || typeof args[0] === "number") {
116
+ return String(args[0]);
117
+ }
118
+ if (typeof args[0] === "object" && args[0]?.[this.keyField]) {
119
+ return String(args[0][this.keyField]);
120
+ }
121
+ }
122
+ }
123
+
124
+ // Try to use __forge_hash if explicitly set by the caller
125
+ if (callContext.__forge_hash) {
126
+ return String(callContext.__forge_hash);
127
+ }
128
+
129
+ return null;
130
+ }
131
+
132
+ _buildRing(workers) {
133
+ // P5: Pre-compute all virtual node hashes and store sorted for binary search
134
+ const totalNodes = workers.length * this.vnodes;
135
+ const hashes = new Uint32Array(totalNodes);
136
+ const indices = new Uint16Array(totalNodes);
137
+ let pos = 0;
138
+ for (let i = 0; i < workers.length; i++) {
139
+ for (let v = 0; v < this.vnodes; v++) {
140
+ hashes[pos] = this._computeHash(`${workers[i].key}:${v}`);
141
+ indices[pos] = i;
142
+ pos++;
143
+ }
144
+ }
145
+ // Sort by hash value, keeping indices aligned
146
+ const sortOrder = Array.from({ length: totalNodes }, (_, i) => i);
147
+ sortOrder.sort((a, b) => hashes[a] - hashes[b]);
148
+ this._ringHashes = new Uint32Array(totalNodes);
149
+ this._ringIndices = new Uint16Array(totalNodes);
150
+ for (let i = 0; i < totalNodes; i++) {
151
+ this._ringHashes[i] = hashes[sortOrder[i]];
152
+ this._ringIndices[i] = indices[sortOrder[i]];
153
+ }
154
+ this._ring = true; // sentinel
155
+ this._workerCacheKey = workers.map(w => w.key).sort().join(',');
156
+ }
157
+
158
+ _findOnRing(hash, workers) {
159
+ const hashes = this._ringHashes;
160
+ // Binary search for the first ring entry >= hash
161
+ let lo = 0,
162
+ hi = hashes.length;
163
+ while (lo < hi) {
164
+ const mid = (lo + hi) >>> 1;
165
+ if (hashes[mid] < hash) lo = mid + 1;
166
+ else hi = mid;
167
+ }
168
+ // Wrap around
169
+ const idx = lo < hashes.length ? lo : 0;
170
+ return workers[this._ringIndices[idx]];
171
+ }
172
+
173
+ /**
174
+ * Pre-compute a keyed SHA-256 hash for ring building (called at build time).
175
+ */
176
+ _computeHash(str) {
177
+ return createHmac("sha256", HASH_SALT).update(str).digest().readUInt32BE(0);
178
+ }
179
+
180
+ /**
181
+ * Keyed SHA-256 hash — collision-resistant, prevents hash flooding attacks.
182
+ */
183
+ _simpleHash(str) {
184
+ return createHmac("sha256", HASH_SALT).update(str).digest().readUInt32BE(0);
185
+ }
186
+
187
+ get name() {
188
+ return `hash(${this.keyField ?? "auto"})`;
189
+ }
190
+ }
191
+
192
+ // ─── Least-Pending ──────────────────────────────────────────
193
+
194
+ export class LeastPendingStrategy {
195
+ constructor() {
196
+ /** @type {Map<string, number>} key → pending count */
197
+ this._pending = new Map();
198
+ }
199
+
200
+ /**
201
+ * Pick the worker with the fewest in-flight requests.
202
+ */
203
+ pick(workers, _callContext) {
204
+ if (workers.length === 0) return null;
205
+ if (workers.length === 1) return workers[0];
206
+
207
+ // Prune stale entries for workers no longer in the current set
208
+ if (this._pending.size > 0) {
209
+ const activeKeys = new Set(workers.map(w => w.key));
210
+ for (const key of this._pending.keys()) {
211
+ if (!activeKeys.has(key)) {
212
+ this._pending.delete(key);
213
+ }
214
+ }
215
+ }
216
+
217
+ let best = workers[0];
218
+ let bestPending = this._pending.get(best.key) ?? 0;
219
+ for (let i = 1; i < workers.length; i++) {
220
+ const p = this._pending.get(workers[i].key) ?? 0;
221
+ if (p < bestPending) {
222
+ best = workers[i];
223
+ bestPending = p;
224
+ }
225
+ }
226
+ return best;
227
+ }
228
+
229
+ /** Call when dispatching a request to a worker */
230
+ acquire(workerKey) {
231
+ this._pending.set(workerKey, (this._pending.get(workerKey) ?? 0) + 1);
232
+ }
233
+
234
+ /** Call when a request to a worker completes */
235
+ release(workerKey) {
236
+ const count = this._pending.get(workerKey) ?? 0;
237
+ if (count <= 0) {
238
+ if (process.env.NODE_ENV !== 'production') {
239
+ console.warn('[LeastPendingStrategy] Spurious release()');
240
+ }
241
+ this._pending.delete(workerKey);
242
+ return;
243
+ }
244
+ if (count <= 1) {
245
+ this._pending.delete(workerKey);
246
+ } else {
247
+ this._pending.set(workerKey, count - 1);
248
+ }
249
+ }
250
+
251
+ get name() {
252
+ return "least-pending";
253
+ }
254
+ }
255
+
256
+ // ─── Broadcast ──────────────────────────────────────────────
257
+
258
+ export class BroadcastStrategy {
259
+ /**
260
+ * Returns ALL workers. The caller is responsible for sending
261
+ * to all of them and handling multiple responses.
262
+ */
263
+ pick(workers, _callContext) {
264
+ return workers; // Return the full array — caller detects this
265
+ }
266
+
267
+ get name() {
268
+ return "broadcast";
269
+ }
270
+
271
+ get isBroadcast() {
272
+ return true;
273
+ }
274
+ }
275
+
276
+ // ─── Primary ────────────────────────────────────────────────
277
+
278
+ export class PrimaryStrategy {
279
+ /**
280
+ * Always routes to worker index 0. If worker 0 is down,
281
+ * routes to the next available.
282
+ */
283
+ pick(workers, _callContext) {
284
+ if (workers.length === 0) return null;
285
+ return workers[0]; // Primary is always first
286
+ }
287
+
288
+ get name() {
289
+ return "primary";
290
+ }
291
+ }
292
+
293
+ // ─── Strategy Factory ───────────────────────────────────────
294
+
295
+ const STRATEGIES = {
296
+ "round-robin": (_opts) => new RoundRobinStrategy(),
297
+ hash: (opts) => new HashStrategy(opts),
298
+ "least-pending": (_opts) => new LeastPendingStrategy(),
299
+ broadcast: (_opts) => new BroadcastStrategy(),
300
+ primary: (_opts) => new PrimaryStrategy(),
301
+ };
302
+
303
+ /**
304
+ * Create a routing strategy by name.
305
+ *
306
+ * @param {string} name - Strategy name
307
+ * @param {Object} [options] - Strategy-specific options
308
+ * @returns {Object} Strategy instance with a pick() method
309
+ */
310
+ export function createStrategy(name, options = {}) {
311
+ const factory = STRATEGIES[name];
312
+ if (!factory) {
313
+ throw new Error(`Unknown routing strategy: "${name}". Available: ${Object.keys(STRATEGIES).join(", ")}`);
314
+ }
315
+ return factory(options);
316
+ }