@omen.foundation/node-microservice-runtime 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 (145) hide show
  1. package/.env +13 -0
  2. package/dist/auth.cjs +97 -0
  3. package/dist/auth.d.ts +14 -0
  4. package/dist/auth.d.ts.map +1 -0
  5. package/dist/auth.js +93 -0
  6. package/dist/auth.js.map +1 -0
  7. package/dist/cli/index.d.ts +3 -0
  8. package/dist/cli/index.d.ts.map +1 -0
  9. package/dist/cli/index.js +588 -0
  10. package/dist/cli/index.js.map +1 -0
  11. package/dist/decorators.cjs +181 -0
  12. package/dist/decorators.d.ts +23 -0
  13. package/dist/decorators.d.ts.map +1 -0
  14. package/dist/decorators.js +155 -0
  15. package/dist/decorators.js.map +1 -0
  16. package/dist/dependency.cjs +165 -0
  17. package/dist/dependency.d.ts +56 -0
  18. package/dist/dependency.d.ts.map +1 -0
  19. package/dist/dependency.js +162 -0
  20. package/dist/dependency.js.map +1 -0
  21. package/dist/dev.cjs +34 -0
  22. package/dist/dev.d.ts +9 -0
  23. package/dist/dev.d.ts.map +1 -0
  24. package/dist/dev.js +32 -0
  25. package/dist/dev.js.map +1 -0
  26. package/dist/discovery.cjs +79 -0
  27. package/dist/discovery.d.ts +20 -0
  28. package/dist/discovery.d.ts.map +1 -0
  29. package/dist/discovery.js +75 -0
  30. package/dist/discovery.js.map +1 -0
  31. package/dist/docs.cjs +206 -0
  32. package/dist/docs.d.ts +30 -0
  33. package/dist/docs.d.ts.map +1 -0
  34. package/dist/docs.js +209 -0
  35. package/dist/docs.js.map +1 -0
  36. package/dist/env.cjs +106 -0
  37. package/dist/env.d.ts +4 -0
  38. package/dist/env.d.ts.map +1 -0
  39. package/dist/env.js +108 -0
  40. package/dist/env.js.map +1 -0
  41. package/dist/errors.cjs +58 -0
  42. package/dist/errors.d.ts +26 -0
  43. package/dist/errors.d.ts.map +1 -0
  44. package/dist/errors.js +48 -0
  45. package/dist/errors.js.map +1 -0
  46. package/dist/federation.cjs +356 -0
  47. package/dist/federation.d.ts +108 -0
  48. package/dist/federation.d.ts.map +1 -0
  49. package/dist/federation.js +341 -0
  50. package/dist/federation.js.map +1 -0
  51. package/dist/index.cjs +42 -0
  52. package/dist/index.d.ts +13 -0
  53. package/dist/index.d.ts.map +1 -0
  54. package/dist/index.js +10 -0
  55. package/dist/index.js.map +1 -0
  56. package/dist/inventory.cjs +361 -0
  57. package/dist/inventory.d.ts +116 -0
  58. package/dist/inventory.d.ts.map +1 -0
  59. package/dist/inventory.js +351 -0
  60. package/dist/inventory.js.map +1 -0
  61. package/dist/logger.cjs +62 -0
  62. package/dist/logger.d.ts +9 -0
  63. package/dist/logger.d.ts.map +1 -0
  64. package/dist/logger.js +29 -0
  65. package/dist/logger.js.map +1 -0
  66. package/dist/message.cjs +19 -0
  67. package/dist/message.d.ts +5 -0
  68. package/dist/message.d.ts.map +1 -0
  69. package/dist/message.js +15 -0
  70. package/dist/message.js.map +1 -0
  71. package/dist/requester.cjs +100 -0
  72. package/dist/requester.d.ts +20 -0
  73. package/dist/requester.d.ts.map +1 -0
  74. package/dist/requester.js +99 -0
  75. package/dist/requester.js.map +1 -0
  76. package/dist/routing.cjs +39 -0
  77. package/dist/routing.d.ts +2 -0
  78. package/dist/routing.d.ts.map +1 -0
  79. package/dist/routing.js +36 -0
  80. package/dist/routing.js.map +1 -0
  81. package/dist/runtime.cjs +735 -0
  82. package/dist/runtime.d.ts +40 -0
  83. package/dist/runtime.d.ts.map +1 -0
  84. package/dist/runtime.js +825 -0
  85. package/dist/runtime.js.map +1 -0
  86. package/dist/services.cjs +346 -0
  87. package/dist/services.d.ts +46 -0
  88. package/dist/services.d.ts.map +1 -0
  89. package/dist/services.js +343 -0
  90. package/dist/services.js.map +1 -0
  91. package/dist/storage.cjs +147 -0
  92. package/dist/storage.d.ts +46 -0
  93. package/dist/storage.d.ts.map +1 -0
  94. package/dist/storage.js +144 -0
  95. package/dist/storage.js.map +1 -0
  96. package/dist/types.cjs +2 -0
  97. package/dist/types.d.ts +108 -0
  98. package/dist/types.d.ts.map +1 -0
  99. package/dist/types.js +2 -0
  100. package/dist/types.js.map +1 -0
  101. package/dist/utils/urls.cjs +55 -0
  102. package/dist/utils/urls.d.ts +5 -0
  103. package/dist/utils/urls.d.ts.map +1 -0
  104. package/dist/utils/urls.js +50 -0
  105. package/dist/utils/urls.js.map +1 -0
  106. package/dist/websocket.cjs +142 -0
  107. package/dist/websocket.d.ts +33 -0
  108. package/dist/websocket.d.ts.map +1 -0
  109. package/dist/websocket.js +139 -0
  110. package/dist/websocket.js.map +1 -0
  111. package/env.sample +13 -0
  112. package/package.json +49 -0
  113. package/scripts/generate-openapi.mjs +114 -0
  114. package/scripts/lib/cli-utils.mjs +58 -0
  115. package/scripts/prepare-cjs.mjs +44 -0
  116. package/scripts/publish-service.mjs +1126 -0
  117. package/scripts/validate-service.mjs +103 -0
  118. package/scripts/ws-test.mjs +25 -0
  119. package/src/auth.ts +117 -0
  120. package/src/cli/index.ts +699 -0
  121. package/src/decorators.ts +207 -0
  122. package/src/dependency.ts +211 -0
  123. package/src/dev.ts +17 -0
  124. package/src/discovery.ts +88 -0
  125. package/src/docs.ts +262 -0
  126. package/src/env.ts +125 -0
  127. package/src/errors.ts +55 -0
  128. package/src/federation.ts +559 -0
  129. package/src/index.ts +51 -0
  130. package/src/inventory.ts +491 -0
  131. package/src/logger.ts +38 -0
  132. package/src/message.ts +19 -0
  133. package/src/requester.ts +126 -0
  134. package/src/routing.ts +42 -0
  135. package/src/runtime.ts +967 -0
  136. package/src/services.ts +459 -0
  137. package/src/storage.ts +206 -0
  138. package/src/types/beamable-sdk-api.d.ts +5 -0
  139. package/src/types.ts +117 -0
  140. package/src/utils/urls.ts +53 -0
  141. package/src/websocket.ts +170 -0
  142. package/tsconfig.base.json +31 -0
  143. package/tsconfig.build.json +10 -0
  144. package/tsconfig.cjs.json +16 -0
  145. package/tsconfig.dev.json +14 -0
