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,571 @@
1
+ /**
2
+ * ThreadForge Decorators — TC39 Stage 3
3
+ *
4
+ * Real standard decorators (not legacy/experimental).
5
+ * Work with TypeScript 5+, tsx, esbuild, and native when engines ship them.
6
+ *
7
+ * Decorator signature: (target: Function, context: DecoratorContext) => Function | void
8
+ *
9
+ * ═══════════════════════════════════════════════════════════════
10
+ * USAGE (TypeScript)
11
+ * ═══════════════════════════════════════════════════════════════
12
+ *
13
+ * @Prefix('/api/users')
14
+ * @Auth('required')
15
+ * @RateLimit('1000/min')
16
+ * class UserService extends Service {
17
+ *
18
+ * @Route('GET', '/:id')
19
+ * async getUser(data, auth, params) { ... }
20
+ *
21
+ * @Route('POST', '/', { rateLimit: '10/min' })
22
+ * @Emit('user.created')
23
+ * @Validate({ name: 'string!', email: 'string!' })
24
+ * async createUser(data, auth) { ... }
25
+ *
26
+ * @Expose() // IPC only, no HTTP
27
+ * async validateCredentials(data) { ... }
28
+ *
29
+ * @Internal({ localOnly: true }) // same-process only
30
+ * async getUserFromCache(id) { ... }
31
+ *
32
+ * @On('billing', 'payment.received')
33
+ * async onPayment(event) { ... }
34
+ * }
35
+ *
36
+ * `forge generate` reads the resulting contract and produces:
37
+ * - routes/users.yaml (ForgeProxy routing)
38
+ * - routes/users.yaml (ForgeProxy routing manifest)
39
+ */
40
+
41
+ // ─── Contract Storage ──────────────────────────────────────
42
+
43
+ /**
44
+ * Global contract registry.
45
+ * Maps: ServiceClass → contract
46
+ * @type {WeakMap<Function, ServiceContract>}
47
+ */
48
+ const CONTRACT_REGISTRY = new WeakMap();
49
+
50
+ function getOrCreateContract(target) {
51
+ const cls = typeof target === "function" ? target : target.constructor;
52
+ if (!CONTRACT_REGISTRY.has(cls)) {
53
+ CONTRACT_REGISTRY.set(cls, {
54
+ methods: new Map(),
55
+ routes: [],
56
+ events: new Map(),
57
+ subscriptions: [],
58
+ prefix: null,
59
+ auth: "required",
60
+ rateLimit: null,
61
+ });
62
+ }
63
+ return CONTRACT_REGISTRY.get(cls);
64
+ }
65
+
66
+ /**
67
+ * Finalize contract from pending decorator metadata.
68
+ * Called by class decorators or lazily by getContract.
69
+ */
70
+ function finalizeContract(cls, pending) {
71
+ const contract = getOrCreateContract(cls);
72
+
73
+ for (const route of pending.routes) {
74
+ if (!contract.routes.some((r) => r.handlerName === route.handlerName && r.path === route.path)) {
75
+ contract.routes.push(route);
76
+ }
77
+ if (!contract.methods.has(route.handlerName)) {
78
+ contract.methods.set(route.handlerName, { name: route.handlerName, options: {} });
79
+ }
80
+ }
81
+
82
+ for (const [name, meta] of Object.entries(pending.methods)) {
83
+ if (!contract.methods.has(name)) {
84
+ contract.methods.set(name, meta);
85
+ }
86
+ }
87
+
88
+ for (const [method, event] of Object.entries(pending.events)) {
89
+ contract.events.set(method, event);
90
+ }
91
+
92
+ for (const sub of pending.subscriptions) {
93
+ if (!contract.subscriptions.some((s) => s.handlerName === sub.handlerName)) {
94
+ contract.subscriptions.push(sub);
95
+ }
96
+ }
97
+
98
+ // Detect @Internal + @Route conflict
99
+ const routeHandlerNames = new Set(contract.routes.map(r => r.handlerName));
100
+ for (const [name, meta] of contract.methods) {
101
+ if (meta.options?.internal && routeHandlerNames.has(name)) {
102
+ throw new Error(`Method "${name}" cannot be both @Internal and @Route`);
103
+ }
104
+ }
105
+
106
+ return contract;
107
+ }
108
+
109
+ /**
110
+ * Get the contract for a service class.
111
+ */
112
+ export function getContract(ServiceClass) {
113
+ // Already cached with content
114
+ let contract = CONTRACT_REGISTRY.get(ServiceClass);
115
+ if (contract && contract.methods.size > 0) return contract;
116
+
117
+ // Check for pending decorator metadata via Symbol.metadata
118
+ const metaKey = ServiceClass[Symbol.metadata];
119
+ if (metaKey && _classPending.has(metaKey)) {
120
+ const pending = _classPending.get(metaKey);
121
+ contract = finalizeContract(ServiceClass, pending);
122
+ if (ServiceClass.prefix) contract.prefix = ServiceClass.prefix;
123
+ if (ServiceClass.auth) contract.auth = ServiceClass.auth;
124
+ if (ServiceClass.rateLimit) contract.rateLimit = ServiceClass.rateLimit;
125
+ _classPending.delete(metaKey); // Prevent memory leak
126
+ return contract;
127
+ }
128
+
129
+ // Static contract fallback (plain JS)
130
+ if (ServiceClass.contract) {
131
+ return buildContractFromStatic(ServiceClass);
132
+ }
133
+
134
+ // Walk prototype chain to inherit parent contracts
135
+ let current = Object.getPrototypeOf(ServiceClass);
136
+ while (current && current !== Function.prototype) {
137
+ const parentContract = CONTRACT_REGISTRY.get(current);
138
+ if (parentContract && parentContract.methods.size > 0) {
139
+ // Clone parent contract for this subclass
140
+ contract = {
141
+ methods: new Map(parentContract.methods),
142
+ routes: [...parentContract.routes],
143
+ events: new Map(parentContract.events),
144
+ subscriptions: [...(parentContract.subscriptions || [])],
145
+ prefix: parentContract.prefix,
146
+ auth: parentContract.auth,
147
+ rateLimit: parentContract.rateLimit,
148
+ };
149
+ CONTRACT_REGISTRY.set(ServiceClass, contract);
150
+ return contract;
151
+ }
152
+ current = Object.getPrototypeOf(current);
153
+ }
154
+
155
+ if (contract) {
156
+ if (!(contract.methods instanceof Map)) contract.methods = new Map();
157
+ if (!Array.isArray(contract.routes)) contract.routes = [];
158
+ if (!(contract.events instanceof Map)) contract.events = new Map();
159
+ if (!Array.isArray(contract.subscriptions)) contract.subscriptions = [];
160
+ }
161
+
162
+ return contract || null;
163
+ }
164
+
165
+ // ─── Static Contract Fallback ──────────────────────────────
166
+
167
+ function buildContractFromStatic(ServiceClass) {
168
+ const def = ServiceClass.contract;
169
+ const contract = {
170
+ methods: new Map(),
171
+ routes: [],
172
+ events: new Map(),
173
+ subscriptions: [],
174
+ prefix: ServiceClass.prefix ?? null,
175
+ auth: ServiceClass.auth ?? "required",
176
+ rateLimit: ServiceClass.rateLimit ?? null,
177
+ };
178
+
179
+ if (def.expose) {
180
+ for (const name of def.expose) {
181
+ contract.methods.set(name, { name, options: {} });
182
+ }
183
+ }
184
+
185
+ if (def.routes) {
186
+ for (const r of def.routes) {
187
+ contract.routes.push({
188
+ httpMethod: r.method,
189
+ path: r.path,
190
+ handlerName: r.handler,
191
+ auth: r.auth ?? null,
192
+ rateLimit: r.rateLimit ?? null,
193
+ cacheSecs: r.cacheSecs ?? r.cache ?? null,
194
+ });
195
+ if (!contract.methods.has(r.handler)) {
196
+ contract.methods.set(r.handler, { name: r.handler, options: {} });
197
+ }
198
+
199
+ // H5: Process r.validate — wrap handler with validation like @Validate
200
+ if (r.validate) {
201
+ const schema = r.validate;
202
+ const handlerName = r.handler;
203
+ const original = ServiceClass.prototype[handlerName];
204
+ if (typeof original === 'function') {
205
+ ServiceClass.prototype[handlerName] = async function (...args) {
206
+ const input = args[0];
207
+ const serviceName = this?.constructor?.name ?? 'Unknown';
208
+ const qualifiedName = `${serviceName}.${handlerName}`;
209
+
210
+ if (typeof schema === 'function') {
211
+ const error = schema(input);
212
+ if (error) throw new Error(`Validation failed for ${qualifiedName}: ${error}`);
213
+ } else if (typeof schema === 'object') {
214
+ const BLOCKED_FIELDS = new Set(['__proto__', 'constructor', 'prototype']);
215
+ for (const [field, type] of Object.entries(schema)) {
216
+ if (BLOCKED_FIELDS.has(field)) {
217
+ throw new Error(`${qualifiedName}: "${field}" is a blocked field name`);
218
+ }
219
+ const fieldValue = Object.prototype.hasOwnProperty.call(input ?? {}, field) ? input[field] : undefined;
220
+ if (type.endsWith('!') && (fieldValue === undefined || fieldValue === null)) {
221
+ throw new Error(`${qualifiedName}: "${field}" is required`);
222
+ }
223
+ const baseType = type.replace('!', '');
224
+ if (fieldValue !== undefined && fieldValue !== null) {
225
+ if (baseType === 'array') {
226
+ if (!Array.isArray(fieldValue)) {
227
+ throw new Error(`${qualifiedName}: "${field}" must be an array`);
228
+ }
229
+ } else if (baseType === 'object') {
230
+ if (typeof fieldValue !== 'object' || Array.isArray(fieldValue)) {
231
+ throw new Error(`${qualifiedName}: "${field}" must be an object`);
232
+ }
233
+ } else if (typeof fieldValue !== baseType) {
234
+ throw new Error(`${qualifiedName}: "${field}" must be ${baseType}`);
235
+ }
236
+ if (baseType === 'number' && typeof fieldValue === 'number' && Number.isNaN(fieldValue)) {
237
+ throw new Error(`${qualifiedName}: "${field}" must be a valid number (NaN is not allowed)`);
238
+ }
239
+ }
240
+ }
241
+ }
242
+
243
+ return original.apply(this, args);
244
+ };
245
+ }
246
+ }
247
+ }
248
+ }
249
+
250
+ if (def.emits) {
251
+ for (const [method, event] of Object.entries(def.emits)) {
252
+ contract.events.set(method, event);
253
+
254
+ // Wrap the method to auto-emit, matching @Emit decorator behavior
255
+ const original = ServiceClass.prototype[method];
256
+ if (typeof original === 'function') {
257
+ ServiceClass.prototype[method] = async function (...args) {
258
+ const result = await original.apply(this, args);
259
+ const emitFn = this.ctx?._emitEvent;
260
+ if (emitFn) {
261
+ try {
262
+ await emitFn(event, result);
263
+ } catch (err) {
264
+ this.ctx?.logger?.warn?.(`Failed to emit event "${event}": ${err.message}`);
265
+ }
266
+ }
267
+ return result;
268
+ };
269
+ }
270
+ }
271
+ }
272
+
273
+ if (def.on) {
274
+ for (const sub of def.on) {
275
+ contract.subscriptions.push({
276
+ service: sub.service,
277
+ event: sub.event,
278
+ handlerName: sub.handler,
279
+ });
280
+ }
281
+ }
282
+
283
+ // Detect @Internal + @Route conflict in static contracts
284
+ const staticRouteHandlers = new Set(contract.routes.map(r => r.handlerName));
285
+ for (const [name, meta] of contract.methods) {
286
+ if (meta.options?.internal && staticRouteHandlers.has(name)) {
287
+ throw new Error(`Method "${name}" cannot be both @Internal and @Route`);
288
+ }
289
+ }
290
+
291
+ CONTRACT_REGISTRY.set(ServiceClass, contract);
292
+ return contract;
293
+ }
294
+
295
+ // ─── Class Decorators ──────────────────────────────────────
296
+
297
+ /**
298
+ * @Prefix('/api/users')
299
+ * Sets the URL prefix for all routes in this service.
300
+ */
301
+ export function Prefix(path) {
302
+ return (target, context) => {
303
+ target.prefix = path;
304
+ // Finalize any pending method decorator metadata
305
+ const pending = _classPending.get(context.metadata);
306
+ if (pending) {
307
+ const contract = finalizeContract(target, pending);
308
+ contract.prefix = path;
309
+ }
310
+ };
311
+ }
312
+
313
+ /**
314
+ * @Auth('required' | 'optional' | 'none')
315
+ * Sets the default auth requirement for all routes.
316
+ */
317
+ export function Auth(requirement) {
318
+ return (target, context) => {
319
+ target.auth = requirement;
320
+ const pending = _classPending.get(context.metadata);
321
+ if (pending) {
322
+ const contract = finalizeContract(target, pending);
323
+ contract.auth = requirement;
324
+ }
325
+ };
326
+ }
327
+
328
+ /**
329
+ * @RateLimit('1000/min')
330
+ * Sets the default rate limit for all routes.
331
+ */
332
+ export function RateLimit(limit) {
333
+ return (target, context) => {
334
+ target.rateLimit = limit;
335
+ const pending = _classPending.get(context.metadata);
336
+ if (pending) {
337
+ const contract = finalizeContract(target, pending);
338
+ contract.rateLimit = limit;
339
+ }
340
+ };
341
+ }
342
+
343
+ /**
344
+ * Per-class pending storage.
345
+ * Method decorators write here; class decorators read and finalize.
346
+ * Keyed by context.metadata which is shared across all decorators on one class.
347
+ *
348
+ * D10: Decorator ordering — class decorators (@Prefix, @Auth, @RateLimit) finalize
349
+ * pending metadata accumulated by method decorators (@Route, @Expose, @Emit, @On).
350
+ * Class decorators must come AFTER (i.e., above) method decorators in the source.
351
+ * TC39 Stage 3 decorators evaluate bottom-up for methods, then class decorators.
352
+ *
353
+ * @type {Map<object, Object>}
354
+ */
355
+ const _classPending = new WeakMap();
356
+
357
+ // ─── Method Decorators ─────────────────────────────────────
358
+
359
+ /**
360
+ * @Route('GET', '/users/:id', { auth?: 'required', rateLimit?: '100/min', cacheSecs?: 60 })
361
+ *
362
+ * Exposes a method via HTTP. ForgeProxy routes to it.
363
+ * Implies @Expose — the method is also callable via IPC.
364
+ *
365
+ * D6/D9: Route handler signature:
366
+ * The decorated method receives `(body, params, query)` where:
367
+ * - body: parsed JSON request body (or {} for GET)
368
+ * - params: URL path parameters (e.g., { id: '123' } for /users/:id)
369
+ * - query: parsed query string parameters
370
+ * Return value is auto-serialized as JSON (201 for POST, 200 otherwise).
371
+ */
372
+ const VALID_HTTP_METHODS = new Set(['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS']);
373
+
374
+ export function Route(httpMethod, path, options = {}) {
375
+ if (!VALID_HTTP_METHODS.has(httpMethod.toUpperCase())) {
376
+ throw new Error(`@Route: invalid HTTP method "${httpMethod}". Valid: ${[...VALID_HTTP_METHODS].join(', ')}`);
377
+ }
378
+ return (target, context) => {
379
+ const methodName = String(context.name);
380
+ const pending = _getClassPending(context);
381
+
382
+ pending.routes.push({
383
+ httpMethod: httpMethod.toUpperCase(),
384
+ path,
385
+ handlerName: methodName,
386
+ auth: options.auth ?? null,
387
+ rateLimit: options.rateLimit ?? null,
388
+ cacheSecs: options.cacheSecs ?? null,
389
+ });
390
+
391
+ pending.methods[methodName] = { name: methodName, options: {} };
392
+
393
+ return target;
394
+ };
395
+ }
396
+
397
+ /**
398
+ * @Expose(options?)
399
+ *
400
+ * Marks a method as callable via IPC by other services.
401
+ * If no @Route is applied, this method is backend-only.
402
+ */
403
+ export function Expose(options = {}) {
404
+ return (target, context) => {
405
+ const methodName = String(context.name);
406
+ const pending = _getClassPending(context);
407
+ pending.methods[methodName] = { name: methodName, options };
408
+ return target;
409
+ };
410
+ }
411
+
412
+ /**
413
+ * @Internal(options?)
414
+ *
415
+ * Backend-only method. Never exposed via HTTP.
416
+ * Same as @Expose but makes intent explicit.
417
+ *
418
+ * @param {Object} [options]
419
+ * @param {boolean} [options.localOnly] - Only callable within same process (zero-copy)
420
+ */
421
+ export function Internal(options = {}) {
422
+ return (target, context) => {
423
+ const methodName = String(context.name);
424
+ const pending = _getClassPending(context);
425
+ pending.methods[methodName] = {
426
+ name: methodName,
427
+ options: { ...options, internal: true },
428
+ };
429
+ return target;
430
+ };
431
+ }
432
+
433
+ /**
434
+ * @Emit('user.created')
435
+ */
436
+ export function Emit(eventName) {
437
+ return (target, context) => {
438
+ const methodName = String(context.name);
439
+ const pending = _getClassPending(context);
440
+ pending.events[methodName] = eventName;
441
+
442
+ // M-PLUGIN-7: Preserve method name for stack traces and debugging
443
+ const wrapper = async function (...args) {
444
+ const result = await target.apply(this, args);
445
+ // Capture emit function AFTER await to get current context
446
+ const emitFn = this.ctx?._emitEvent;
447
+ if (emitFn) {
448
+ try {
449
+ await emitFn(eventName, result);
450
+ } catch (err) {
451
+ this.ctx?.logger?.warn?.(`Failed to emit event "${eventName}": ${err.message}`);
452
+ }
453
+ }
454
+ return result;
455
+ };
456
+ Object.defineProperty(wrapper, 'name', { value: methodName, configurable: true });
457
+ return wrapper;
458
+ };
459
+ }
460
+
461
+ /**
462
+ * @On('billing', 'payment.received')
463
+ */
464
+ export function On(serviceName, eventName) {
465
+ return (target, context) => {
466
+ const methodName = String(context.name);
467
+ const pending = _getClassPending(context);
468
+ pending.subscriptions.push({
469
+ service: serviceName,
470
+ event: eventName,
471
+ handlerName: methodName,
472
+ });
473
+ return target;
474
+ };
475
+ }
476
+
477
+ function _getClassPending(context) {
478
+ const key = context.metadata;
479
+ if (typeof key !== "object" || key === null) {
480
+ throw new Error(`Decorator context.metadata must be an object, got ${typeof key}`);
481
+ }
482
+ if (!_classPending.has(key)) {
483
+ _classPending.set(key, { routes: [], methods: {}, events: {}, subscriptions: [] });
484
+ }
485
+ return _classPending.get(key);
486
+ }
487
+
488
+ /**
489
+ * @Validate({ name: 'string!', email: 'string!' })
490
+ *
491
+ * Validates the first argument before method execution.
492
+ * '!' suffix means required.
493
+ */
494
+ export function Validate(schema) {
495
+ return (target, context) => {
496
+ const methodName = String(context.name);
497
+
498
+ return async function (...args) {
499
+ const input = args[0];
500
+ const serviceName = this?.constructor?.name ?? 'Unknown';
501
+ const qualifiedName = `${serviceName}.${methodName}`;
502
+
503
+ if (typeof schema === "function") {
504
+ const error = schema(input);
505
+ if (error) throw new Error(`Validation failed for ${qualifiedName}: ${error}`);
506
+ } else if (typeof schema === "object") {
507
+ const BLOCKED_FIELDS = new Set(['__proto__', 'constructor', 'prototype']);
508
+ for (const [field, type] of Object.entries(schema)) {
509
+ if (BLOCKED_FIELDS.has(field)) {
510
+ throw new Error(`${qualifiedName}: "${field}" is a blocked field name`);
511
+ }
512
+ const fieldValue = Object.prototype.hasOwnProperty.call(input ?? {}, field) ? input[field] : undefined;
513
+ if (type.endsWith("!") && (fieldValue === undefined || fieldValue === null)) {
514
+ throw new Error(`${qualifiedName}: "${field}" is required`);
515
+ }
516
+ const baseType = type.replace("!", "");
517
+ if (fieldValue !== undefined && fieldValue !== null) {
518
+ if (baseType === "array") {
519
+ if (!Array.isArray(fieldValue)) {
520
+ throw new Error(`${qualifiedName}: "${field}" must be an array`);
521
+ }
522
+ } else if (baseType === "object") {
523
+ if (typeof fieldValue !== "object" || Array.isArray(fieldValue)) {
524
+ throw new Error(`${qualifiedName}: "${field}" must be an object`);
525
+ }
526
+ } else if (typeof fieldValue !== baseType) {
527
+ throw new Error(`${qualifiedName}: "${field}" must be ${baseType}`);
528
+ }
529
+ if (baseType === 'number' && typeof fieldValue === 'number' && Number.isNaN(fieldValue)) {
530
+ throw new Error(`${qualifiedName}: "${field}" must be a valid number (NaN is not allowed)`);
531
+ }
532
+ }
533
+ }
534
+ }
535
+
536
+ return target.apply(this, args);
537
+ };
538
+ };
539
+ }
540
+
541
+ // ─── Query Helpers ─────────────────────────────────────────
542
+
543
+ /**
544
+ * Check if a method is internal-only (no HTTP route).
545
+ */
546
+ export function isInternalMethod(ServiceClass, methodName) {
547
+ const contract = getContract(ServiceClass);
548
+ if (!contract) return false;
549
+ if (!contract.methods.has(methodName)) return false;
550
+ return !contract.routes.some((r) => r.handlerName === methodName);
551
+ }
552
+
553
+ /**
554
+ * Check if a method is local-only (direct call, no IPC).
555
+ */
556
+ export function isLocalOnlyMethod(ServiceClass, methodName) {
557
+ const contract = getContract(ServiceClass);
558
+ if (!contract) return false;
559
+ const meta = contract.methods.get(methodName);
560
+ return meta?.options?.localOnly === true;
561
+ }
562
+
563
+ /**
564
+ * Get all internal (non-HTTP) methods for a service.
565
+ */
566
+ export function getInternalMethods(ServiceClass) {
567
+ const contract = getContract(ServiceClass);
568
+ if (!contract) return [];
569
+ const routeHandlers = new Set(contract.routes.map((r) => r.handlerName));
570
+ return [...contract.methods.keys()].filter((m) => !routeHandlers.has(m));
571
+ }