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,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
|
+
}
|