package/src/runtime.ts ADDED
@@ -0,0 +1,967 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { BeamableWebSocket } from './websocket.js';
3
+ import { GatewayRequester } from './requester.js';
4
+ import { AuthManager } from './auth.js';
5
+ import { createLogger } from './logger.js';
6
+ import { loadEnvironmentConfig } from './env.js';
7
+ import { listRegisteredServices, getServiceOptions, getConfigureServicesHandlers, getInitializeServicesHandlers } from './decorators.js';
8
+ import { generateOpenApiDocument } from './docs.js';
9
+ import { DiscoveryBroadcaster } from './discovery.js';
10
+ import { BeamableRuntimeError, MissingScopesError, UnauthorizedUserError, UnknownRouteError } from './errors.js';
11
+ import type {
12
+ EnvironmentConfig,
13
+ RequestContext,
14
+ ServiceDefinition,
15
+ ServiceCallableMetadata,
16
+ GatewayResponse,
17
+ WebsocketEventEnvelope,
18
+ ServiceAccess,
19
+ } from './types.js';
20
+ import type { Logger } from 'pino';
21
+ import { BeamableServiceManager } from './services.js';
22
+ import {
23
+ DependencyBuilder,
24
+ LOGGER_TOKEN,
25
+ ENVIRONMENT_CONFIG_TOKEN,
26
+ REQUEST_CONTEXT_TOKEN,
27
+ BEAMABLE_SERVICES_TOKEN,
28
+ ServiceProvider,
29
+ MutableDependencyScope,
30
+ } from './dependency.js';
31
+ import { hostToHttpUrl, hostToPortalUrl } from './utils/urls.js';
32
+ import { FederationRegistry, getFederationComponents, getFederatedInventoryMetadata } from './federation.js';
33
+ import type { FederatedRequestContext } from './federation.js';
34
+ import { createServer, type Server } from 'node:http';
35
+
36
+ interface ServiceInstance {
37
+ definition: ServiceDefinition;
38
+ instance: Record<string, unknown>;
39
+ configureHandlers: ReturnType<typeof getConfigureServicesHandlers>;
40
+ initializeHandlers: ReturnType<typeof getInitializeServicesHandlers>;
41
+ provider?: ServiceProvider;
42
+ logger: Logger;
43
+ federationRegistry: FederationRegistry;
44
+ }
45
+
46
+ export class MicroserviceRuntime {
47
+ private readonly env: EnvironmentConfig;
48
+ private readonly logger: Logger;
49
+ private readonly services: ServiceInstance[];
50
+ private readonly webSocket: BeamableWebSocket;
51
+ private readonly requester: GatewayRequester;
52
+ private readonly authManager: AuthManager;
53
+ private readonly discovery?: DiscoveryBroadcaster;
54
+ private readonly microServiceId = randomUUID();
55
+ private readonly serviceManager: BeamableServiceManager;
56
+ private healthCheckServer?: Server;
57
+ private isReady: boolean = false;
58
+
59
+ constructor(env?: EnvironmentConfig) {
60
+ this.env = env ?? loadEnvironmentConfig();
61
+ this.logger = createLogger(this.env, { name: 'beamable-node-microservice' });
62
+ this.serviceManager = new BeamableServiceManager(this.env, this.logger);
63
+
64
+ const registered = listRegisteredServices();
65
+ if (registered.length === 0) {
66
+ throw new Error('No microservices registered. Use the @Microservice decorator to register at least one class.');
67
+ }
68
+ this.services = registered.map((definition) => {
69
+ const instance = new definition.ctor() as Record<string, unknown>;
70
+ const configureHandlers = getConfigureServicesHandlers(definition.ctor);
71
+ const initializeHandlers = getInitializeServicesHandlers(definition.ctor);
72
+ const logger = this.logger.child({ service: definition.name });
73
+ const federationRegistry = new FederationRegistry(logger);
74
+ const decoratedFederations = getFederationComponents(definition.ctor);
75
+ for (const component of decoratedFederations) {
76
+ federationRegistry.register(component);
77
+ }
78
+ const inventoryMetadata = getFederatedInventoryMetadata(definition.ctor);
79
+ if (inventoryMetadata.length > 0) {
80
+ federationRegistry.registerInventoryHandlers(instance, inventoryMetadata);
81
+ }
82
+ this.serviceManager.registerFederationRegistry(definition.name, federationRegistry);
83
+ return { definition, instance, configureHandlers, initializeHandlers, logger, federationRegistry };
84
+ });
85
+
86
+ const socketUrl = this.env.host.endsWith('/socket') ? this.env.host : `${this.env.host}/socket`;
87
+ this.webSocket = new BeamableWebSocket({ url: socketUrl, logger: this.logger });
88
+ this.webSocket.on('message', (payload) => {
89
+ this.logger.debug({ payload }, 'Runtime observed websocket frame.');
90
+ });
91
+ this.requester = new GatewayRequester(this.webSocket, this.logger);
92
+ this.authManager = new AuthManager(this.env, this.requester);
93
+ this.requester.on('event', (envelope) => this.handleEvent(envelope));
94
+
95
+ if (!isRunningInContainer() && this.services.length > 0) {
96
+ this.discovery = new DiscoveryBroadcaster({
97
+ env: this.env,
98
+ serviceName: this.services[0].definition.name,
99
+ routingKey: this.env.routingKey,
100
+ logger: this.logger.child({ component: 'DiscoveryBroadcaster' }),
101
+ });
102
+ }
103
+ }
104
+
105
+ async start(): Promise<void> {
106
+ // Immediate console output for container logs (before logger is ready)
107
+ console.error('[BEAMABLE-NODE] MicroserviceRuntime.start() called');
108
+ console.error(`[BEAMABLE-NODE] Service count: ${this.services.length}`);
109
+
110
+ this.printHelpfulUrls(this.services[0]);
111
+ this.logger.info('Starting Beamable Node microservice runtime.');
112
+
113
+ // Start health check server FIRST - this is critical for container health checks
114
+ // Even if startup fails, the health check server must be running so we can debug
115
+ console.error('[BEAMABLE-NODE] Starting health check server...');
116
+ await this.startHealthCheckServer();
117
+ console.error('[BEAMABLE-NODE] Health check server started');
118
+
119
+ try {
120
+ this.logger.info('Connecting to Beamable gateway...');
121
+ await this.webSocket.connect();
122
+ await new Promise((resolve) => setTimeout(resolve, 250));
123
+
124
+ this.logger.info('Authenticating with Beamable...');
125
+ await this.authManager.authenticate();
126
+
127
+ this.logger.info('Initializing Beamable SDK services...');
128
+ await this.serviceManager.initialize();
129
+
130
+ this.logger.info('Initializing dependency providers...');
131
+ await this.initializeDependencyProviders();
132
+
133
+ this.logger.info('Registering service with gateway...');
134
+ await this.registerService();
135
+
136
+ this.logger.info('Starting discovery broadcaster...');
137
+ await this.discovery?.start();
138
+
139
+ // Mark as ready only after service is fully registered and discovery is started
140
+ this.isReady = true;
141
+ this.logger.info('Beamable microservice runtime is ready to accept traffic.');
142
+ } catch (error) {
143
+ // Log the error with full context but don't crash - health check server is running
144
+ // This allows the container to stay alive so we can debug the issue
145
+ // Immediate console output for container logs (logger might not be working)
146
+ console.error('[BEAMABLE-NODE] FATAL ERROR during startup:');
147
+ console.error(`[BEAMABLE-NODE] Error message: ${error instanceof Error ? error.message : String(error)}`);
148
+ console.error(`[BEAMABLE-NODE] Error stack: ${error instanceof Error ? error.stack : 'No stack trace'}`);
149
+ console.error(`[BEAMABLE-NODE] isReady: ${this.isReady}`);
150
+
151
+ this.logger.error(
152
+ {
153
+ err: error,
154
+ errorMessage: error instanceof Error ? error.message : String(error),
155
+ errorStack: error instanceof Error ? error.stack : undefined,
156
+ isReady: this.isReady,
157
+ },
158
+ 'Failed to fully initialize microservice runtime. Health check will continue to return 503 until initialization completes.'
159
+ );
160
+ // DON'T re-throw - keep process alive so health check can show 503
161
+ // This allows us to see the error in logs
162
+ this.isReady = false;
163
+ }
164
+ }
165
+
166
+ async shutdown(): Promise<void> {
167
+ this.logger.info('Shutting down microservice runtime.');
168
+ this.isReady = false; // Mark as not ready during shutdown
169
+ this.discovery?.stop();
170
+ await this.stopHealthCheckServer();
171
+ this.requester.dispose();
172
+ await this.webSocket.close();
173
+ }
174
+
175
+ private async startHealthCheckServer(): Promise<void> {
176
+ // For deployed services, always start health check server (required for container health checks)
177
+ // For local development, only start if healthPort is explicitly set
178
+ // IMPORTANT: Always default to 6565 if HEALTH_PORT env var is not set, as this is the standard port
179
+ const healthPort = this.env.healthPort || 6565;
180
+
181
+ // Always start health check server if we have a valid port
182
+ // The container orchestrator expects this endpoint to be available
183
+ if (!healthPort || healthPort === 0) {
184
+ // Health check server not needed (local development without explicit port)
185
+ this.logger.debug('Health check server skipped (no healthPort set)');
186
+ return;
187
+ }
188
+
189
+ this.healthCheckServer = createServer((req, res) => {
190
+ if (req.url === '/health' && req.method === 'GET') {
191
+ // Only return success if service is fully ready (registered and accepting traffic)
192
+ if (this.isReady) {
193
+ res.writeHead(200, { 'Content-Type': 'text/plain' });
194
+ res.end('responsive');
195
+ } else {
196
+ // Service is still starting up
197
+ res.writeHead(503, { 'Content-Type': 'text/plain' });
198
+ res.end('Service Unavailable');
199
+ }
200
+ } else {
201
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
202
+ res.end('Not Found');
203
+ }
204
+ });
205
+
206
+ return new Promise((resolve, reject) => {
207
+ this.healthCheckServer!.listen(healthPort, '0.0.0.0', () => {
208
+ this.logger.info({ port: healthPort }, 'Health check server started on port');
209
+ resolve();
210
+ });
211
+ this.healthCheckServer!.on('error', (err) => {
212
+ this.logger.error({ err, port: healthPort }, 'Failed to start health check server');
213
+ reject(err);
214
+ });
215
+ });
216
+ }
217
+
218
+ private async stopHealthCheckServer(): Promise<void> {
219
+ if (!this.healthCheckServer) {
220
+ return;
221
+ }
222
+
223
+ return new Promise((resolve) => {
224
+ this.healthCheckServer!.close(() => {
225
+ this.logger.info('Health check server stopped');
226
+ resolve();
227
+ });
228
+ });
229
+ }
230
+
231
+ private async registerService(): Promise<void> {
232
+ const primary = this.services[0]?.definition;
233
+ if (!primary) {
234
+ throw new Error('Unexpected missing service definition during registration.');
235
+ }
236
+ const options = getServiceOptions(primary.ctor) ?? {};
237
+ // Match C# exactly: use qualifiedName (preserves case) for name field.
238
+ // The gateway lowercases service names when creating bindings, but uses the original case
239
+ // from the registration request when constructing routing key lookups.
240
+ // This ensures the routing key format matches what the gateway expects.
241
+ // The gateway's binding lookup behavior:
242
+ // - Gateway error shows: "No binding found for service ...micro_examplenodeservice.basic"
243
+ // - This means the gateway lowercases the service name for binding storage/lookup
244
+ // - Portal sends requests with mixed case in URL and routing key header
245
+ // - The gateway lowercases the URL path for binding lookup, which should work
246
+ // - But we need to register with the format the gateway expects for the binding key
247
+ // - The gateway constructs binding key as: {cid}.{pid}.{lowercaseServiceName}.{type}
248
+ // - So we register with lowercase to match what the gateway stores in the binding
249
+ // - The portal will still send mixed case, and the gateway will lowercase it for lookup
250
+ // Register with mixed-case qualifiedName to match C# behavior
251
+ // The gateway will lowercase the service name when creating the binding key,
252
+ // but the registration request should use the original case (as C# does)
253
+ // This ensures the service name in the registration matches what the portal expects
254
+ // Register with mixed-case qualifiedName to match C# behavior
255
+ // The gateway's ServiceIdentity.fullNameNoType lowercases the service name when creating bindings,
256
+ // but the registration request should use the original case (as C# does)
257
+ // This ensures the service name in the registration matches what the portal expects
258
+ const isDeployed = isRunningInContainer();
259
+
260
+ // For deployed services, routingKey should be null/undefined (None in Scala)
261
+ // For local dev, routingKey should be the actual routing key string
262
+ // The backend expects Option[String] = None for deployed services
263
+ // Note: microServiceId is not part of SocketSessionProviderRegisterRequest, so we don't include it
264
+ // All fields except 'type' are Option[T] in Scala, so undefined fields will be omitted from JSON
265
+ // This matches C# behavior where null/None fields are not serialized
266
+ const request: Record<string, unknown> = {
267
+ type: 'basic',
268
+ name: primary.qualifiedName, // Use mixed-case as C# does - gateway handles lowercasing for binding storage
269
+ beamoName: primary.name,
270
+ };
271
+
272
+ // Only include routingKey and startedById if they have values (for local dev)
273
+ // For deployed services, these should be omitted (undefined) to match None in Scala
274
+ if (!isDeployed && this.env.routingKey) {
275
+ request.routingKey = this.env.routingKey;
276
+ }
277
+ if (!isDeployed && this.env.accountId) {
278
+ request.startedById = this.env.accountId;
279
+ }
280
+
281
+ // Log registration request to match C# behavior exactly
282
+ // Also log the actual JSON that will be sent (undefined fields will be omitted)
283
+ const serializedRequest = JSON.stringify(request);
284
+ this.logger.debug(
285
+ {
286
+ request: {
287
+ type: request.type,
288
+ name: request.name,
289
+ beamoName: request.beamoName,
290
+ routingKey: request.routingKey,
291
+ startedById: request.startedById,
292
+ },
293
+ serializedJson: serializedRequest,
294
+ isDeployed,
295
+ },
296
+ 'Registering service provider with gateway.',
297
+ );
298
+
299
+ try {
300
+ await this.requester.request('post', 'gateway/provider', request);
301
+ this.logger.info({ serviceName: primary.qualifiedName }, 'Service provider registered successfully.');
302
+
303
+ // After registration, the gateway's BasicServiceProvider.start() is called asynchronously
304
+ // This triggers afterRabbitInit() -> setupDirectServiceCommunication() -> scheduleServiceBindingCheck()
305
+ // The first updateBindings() call happens immediately (0.millisecond delay)
306
+ // We wait a bit to allow the gateway to set up the HTTP binding and call updateBindings()
307
+ // This is especially important for deployed services where the gateway needs to establish
308
+ // the external host binding before bindings can be created
309
+ if (isDeployed) {
310
+ this.logger.debug('Waiting for gateway to establish bindings after registration...');
311
+ await new Promise((resolve) => setTimeout(resolve, 2000)); // 2 second wait for deployed services
312
+ this.logger.debug('Wait complete, gateway should have established bindings by now.');
313
+ }
314
+ } catch (error) {
315
+ this.logger.error(
316
+ {
317
+ err: error,
318
+ request,
319
+ serviceName: primary.qualifiedName,
320
+ errorMessage: error instanceof Error ? error.message : String(error),
321
+ },
322
+ 'Failed to register service provider with gateway. This will prevent the service from receiving requests.'
323
+ );
324
+ throw error; // Re-throw so startup fails and we can see the error
325
+ }
326
+
327
+ if (options.disableAllBeamableEvents) {
328
+ this.logger.info('Beamable events disabled by configuration.');
329
+ } else {
330
+ const eventRequest = {
331
+ type: 'event',
332
+ evtWhitelist: ['content.manifest', 'realm.config', 'logging.context'],
333
+ };
334
+ try {
335
+ await this.requester.request('post', 'gateway/provider', eventRequest);
336
+ } catch (error) {
337
+ this.logger.warn({ err: error }, 'Failed to register event provider. Continuing without events.');
338
+ }
339
+ }
340
+ }
341
+
342
+ private async handleEvent(envelope: WebsocketEventEnvelope): Promise<void> {
343
+ try {
344
+ if (!envelope.path) {
345
+ this.logger.debug({ envelope }, 'Ignoring websocket event without path.');
346
+ return;
347
+ }
348
+
349
+ if (envelope.path.startsWith('event/')) {
350
+ await this.requester.acknowledge(envelope.id);
351
+ return;
352
+ }
353
+
354
+ const context = this.toRequestContext(envelope);
355
+ await this.dispatch(context);
356
+ } catch (error) {
357
+ const err = error instanceof Error ? error : new Error(String(error));
358
+ this.logger.error({ err, envelope }, 'Failed to handle websocket event.');
359
+ const status = this.resolveErrorStatus(err);
360
+ const response: GatewayResponse = {
361
+ id: envelope.id,
362
+ status,
363
+ body: {
364
+ error: err.name,
365
+ message: err.message,
366
+ },
367
+ };
368
+ await this.requester.sendResponse(response);
369
+ }
370
+ }
371
+
372
+ private toRequestContext(envelope: WebsocketEventEnvelope): RequestContext {
373
+ const path = envelope.path ?? '';
374
+ const method = envelope.method ?? 'post';
375
+ const userId = typeof envelope.from === 'number' ? envelope.from : 0;
376
+ const headers = envelope.headers ?? {};
377
+
378
+ // Extract scopes from envelope.scopes array
379
+ // Note: X-DE-SCOPE header contains CID.PID, not scope values
380
+ // The gateway sends scopes in envelope.scopes array
381
+ // For admin endpoints, the gateway may not send scopes in the envelope,
382
+ // so we infer admin scope from the path if it's an admin route
383
+ const envelopeScopes = envelope.scopes ?? [];
384
+ let scopes = normalizeScopes(envelopeScopes);
385
+
386
+ // If this is an admin endpoint and no scopes are provided, infer admin scope
387
+ // The gateway may not always send scopes for admin routes
388
+ const pathLower = path.toLowerCase();
389
+ if (pathLower.includes('/admin/') && scopes.size === 0) {
390
+ scopes = normalizeScopes(['admin']);
391
+ }
392
+
393
+ let body: Record<string, unknown> | undefined;
394
+ if (envelope.body && typeof envelope.body === 'string') {
395
+ try {
396
+ body = JSON.parse(envelope.body) as Record<string, unknown>;
397
+ } catch (error) {
398
+ this.logger.warn({ err: error, raw: envelope.body }, 'Failed to parse body string.');
399
+ }
400
+ } else if (envelope.body && typeof envelope.body === 'object') {
401
+ body = envelope.body as Record<string, unknown>;
402
+ }
403
+
404
+ let payload: unknown;
405
+ if (body && typeof body === 'object' && 'payload' in body) {
406
+ const rawPayload = (body as Record<string, unknown>).payload;
407
+ if (typeof rawPayload === 'string') {
408
+ try {
409
+ payload = JSON.parse(rawPayload) as unknown[];
410
+ } catch (error) {
411
+ this.logger.warn({ err: error }, 'Failed to parse payload JSON.');
412
+ payload = rawPayload;
413
+ }
414
+ } else {
415
+ payload = rawPayload;
416
+ }
417
+ }
418
+
419
+ const targetService = this.findServiceForPath(path);
420
+ const services = this.serviceManager.createFacade(userId, scopes, targetService?.definition.name);
421
+ const provider = this.createRequestScope(path, targetService);
422
+
423
+ const context: RequestContext = {
424
+ id: envelope.id,
425
+ path,
426
+ method,
427
+ status: envelope.status ?? 0,
428
+ userId,
429
+ payload,
430
+ body,
431
+ scopes,
432
+ headers,
433
+ cid: this.env.cid,
434
+ pid: this.env.pid,
435
+ services,
436
+ throwIfCancelled: () => {},
437
+ isCancelled: () => false,
438
+ hasScopes: (...requiredScopes: string[]) => requiredScopes.every((scope) => scopeSetHas(scopes, scope)),
439
+ requireScopes: (...requiredScopes: string[]) => {
440
+ const missingScopes = requiredScopes.filter((scope) => !scopeSetHas(scopes, scope));
441
+ if (missingScopes.length > 0) {
442
+ throw new MissingScopesError(missingScopes);
443
+ }
444
+ },
445
+ provider,
446
+ };
447
+
448
+ provider.setInstance(REQUEST_CONTEXT_TOKEN, context);
449
+ provider.setInstance(BEAMABLE_SERVICES_TOKEN, services);
450
+
451
+ return context;
452
+ }
453
+
454
+ private findServiceForPath(path: string): ServiceInstance | undefined {
455
+ // Gateway sends paths with lowercase service names, so we need case-insensitive matching
456
+ // Match by comparing lowercase versions to handle gateway's lowercase path format
457
+ const pathLower = path.toLowerCase();
458
+ return this.services.find((service) => {
459
+ const qualifiedNameLower = service.definition.qualifiedName.toLowerCase();
460
+ return pathLower.startsWith(`${qualifiedNameLower}/`);
461
+ });
462
+ }
463
+
464
+ private async dispatch(ctx: RequestContext): Promise<void> {
465
+ const service = this.findServiceForPath(ctx.path);
466
+ if (!service) {
467
+ throw new UnknownRouteError(ctx.path);
468
+ }
469
+
470
+ if (await this.tryHandleFederationRoute(ctx, service)) {
471
+ return;
472
+ }
473
+
474
+ if (await this.tryHandleAdminRoute(ctx, service)) {
475
+ return;
476
+ }
477
+
478
+ // Extract route from path - handle case-insensitive path matching
479
+ const pathLower = ctx.path.toLowerCase();
480
+ const qualifiedNameLower = service.definition.qualifiedName.toLowerCase();
481
+ const route = pathLower.substring(qualifiedNameLower.length + 1);
482
+ const metadata = service.definition.callables.get(route);
483
+ if (!metadata) {
484
+ throw new UnknownRouteError(ctx.path);
485
+ }
486
+
487
+ // For server and admin access, allow userId: 0 if the appropriate scope is present
488
+ // For client access, always require userId > 0
489
+ if (metadata.requireAuth && ctx.userId <= 0) {
490
+ if (metadata.access === 'server' && scopeSetHas(ctx.scopes, 'server')) {
491
+ // Server callables with server scope don't need a user ID
492
+ } else if (metadata.access === 'admin' && scopeSetHas(ctx.scopes, 'admin')) {
493
+ // Admin callables with admin scope don't need a user ID
494
+ } else {
495
+ throw new UnauthorizedUserError(route);
496
+ }
497
+ }
498
+
499
+ enforceAccess(metadata.access, ctx);
500
+
501
+ if (metadata.requiredScopes.length > 0) {
502
+ const missing = metadata.requiredScopes.filter((scope) => !scopeSetHas(ctx.scopes, scope));
503
+ if (missing.length > 0) {
504
+ throw new MissingScopesError(missing);
505
+ }
506
+ }
507
+
508
+ const handler = service.instance[metadata.displayName];
509
+ if (typeof handler !== 'function') {
510
+ throw new Error(`Callable ${metadata.displayName} is not a function on service ${service.definition.name}.`);
511
+ }
512
+
513
+ const args = this.buildInvocationArguments(handler as (...args: unknown[]) => unknown, ctx);
514
+ const result = await Promise.resolve((handler as (...args: unknown[]) => unknown).apply(service.instance, args));
515
+ await this.sendSuccessResponse(ctx, metadata, result);
516
+ }
517
+
518
+ private buildInvocationArguments(
519
+ handler: (...args: unknown[]) => unknown,
520
+ ctx: RequestContext,
521
+ ): unknown[] {
522
+ const payload = Array.isArray(ctx.payload)
523
+ ? ctx.payload
524
+ : typeof ctx.payload === 'string'
525
+ ? [ctx.payload]
526
+ : ctx.payload === undefined
527
+ ? []
528
+ : [ctx.payload];
529
+
530
+ const expectedParams = handler.length;
531
+ if (expectedParams === payload.length + 1) {
532
+ return [ctx, ...payload];
533
+ }
534
+ return payload;
535
+ }
536
+
537
+ private async sendSuccessResponse(ctx: RequestContext, metadata: ServiceCallableMetadata, result: unknown): Promise<void> {
538
+ let body: unknown;
539
+ if (metadata.useLegacySerialization) {
540
+ const serialized = typeof result === 'string' ? result : JSON.stringify(result ?? null);
541
+ body = { payload: serialized };
542
+ } else {
543
+ body = result ?? null;
544
+ }
545
+
546
+ const response: GatewayResponse = {
547
+ id: ctx.id,
548
+ status: 200,
549
+ body,
550
+ };
551
+ await this.requester.sendResponse(response);
552
+ }
553
+
554
+ private async sendFederationResponse(ctx: RequestContext, result: unknown): Promise<void> {
555
+ const response: GatewayResponse = {
556
+ id: ctx.id,
557
+ status: 200,
558
+ body: result ?? null,
559
+ };
560
+ await this.requester.sendResponse(response);
561
+ }
562
+
563
+ private async tryHandleFederationRoute(ctx: RequestContext, service: ServiceInstance): Promise<boolean> {
564
+ // Gateway sends paths with lowercase service names, so we need case-insensitive matching
565
+ const pathLower = ctx.path.toLowerCase();
566
+ const qualifiedNameLower = service.definition.qualifiedName.toLowerCase();
567
+ const prefixLower = `${qualifiedNameLower}/`;
568
+ if (!pathLower.startsWith(prefixLower)) {
569
+ return false;
570
+ }
571
+ // Extract relative path - use lowercase length since gateway sends lowercase paths
572
+ const relativePath = ctx.path.substring(qualifiedNameLower.length + 1);
573
+ const handler = service.federationRegistry.resolve(relativePath);
574
+ if (!handler) {
575
+ return false;
576
+ }
577
+
578
+ const result = await handler.invoke(ctx as FederatedRequestContext);
579
+ await this.sendFederationResponse(ctx, result);
580
+ return true;
581
+ }
582
+
583
+ private async tryHandleAdminRoute(ctx: RequestContext, service: ServiceInstance): Promise<boolean> {
584
+ // Gateway sends paths with lowercase service names, so we need case-insensitive matching
585
+ // Check if path starts with the admin prefix (case-insensitive)
586
+ const pathLower = ctx.path.toLowerCase();
587
+ const adminPrefixLower = `${service.definition.qualifiedName.toLowerCase()}/admin/`;
588
+ if (!pathLower.startsWith(adminPrefixLower)) {
589
+ return false;
590
+ }
591
+
592
+ const options = getServiceOptions(service.definition.ctor) ?? {};
593
+
594
+ const action = pathLower.substring(adminPrefixLower.length);
595
+ const requiresAdmin = action === 'metadata' || action === 'docs';
596
+ if (requiresAdmin && !scopeSetHas(ctx.scopes, 'admin')) {
597
+ // For portal requests to admin endpoints, the gateway may not send scopes
598
+ // The X-DE-SCOPE header contains CID.PID, not scope values
599
+ // If this is a portal request (has X-DE-SCOPE header), grant admin scope
600
+ const hasPortalHeader = ctx.headers['X-DE-SCOPE'] || ctx.headers['x-de-scope'];
601
+ if (hasPortalHeader) {
602
+ // Grant admin scope for portal requests to admin endpoints
603
+ ctx.scopes.add('admin');
604
+ } else {
605
+ throw new MissingScopesError(['admin']);
606
+ }
607
+ }
608
+
609
+ switch (action) {
610
+ case 'health':
611
+ case 'healthcheck':
612
+ await this.requester.sendResponse({ id: ctx.id, status: 200, body: 'responsive' });
613
+ return true;
614
+ case 'metadata':
615
+ case 'docs':
616
+ break;
617
+ default:
618
+ if (!scopeSetHas(ctx.scopes, 'admin')) {
619
+ throw new MissingScopesError(['admin']);
620
+ }
621
+ throw new UnknownRouteError(ctx.path);
622
+ }
623
+
624
+ if (action === 'metadata') {
625
+ const federatedComponents = service.federationRegistry
626
+ .list()
627
+ .map((component) => ({
628
+ federationNamespace: component.federationNamespace,
629
+ federationType: component.federationType,
630
+ }));
631
+
632
+ await this.requester.sendResponse({
633
+ id: ctx.id,
634
+ status: 200,
635
+ body: {
636
+ serviceName: service.definition.name,
637
+ sdkVersion: this.env.sdkVersionExecution,
638
+ sdkBaseBuildVersion: this.env.sdkVersionExecution,
639
+ sdkExecutionVersion: this.env.sdkVersionExecution,
640
+ useLegacySerialization: Boolean(options.useLegacySerialization),
641
+ disableAllBeamableEvents: Boolean(options.disableAllBeamableEvents),
642
+ enableEagerContentLoading: false,
643
+ instanceId: this.microServiceId,
644
+ routingKey: this.env.routingKey ?? '',
645
+ federatedComponents,
646
+ },
647
+ });
648
+ return true;
649
+ }
650
+
651
+ if (action === 'docs') {
652
+ try {
653
+ const document = generateOpenApiDocument(
654
+ {
655
+ qualifiedName: service.definition.qualifiedName,
656
+ name: service.definition.name,
657
+ callables: Array.from(service.definition.callables.values()).map((callable) => ({
658
+ name: callable.displayName,
659
+ route: callable.route,
660
+ metadata: callable,
661
+ handler: typeof service.instance[callable.displayName] === 'function'
662
+ ? (service.instance[callable.displayName] as (...args: unknown[]) => unknown)
663
+ : undefined,
664
+ })),
665
+ },
666
+ this.env,
667
+ );
668
+
669
+ // Ensure document is valid (not empty)
670
+ if (!document || Object.keys(document).length === 0) {
671
+ this.logger.warn({ serviceName: service.definition.name }, 'Generated OpenAPI document is empty');
672
+ }
673
+
674
+ await this.requester.sendResponse({
675
+ id: ctx.id,
676
+ status: 200,
677
+ body: document,
678
+ });
679
+ return true;
680
+ } catch (error) {
681
+ this.logger.error({ err: error, serviceName: service.definition.name }, 'Failed to generate or send docs');
682
+ throw error;
683
+ }
684
+ }
685
+
686
+ return false;
687
+ }
688
+
689
+ private resolveErrorStatus(error: Error): number {
690
+ if (error instanceof UnauthorizedUserError) {
691
+ return 401;
692
+ }
693
+ if (error instanceof MissingScopesError) {
694
+ return 403;
695
+ }
696
+ if (error instanceof UnknownRouteError) {
697
+ return 404;
698
+ }
699
+ if (error instanceof BeamableRuntimeError) {
700
+ return 500;
701
+ }
702
+ return 500;
703
+ }
704
+
705
+ private printHelpfulUrls(service?: ServiceInstance): void {
706
+ if (!service) {
707
+ return;
708
+ }
709
+
710
+ const docsUrl = buildDocsPortalUrl(this.env, service.definition);
711
+ const endpointBase = buildPostmanBaseUrl(this.env, service.definition);
712
+
713
+ const green = (text: string) => `\x1b[32m${text}\x1b[0m`;
714
+ const yellow = (text: string) => `\x1b[33m${text}\x1b[0m`;
715
+
716
+ const bannerLines = [
717
+ '',
718
+ yellow('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'),
719
+ ` ${green('Beamable Node microservice ready:')} ${green(service.definition.name)}`,
720
+ yellow(' Quick shortcuts'),
721
+ ` ${yellow('Docs:')} ${docsUrl}`,
722
+ ` ${yellow('Endpoint:')} ${endpointBase}`,
723
+ yellow('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'),
724
+ '',
725
+ ];
726
+
727
+ process.stdout.write(`${bannerLines.join('\n')}`);
728
+ }
729
+
730
+ private async initializeDependencyProviders(): Promise<void> {
731
+ for (const service of this.services) {
732
+ const builder = new DependencyBuilder();
733
+ builder.tryAddSingletonInstance(LOGGER_TOKEN, service.logger);
734
+ builder.tryAddSingletonInstance(ENVIRONMENT_CONFIG_TOKEN, this.env);
735
+ builder.tryAddSingletonInstance(BeamableServiceManager, this.serviceManager);
736
+ builder.tryAddSingletonInstance(service.definition.ctor, service.instance);
737
+ builder.tryAddSingletonInstance(FederationRegistry, service.federationRegistry);
738
+
739
+ for (const handler of service.configureHandlers) {
740
+ await handler(builder);
741
+ }
742
+
743
+ const provider = builder.build();
744
+ service.provider = provider;
745
+
746
+ for (const handler of service.initializeHandlers) {
747
+ await handler(provider);
748
+ }
749
+
750
+ Object.defineProperty(service.instance, 'provider', {
751
+ value: provider,
752
+ enumerable: false,
753
+ configurable: false,
754
+ writable: false,
755
+ });
756
+ }
757
+ }
758
+
759
+ private createRequestScope(path: string, service?: ServiceInstance): MutableDependencyScope {
760
+ const targetService = service ?? this.findServiceForPath(path);
761
+ const provider = targetService?.provider ?? new DependencyBuilder().build();
762
+ return provider.createScope();
763
+ }
764
+ }
765
+
766
+ function enforceAccess(access: ServiceAccess, ctx: RequestContext): void {
767
+ switch (access) {
768
+ case 'client':
769
+ if (ctx.userId <= 0) {
770
+ throw new UnauthorizedUserError(ctx.path);
771
+ }
772
+ break;
773
+ case 'server':
774
+ if (!scopeSetHas(ctx.scopes, 'server')) {
775
+ throw new MissingScopesError(['server']);
776
+ }
777
+ break;
778
+ case 'admin':
779
+ if (!scopeSetHas(ctx.scopes, 'admin')) {
780
+ throw new MissingScopesError(['admin']);
781
+ }
782
+ break;
783
+ default:
784
+ break;
785
+ }
786
+ }
787
+
788
+ function isRunningInContainer(): boolean {
789
+ // Beamable sets CID, PID, HOST, SECRET in deployed containers
790
+ // If we have these required env vars but NO routing key, we're in a deployed container
791
+ // This is more reliable than checking for DOTNET_RUNNING_IN_CONTAINER (which is C#-specific)
792
+ const hasBeamableEnvVars = !!(process.env.CID && process.env.PID && process.env.HOST && process.env.SECRET);
793
+ const hasNoRoutingKey = !process.env.NAME_PREFIX && !process.env.ROUTING_KEY;
794
+
795
+ if (hasBeamableEnvVars && hasNoRoutingKey) {
796
+ return true; // Deployed container
797
+ }
798
+
799
+ // Fallback checks for other container indicators
800
+ return (
801
+ process.env.DOTNET_RUNNING_IN_CONTAINER === 'true' ||
802
+ process.env.CONTAINER === 'beamable' ||
803
+ !!process.env.ECS_CONTAINER_METADATA_URI ||
804
+ !!process.env.KUBERNETES_SERVICE_HOST
805
+ );
806
+ }
807
+
808
+ function normalizeScopes(scopes: Array<unknown>): Set<string> {
809
+ const normalized = new Set<string>();
810
+ for (const scope of scopes) {
811
+ if (typeof scope !== 'string' || scope.trim().length === 0) {
812
+ continue;
813
+ }
814
+ normalized.add(scope.trim().toLowerCase());
815
+ }
816
+ return normalized;
817
+ }
818
+
819
+ function scopeSetHas(scopes: Set<string>, scope: string): boolean {
820
+ const normalized = scope.trim().toLowerCase();
821
+ if (normalized.length === 0) {
822
+ return false;
823
+ }
824
+ return scopes.has('*') || scopes.has(normalized);
825
+ }
826
+
827
+ function buildPostmanBaseUrl(env: EnvironmentConfig, service: ServiceDefinition): string {
828
+ const httpHost = hostToHttpUrl(env.host);
829
+ const routingKeyPart = env.routingKey ? env.routingKey : '';
830
+ return `${httpHost}/basic/${env.cid}.${env.pid}.${routingKeyPart}${service.qualifiedName}/`;
831
+ }
832
+
833
+ function buildDocsPortalUrl(env: EnvironmentConfig, service: ServiceDefinition): string {
834
+ const portalHost = hostToPortalUrl(hostToHttpUrl(env.host));
835
+ const queryParams: Record<string, string> = { srcTool: 'node-runtime' };
836
+ if (env.routingKey) {
837
+ queryParams.routingKey = env.routingKey;
838
+ }
839
+ const query = new URLSearchParams(queryParams);
840
+ if (env.refreshToken) {
841
+ query.set('refresh_token', env.refreshToken);
842
+ }
843
+ const beamoId = service.name;
844
+ return `${portalHost}/${env.cid}/games/${env.pid}/realms/${env.pid}/microservices/micro_${beamoId}/docs?${query.toString()}`;
845
+ }
846
+
847
+ export async function runMicroservice(): Promise<void> {
848
+ // Immediate console output to verify process is starting
849
+ // This goes to stdout and should be visible in container logs
850
+ console.error('[BEAMABLE-NODE] Starting microservice...');
851
+ console.error(`[BEAMABLE-NODE] Node version: ${process.version}`);
852
+ console.error(`[BEAMABLE-NODE] Working directory: ${process.cwd()}`);
853
+ console.error(`[BEAMABLE-NODE] Environment: ${JSON.stringify({
854
+ NODE_ENV: process.env.NODE_ENV,
855
+ CID: process.env.CID ? 'SET' : 'NOT SET',
856
+ PID: process.env.PID ? 'SET' : 'NOT SET',
857
+ HOST: process.env.HOST ? 'SET' : 'NOT SET',
858
+ SECRET: process.env.SECRET ? 'SET' : 'NOT SET',
859
+ })}`);
860
+ if (process.env.BEAMABLE_SKIP_RUNTIME === 'true') {
861
+ return;
862
+ }
863
+ const runtime = new MicroserviceRuntime();
864
+
865
+ // Handle uncaught errors - log them but don't crash immediately
866
+ // This allows the health check server to keep running so we can debug
867
+ process.on('uncaughtException', (error) => {
868
+ console.error('Uncaught Exception:', error);
869
+ // Don't exit - let the health check server keep running
870
+ });
871
+
872
+ process.on('unhandledRejection', (reason, promise) => {
873
+ console.error('Unhandled Rejection at:', promise, 'reason:', reason);
874
+ // Don't exit - let the health check server keep running
875
+ });
876
+
877
+ try {
878
+ await runtime.start();
879
+ } catch (error) {
880
+ // Log the error but don't exit - health check server is running
881
+ // This allows the container to stay alive so we can see what went wrong
882
+ console.error('Failed to start microservice runtime:', error);
883
+ // Keep the process alive so health check can continue
884
+ // The health check will return 503 until isReady is true
885
+ }
886
+
887
+ const shutdownSignals: NodeJS.Signals[] = ['SIGTERM', 'SIGINT'];
888
+ shutdownSignals.forEach((signal) => {
889
+ process.once(signal, async () => {
890
+ await runtime.shutdown();
891
+ process.exit(0);
892
+ });
893
+ });
894
+ }
895
+
896
+ interface GenerateOpenApiOptions {
897
+ serviceName?: string;
898
+ }
899
+
900
+ export function generateOpenApiDocumentForRegisteredServices(
901
+ overrides: Partial<EnvironmentConfig> = {},
902
+ options: GenerateOpenApiOptions = {},
903
+ ): unknown {
904
+ const services = listRegisteredServices();
905
+ if (services.length === 0) {
906
+ throw new Error('No microservices registered. Import your service module before generating documentation.');
907
+ }
908
+
909
+ const baseEnv = buildEnvironmentConfig(overrides);
910
+ const primary = options.serviceName
911
+ ? services.find((svc) => svc.name === options.serviceName || svc.qualifiedName === options.serviceName)
912
+ : services[0];
913
+
914
+ if (!primary) {
915
+ throw new Error(`No registered microservice matched '${options.serviceName}'.`);
916
+ }
917
+
918
+ return generateOpenApiDocument(
919
+ {
920
+ qualifiedName: primary.qualifiedName,
921
+ name: primary.name,
922
+ callables: Array.from(primary.callables.entries()).map(([displayName, metadata]) => ({
923
+ name: displayName,
924
+ route: metadata.route,
925
+ metadata,
926
+ handler: undefined,
927
+ })),
928
+ },
929
+ baseEnv,
930
+ );
931
+ }
932
+
933
+ function buildEnvironmentConfig(overrides: Partial<EnvironmentConfig>): EnvironmentConfig {
934
+ const merged: Partial<EnvironmentConfig> = { ...overrides };
935
+
936
+ const ensure = (key: keyof EnvironmentConfig, fallbackEnv?: string) => {
937
+ if (merged[key] !== undefined) {
938
+ return;
939
+ }
940
+ if (fallbackEnv && process.env[fallbackEnv]) {
941
+ merged[key] = process.env[fallbackEnv] as never;
942
+ }
943
+ };
944
+
945
+ ensure('cid', 'CID');
946
+ ensure('pid', 'PID');
947
+ ensure('host', 'HOST');
948
+ ensure('secret', 'SECRET');
949
+ ensure('refreshToken', 'REFRESH_TOKEN');
950
+ // Routing key is optional for deployed services (in containers)
951
+ // It will be resolved to empty string if not provided and running in container
952
+ if (!merged.routingKey && !isRunningInContainer()) {
953
+ ensure('routingKey', 'NAME_PREFIX');
954
+ }
955
+
956
+ if (!merged.cid || !merged.pid || !merged.host) {
957
+ throw new Error('CID, PID, and HOST are required to generate documentation.');
958
+ }
959
+
960
+ const base = loadEnvironmentConfig();
961
+
962
+ return {
963
+ ...base,
964
+ ...merged,
965
+ routingKey: merged.routingKey ?? base.routingKey,
966
+ };
967
+ }