@hla4ts/spacekit 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/src/app.ts ADDED
@@ -0,0 +1,1018 @@
1
+ import { decodeHLAinteger64Time, handleToHex, type LogicalTime, type RTIAmbassador } from "@hla4ts/hla-api";
2
+ import type { FomOutputFormat, ModelIdentification, FomOutputOptions, FomModel, SharingType } from "@hla4ts/fom-codegen";
3
+ import { renderFomXml } from "@hla4ts/fom-codegen";
4
+ import { promises as fs } from "node:fs";
5
+ import path from "node:path";
6
+ import { DeclarativeFom } from "./declarative.ts";
7
+ import {
8
+ DeclarativeRuntime,
9
+ type DeclarativeRuntimeOptions,
10
+ type DeclarativeObjectHandlers,
11
+ type DeclarativeInteractionHandlers,
12
+ type DeclarativePublicationSummary,
13
+ } from "./declarative-runtime.ts";
14
+ import { ObjectClassAdapter, InteractionClassAdapter } from "./object-model.ts";
15
+ import type { ObjectInstanceAdapter } from "./object-model.ts";
16
+ import { SpacekitFederate } from "./federate.ts";
17
+ import { TimeAdvanceController } from "./time-advance.ts";
18
+ import type { FederateHandlers, SpacekitFederateConfig } from "./types.ts";
19
+ import type { LogLevel, SpacekitLogger } from "./env.ts";
20
+ import { createConsoleLogger, isDebugEnabled, withDebugEnabled } from "./logger.ts";
21
+ import { bindEntity, consumeEntityChanges, getEntityBinding, type SpacekitEntity } from "./entity.ts";
22
+ import type { FomClassConstructor } from "./decorators.ts";
23
+ import {
24
+ getDecoratedInteractionClassMetadata,
25
+ getDecoratedInteractionClassName,
26
+ getDecoratedObjectClassMetadata,
27
+ getDecoratedObjectClassName,
28
+ registerDecoratedClasses,
29
+ } from "./decorators.ts";
30
+
31
+ export interface SpacekitBootstrapper {
32
+ handlers: FederateHandlers;
33
+ run(rti: RTIAmbassador): Promise<unknown>;
34
+ }
35
+
36
+ export interface SpacekitAppConfig {
37
+ federate: SpacekitFederateConfig;
38
+ handlers?: FederateHandlers;
39
+ declarative?: DeclarativeFom;
40
+ runtime?: DeclarativeRuntimeOptions;
41
+ bootstrap?: SpacekitBootstrapper;
42
+ fomExport?: {
43
+ enabled?: boolean;
44
+ path?: string;
45
+ format?: FomOutputFormat;
46
+ modelIdentification?: ModelIdentification;
47
+ indent?: string;
48
+ inferParentStubs?: boolean;
49
+ };
50
+ logging?: {
51
+ logger?: SpacekitLogger;
52
+ level?: LogLevel;
53
+ scope?: string;
54
+ };
55
+ }
56
+
57
+ export interface RegisterObjectOptions {
58
+ name?: string;
59
+ reserveName?: boolean;
60
+ reservationTimeoutMs?: number;
61
+ }
62
+
63
+ export interface TrackRecord<T extends object> {
64
+ key: string;
65
+ instance: ObjectInstanceAdapter<T>;
66
+ name?: string;
67
+ tracked: boolean;
68
+ updateCount: number;
69
+ lastValues?: Partial<T>;
70
+ lastTime?: LogicalTime;
71
+ }
72
+
73
+ export interface TrackOptions<T extends object> {
74
+ filter?: (record: TrackRecord<T>) => boolean;
75
+ onDiscover?: (payload: { record: TrackRecord<T> }) => void;
76
+ onUpdate?: (payload: { record: TrackRecord<T>; values: Partial<T>; time?: LogicalTime }) => void;
77
+ onRemove?: (payload: { record: TrackRecord<T> }) => void;
78
+ }
79
+
80
+ interface PendingEntity<T extends object> {
81
+ entity: SpacekitEntity<T>;
82
+ options?: RegisterObjectOptions;
83
+ resolve: (instance: ObjectInstanceAdapter<T>) => void;
84
+ reject: (err: Error) => void;
85
+ }
86
+
87
+ interface ObjectSchemaProvider<T extends object> {
88
+ schema?: () => import("./declarative.ts").DeclarativeObjectClass<T>;
89
+ }
90
+
91
+ interface InteractionSchemaProvider<T extends object> {
92
+ schema?: () => import("./declarative.ts").DeclarativeInteractionClass<T>;
93
+ }
94
+
95
+ export class SpacekitApp {
96
+ readonly declarative: DeclarativeFom;
97
+ readonly runtime: DeclarativeRuntime;
98
+ readonly federate: SpacekitFederate;
99
+ readonly bootstrap?: SpacekitBootstrapper;
100
+ readonly logger: SpacekitLogger;
101
+ readonly timeController: TimeAdvanceController;
102
+ private readonly _appConfig: SpacekitAppConfig;
103
+ private readonly _entities = new Set<SpacekitEntity<object>>();
104
+ private readonly _pendingEntities: Array<PendingEntity<object>> = [];
105
+ private _runtimePrepared = false;
106
+ private _started = false;
107
+ private readonly _reservationHandlers: FederateHandlers;
108
+ private readonly _pendingReservations = new Map<
109
+ string,
110
+ { resolve: () => void; reject: (err: Error) => void; timeout?: ReturnType<typeof setTimeout> }
111
+ >();
112
+ private _bootstrapState: unknown;
113
+ private readonly _debugEnabled: boolean;
114
+ private _shutdownRequested = false;
115
+ private _shutdownInProgress = false;
116
+ private readonly _shutdownHandlers: Array<{ signal: NodeJS.Signals; handler: () => void }> = [];
117
+ private _gracefulShutdownEnabled = false;
118
+
119
+ constructor(config: SpacekitAppConfig) {
120
+ this._appConfig = config;
121
+ this.declarative = config.declarative ?? new DeclarativeFom();
122
+ this.runtime = new DeclarativeRuntime(this.declarative, config.runtime);
123
+ this.bootstrap = config.bootstrap;
124
+ this.timeController = new TimeAdvanceController();
125
+ this.logger =
126
+ config.logging?.logger ??
127
+ createConsoleLogger({
128
+ level: config.logging?.level ?? "info",
129
+ scope: config.logging?.scope ?? "spacekit",
130
+ });
131
+ this._debugEnabled = config.logging?.level === "debug" || isDebugEnabled(this.logger);
132
+ withDebugEnabled(this.logger, this._debugEnabled);
133
+ this._reservationHandlers = this._createReservationHandlers();
134
+
135
+ this.federate = new SpacekitFederate(config.federate, this.runtime.handlers);
136
+ this.federate.addHandlers(this._reservationHandlers);
137
+ this.federate.addHandlers(this.timeController.handlers);
138
+ if (this.bootstrap) {
139
+ this.federate.addHandlers(this.bootstrap.handlers);
140
+ }
141
+ if (config.handlers) {
142
+ this.federate.addHandlers(config.handlers);
143
+ }
144
+ }
145
+
146
+ get rtiAmbassador(): RTIAmbassador | null {
147
+ return this.federate.rtiAmbassador;
148
+ }
149
+
150
+ get bootstrapState(): unknown {
151
+ return this._bootstrapState;
152
+ }
153
+
154
+ get isStarted(): boolean {
155
+ return this._started;
156
+ }
157
+
158
+ get isShutdownRequested(): boolean {
159
+ return this._shutdownRequested;
160
+ }
161
+
162
+ get debugEnabled(): boolean {
163
+ return this._debugEnabled;
164
+ }
165
+
166
+ get currentTimeMicros(): bigint | null {
167
+ return this.timeController.lastGrantedTimeMicros;
168
+ }
169
+
170
+ defineObjectClass<T extends object>(schema: import("./declarative.ts").DeclarativeObjectClass<T>) {
171
+ const adapter = this.declarative.defineObjectClass(schema);
172
+ if (this._debugEnabled) {
173
+ this.runtime.addObjectHandlers(adapter, {
174
+ discover: ({ instance }) => {
175
+ this.logger.debug("Object discovered.", {
176
+ className: adapter.className,
177
+ instanceName: instance.objectInstanceName ?? "(unnamed)",
178
+ });
179
+ },
180
+ update: ({ values, time }) => {
181
+ this.logger.debug("Object update.", {
182
+ className: adapter.className,
183
+ time,
184
+ values,
185
+ });
186
+ },
187
+ remove: ({ instance }) => {
188
+ this.logger.debug("Object removed.", {
189
+ className: adapter.className,
190
+ instanceName: instance.objectInstanceName ?? "(unnamed)",
191
+ });
192
+ },
193
+ });
194
+ }
195
+ return adapter;
196
+ }
197
+
198
+ defineInteractionClass<T extends object>(
199
+ schema: import("./declarative.ts").DeclarativeInteractionClass<T>
200
+ ) {
201
+ const adapter = this.declarative.defineInteractionClass(schema);
202
+ if (this._debugEnabled) {
203
+ this.runtime.addInteractionHandlers(adapter, {
204
+ receive: ({ values, time }) => {
205
+ this.logger.debug("Interaction received.", {
206
+ className: adapter.className,
207
+ time,
208
+ values,
209
+ });
210
+ },
211
+ });
212
+ }
213
+ return adapter;
214
+ }
215
+
216
+ onObject<T extends object>(
217
+ target: ObjectClassAdapter<T> | FomClassConstructor,
218
+ handlers: DeclarativeObjectHandlers<T>
219
+ ): void {
220
+ const adapter =
221
+ target instanceof ObjectClassAdapter
222
+ ? target
223
+ : this._ensureObjectAdapterFromClass<T>(target);
224
+ this.runtime.registerObjectHandlers(adapter, handlers);
225
+ }
226
+
227
+ onInteraction<T extends object>(
228
+ target: InteractionClassAdapter<T> | FomClassConstructor,
229
+ handlers:
230
+ | DeclarativeInteractionHandlers<T>
231
+ | ((payload: { values: Partial<T>; time?: LogicalTime }) => void)
232
+ ): void {
233
+ const adapter =
234
+ target instanceof InteractionClassAdapter
235
+ ? target
236
+ : this._ensureInteractionAdapterFromClass<T>(target);
237
+ const resolvedHandlers: DeclarativeInteractionHandlers<T> =
238
+ typeof handlers === "function"
239
+ ? {
240
+ receive: ({ values, time }) => {
241
+ handlers({ values, time });
242
+ },
243
+ }
244
+ : handlers;
245
+ this.runtime.registerInteractionHandlers(adapter, resolvedHandlers);
246
+ }
247
+
248
+ listen<T extends object>(
249
+ target: ObjectClassAdapter<T> | InteractionClassAdapter<T> | FomClassConstructor,
250
+ handlers:
251
+ | DeclarativeObjectHandlers<T>
252
+ | DeclarativeInteractionHandlers<T>
253
+ | ((
254
+ payload:
255
+ | { values: Partial<T>; time?: LogicalTime }
256
+ | {
257
+ values: Partial<T>;
258
+ instance: ObjectInstanceAdapter<T>;
259
+ }
260
+ ) => void)
261
+ ): void {
262
+ if (target instanceof ObjectClassAdapter) {
263
+ if (typeof handlers === "function") {
264
+ this.onObject(target, {
265
+ update: ({ values, instance }) => {
266
+ handlers({ values, instance });
267
+ },
268
+ });
269
+ return;
270
+ }
271
+ this.onObject(target, handlers as DeclarativeObjectHandlers<T>);
272
+ return;
273
+ }
274
+ if (target instanceof InteractionClassAdapter) {
275
+ if (typeof handlers === "function") {
276
+ this.onInteraction(target, ({ values, time }) => handlers({ values, time }));
277
+ return;
278
+ }
279
+ this.onInteraction(target, handlers as DeclarativeInteractionHandlers<T>);
280
+ return;
281
+ }
282
+
283
+ try {
284
+ this.onObject(target, handlers as DeclarativeObjectHandlers<T>);
285
+ return;
286
+ } catch {}
287
+
288
+ this.onInteraction(target, handlers as DeclarativeInteractionHandlers<T>);
289
+ }
290
+
291
+ track<T extends object>(
292
+ target: ObjectClassAdapter<T> | FomClassConstructor,
293
+ options: TrackOptions<T> = {}
294
+ ): Map<string, TrackRecord<T>> {
295
+ const records = new Map<string, TrackRecord<T>>();
296
+ const resolveRecord = (instance: ObjectInstanceAdapter<T>): TrackRecord<T> => {
297
+ const key = handleToHex(instance.objectInstance);
298
+ const existing = records.get(key);
299
+ if (existing) return existing;
300
+ const created: TrackRecord<T> = {
301
+ key,
302
+ instance,
303
+ tracked: false,
304
+ updateCount: 0,
305
+ };
306
+ records.set(key, created);
307
+ return created;
308
+ };
309
+
310
+ this.onObject(target, {
311
+ discover: ({ instance, objectInstanceName }) => {
312
+ const record = resolveRecord(instance);
313
+ if (objectInstanceName) record.name = objectInstanceName;
314
+ if (!record.tracked && (!options.filter || options.filter(record))) {
315
+ record.tracked = true;
316
+ options.onDiscover?.({ record });
317
+ }
318
+ },
319
+ update: ({ instance, values, time }) => {
320
+ const record = resolveRecord(instance);
321
+ if (values && "name" in values && typeof values.name === "string") {
322
+ record.name = values.name;
323
+ }
324
+ if (!record.tracked && options.filter && !options.filter(record)) {
325
+ return;
326
+ }
327
+ if (!record.tracked) {
328
+ record.tracked = true;
329
+ options.onDiscover?.({ record });
330
+ }
331
+ record.lastValues = values;
332
+ record.lastTime = time;
333
+ record.updateCount += 1;
334
+ options.onUpdate?.({ record, values, time });
335
+ },
336
+ remove: ({ instance }) => {
337
+ const key = handleToHex(instance.objectInstance);
338
+ const record = records.get(key);
339
+ if (record) {
340
+ if (record.tracked) {
341
+ options.onRemove?.({ record });
342
+ }
343
+ records.delete(key);
344
+ }
345
+ },
346
+ });
347
+
348
+ return records;
349
+ }
350
+
351
+ async start(options?: { entities?: Array<SpacekitEntity<object>> }): Promise<void> {
352
+ this._shutdownRequested = false;
353
+ const pendingRegistrations: Array<Promise<ObjectInstanceAdapter<object>>> = [];
354
+ if (options?.entities) {
355
+ for (const entity of options.entities) {
356
+ pendingRegistrations.push(this.registerEntity(entity));
357
+ }
358
+ }
359
+
360
+ await this.exportFomXml();
361
+ this.logger.info("Connecting to RTI...");
362
+ await this.federate.start();
363
+ this.logger.info("Connected and joined federation.");
364
+ const rti = this._requireRti();
365
+ const summary = this.runtime.getPublicationSummary();
366
+ await this.runtime.prepare(rti);
367
+ this._runtimePrepared = true;
368
+ this._logPublicationSummary(summary);
369
+ if (this.bootstrap) {
370
+ this._bootstrapState = await this.bootstrap.run(rti);
371
+ this.logger.info("Bootstrap complete.");
372
+ }
373
+ await this._flushPendingEntities();
374
+ await Promise.all(pendingRegistrations);
375
+ this._started = true;
376
+ }
377
+
378
+ async stop(options: { resignTimeoutMs?: number; disconnectTimeoutMs?: number } = {}): Promise<void> {
379
+ if (this._shutdownInProgress) return;
380
+ this._shutdownInProgress = true;
381
+ this._shutdownRequested = true;
382
+ this.logger.info("Stopping SpacekitApp...");
383
+ await this.federate.stop({
384
+ resignTimeoutMs: options.resignTimeoutMs,
385
+ disconnectTimeoutMs: options.disconnectTimeoutMs,
386
+ onError: (phase, error) => {
387
+ this.logger.warn("Shutdown step failed.", { phase, error });
388
+ },
389
+ });
390
+ this.logger.info("Disconnected.");
391
+ this._shutdownInProgress = false;
392
+ }
393
+
394
+ requestShutdown(reason?: string): void {
395
+ if (this._shutdownRequested) return;
396
+ this._shutdownRequested = true;
397
+ this.logger.info("Shutdown requested.", { reason });
398
+ }
399
+
400
+ enableGracefulShutdown(options: {
401
+ signals?: NodeJS.Signals[];
402
+ exitOnSecondSignal?: boolean;
403
+ } = {}): () => void {
404
+ if (this._gracefulShutdownEnabled) {
405
+ return () => {};
406
+ }
407
+ this._gracefulShutdownEnabled = true;
408
+ const signals = options.signals ?? ["SIGINT", "SIGTERM"];
409
+ const exitOnSecondSignal = options.exitOnSecondSignal ?? true;
410
+ let signalCount = 0;
411
+ const handler = (): void => {
412
+ signalCount += 1;
413
+ const reason = signalCount === 1 ? "signal" : "signal (forced)";
414
+ this.requestShutdown(reason);
415
+ void this.stop().catch((err) => {
416
+ this.logger.error("Shutdown error.", { error: err });
417
+ });
418
+ if (exitOnSecondSignal && signalCount > 1) {
419
+ process.exit(1);
420
+ }
421
+ };
422
+
423
+ for (const signal of signals) {
424
+ process.on(signal, handler);
425
+ this._shutdownHandlers.push({ signal, handler });
426
+ }
427
+
428
+ return () => {
429
+ for (const { signal, handler: registered } of this._shutdownHandlers) {
430
+ process.off(signal, registered);
431
+ }
432
+ this._shutdownHandlers.length = 0;
433
+ this._gracefulShutdownEnabled = false;
434
+ };
435
+ }
436
+
437
+ async exportFomXml(): Promise<{ path: string; xml: string }> {
438
+ const config = this.federateConfig.fomExport;
439
+ if (config?.enabled === false) {
440
+ return { path: "", xml: "" };
441
+ }
442
+ const format = config?.format ?? "fdd";
443
+ const outputPath = this._resolveFomExportPath(config?.path);
444
+ const xml = this._buildFomXml({
445
+ format,
446
+ modelIdentification: config?.modelIdentification,
447
+ indent: config?.indent,
448
+ inferParentStubs: config?.inferParentStubs,
449
+ });
450
+
451
+ await fs.mkdir(path.dirname(outputPath), { recursive: true });
452
+ await fs.writeFile(outputPath, xml, "utf8");
453
+ this.logger.info("FOM XML exported.", { path: outputPath, format });
454
+ return { path: outputPath, xml };
455
+ }
456
+
457
+ async reserveObjectInstanceName(name: string, timeoutMs = 10000): Promise<void> {
458
+ if (this._pendingReservations.has(name)) {
459
+ throw new Error(`Reservation already pending: ${name}`);
460
+ }
461
+ const rti = this._requireRti();
462
+ this.logger.info("Reserving object instance name...", { name });
463
+ await new Promise<void>((resolve, reject) => {
464
+ const timeout =
465
+ timeoutMs > 0
466
+ ? setTimeout(() => {
467
+ this._pendingReservations.delete(name);
468
+ reject(new Error(`Name reservation timed out: ${name}`));
469
+ }, timeoutMs)
470
+ : undefined;
471
+ this._pendingReservations.set(name, { resolve, reject, timeout });
472
+ rti.reserveObjectInstanceName(name).catch((err) => {
473
+ if (timeout) clearTimeout(timeout);
474
+ this._pendingReservations.delete(name);
475
+ reject(err instanceof Error ? err : new Error(String(err)));
476
+ });
477
+ });
478
+ }
479
+
480
+ async registerObject<T extends object>(
481
+ adapter: ObjectClassAdapter<T>,
482
+ options: RegisterObjectOptions = {}
483
+ ): Promise<ObjectInstanceAdapter<T>> {
484
+ const rti = this._requireRti();
485
+ if (options.reserveName && options.name) {
486
+ await this.reserveObjectInstanceName(options.name, options.reservationTimeoutMs);
487
+ }
488
+ if (!adapter.isResolved) {
489
+ await adapter.resolve(rti);
490
+ }
491
+ const instance = await adapter.registerInstance(rti, options.name);
492
+ this.logger.info("Registered object instance.", {
493
+ className: adapter.className,
494
+ name: options.name ?? instance.objectInstanceName ?? "(unnamed)",
495
+ });
496
+ return instance;
497
+ }
498
+
499
+ async registerEntity<T extends object>(
500
+ entity: SpacekitEntity<T>,
501
+ options: RegisterObjectOptions = {}
502
+ ): Promise<ObjectInstanceAdapter<T>> {
503
+ const existing = getEntityBinding(entity);
504
+ if (existing) {
505
+ return existing.instance as ObjectInstanceAdapter<T>;
506
+ }
507
+ this._ensureObjectAdapterFromEntity(entity);
508
+ if (!this.rtiAmbassador || !this._runtimePrepared) {
509
+ return new Promise<ObjectInstanceAdapter<T>>((resolve, reject) => {
510
+ this._pendingEntities.push({
511
+ entity: entity as unknown as SpacekitEntity<object>,
512
+ options,
513
+ resolve: resolve as (instance: ObjectInstanceAdapter<object>) => void,
514
+ reject,
515
+ });
516
+ });
517
+ }
518
+ return this._registerEntityNow(entity, options);
519
+ }
520
+
521
+ async registerEntities<T extends object>(
522
+ entities: Array<SpacekitEntity<T>>,
523
+ options: RegisterObjectOptions = {}
524
+ ): Promise<Array<ObjectInstanceAdapter<T>>> {
525
+ const results: Array<ObjectInstanceAdapter<T>> = [];
526
+ for (const entity of entities) {
527
+ results.push(await this.registerEntity(entity, options));
528
+ }
529
+ return results;
530
+ }
531
+
532
+ registerInteractionClass<T extends object>(ctor: FomClassConstructor): InteractionClassAdapter<T> {
533
+ const adapter = this._ensureInteractionAdapterFromClass<T>(ctor);
534
+ if (this._runtimePrepared && this.rtiAmbassador) {
535
+ void this._prepareInteractionAdapter(adapter);
536
+ }
537
+ return adapter;
538
+ }
539
+
540
+ registerInteractionClasses(ctors: FomClassConstructor[]): InteractionClassAdapter<object>[] {
541
+ return ctors.map((ctor) => this.registerInteractionClass<object>(ctor));
542
+ }
543
+
544
+ registerObjectClass<T extends object>(ctor: FomClassConstructor): ObjectClassAdapter<T> {
545
+ const adapter = this._ensureObjectAdapterFromClass<T>(ctor);
546
+ if (this._runtimePrepared && this.rtiAmbassador) {
547
+ void this._prepareObjectAdapter(adapter);
548
+ }
549
+ return adapter;
550
+ }
551
+
552
+ registerObjectClasses(ctors: FomClassConstructor[]): ObjectClassAdapter<object>[] {
553
+ return ctors.map((ctor) => this.registerObjectClass<object>(ctor));
554
+ }
555
+
556
+ async register(...items: Array<SpacekitEntity<object> | FomClassConstructor>): Promise<void> {
557
+ for (const item of items) {
558
+ if (typeof item === "function") {
559
+ try {
560
+ this.registerObjectClass(item);
561
+ continue;
562
+ } catch {}
563
+ try {
564
+ this.registerInteractionClass(item);
565
+ continue;
566
+ } catch {}
567
+ throw new Error(`Unable to register class without schema metadata: ${item.name}`);
568
+ }
569
+ if (!this._runtimePrepared) {
570
+ void this.registerEntity(item);
571
+ continue;
572
+ }
573
+ await this.registerEntity(item);
574
+ }
575
+ }
576
+
577
+ async sendInteraction<T extends object>(
578
+ adapter: InteractionClassAdapter<T>,
579
+ values: Partial<T>,
580
+ userSuppliedTag: Uint8Array = new Uint8Array(),
581
+ time?: LogicalTime
582
+ ): Promise<void> {
583
+ const rti = this._requireRti();
584
+ if (!adapter.isResolved) {
585
+ await adapter.resolve(rti);
586
+ }
587
+ await adapter.send(rti, values, userSuppliedTag, time);
588
+ if (this._debugEnabled) {
589
+ this.logger.debug("Interaction sent.", { className: adapter.className, values, time });
590
+ }
591
+ }
592
+
593
+ async send<T extends object>(
594
+ target: InteractionClassAdapter<T> | FomClassConstructor,
595
+ values: Partial<T>,
596
+ userSuppliedTag: Uint8Array = new Uint8Array(),
597
+ time?: LogicalTime
598
+ ): Promise<void> {
599
+ const adapter =
600
+ target instanceof InteractionClassAdapter
601
+ ? target
602
+ : this.registerInteractionClass<T>(target);
603
+ await this.sendInteraction(adapter, values, userSuppliedTag, time);
604
+ }
605
+
606
+ async reply<T extends object>(
607
+ target: InteractionClassAdapter<T> | FomClassConstructor,
608
+ values: Partial<T>,
609
+ options: { tag?: Uint8Array; time?: LogicalTime } = {}
610
+ ): Promise<void> {
611
+ await this.send(target, values, options.tag ?? new Uint8Array(), options.time);
612
+ }
613
+
614
+ async respond<T extends object>(
615
+ target: InteractionClassAdapter<T> | FomClassConstructor,
616
+ values: Partial<T>,
617
+ options: { tag?: Uint8Array; time?: LogicalTime } = {}
618
+ ): Promise<void> {
619
+ await this.reply(target, values, options);
620
+ }
621
+
622
+ async updateObject<T extends object>(
623
+ instance: ObjectInstanceAdapter<T>,
624
+ values: Partial<T>,
625
+ userSuppliedTag: Uint8Array = new Uint8Array(),
626
+ time?: LogicalTime
627
+ ): Promise<void> {
628
+ const rti = this._requireRti();
629
+ await instance.updateAttributes(rti, values, userSuppliedTag, time);
630
+ if (this._debugEnabled) {
631
+ this.logger.debug("Object updated.", {
632
+ className: instance.className,
633
+ instanceName: instance.objectInstanceName ?? "(unnamed)",
634
+ values,
635
+ time,
636
+ });
637
+ }
638
+ }
639
+
640
+ async syncEntity<T extends object>(
641
+ entity: SpacekitEntity<T>,
642
+ time?: LogicalTime,
643
+ userSuppliedTag: Uint8Array = new Uint8Array()
644
+ ): Promise<void> {
645
+ const binding = getEntityBinding(entity);
646
+ if (!binding) {
647
+ throw new Error("Entity is not registered with this SpacekitApp.");
648
+ }
649
+ const changes = consumeEntityChanges(entity);
650
+ if (Object.keys(changes).length === 0) {
651
+ return;
652
+ }
653
+ await this.updateObject(binding.instance, changes, userSuppliedTag, time);
654
+ }
655
+
656
+ async sync(
657
+ entities?: SpacekitEntity<object> | Array<SpacekitEntity<object>>,
658
+ time?: LogicalTime,
659
+ userSuppliedTag: Uint8Array = new Uint8Array()
660
+ ): Promise<void> {
661
+ if (!entities) {
662
+ for (const entity of this._entities) {
663
+ await this.syncEntity(entity, time, userSuppliedTag);
664
+ }
665
+ return;
666
+ }
667
+ if (Array.isArray(entities)) {
668
+ for (const entity of entities) {
669
+ await this.syncEntity(entity, time, userSuppliedTag);
670
+ }
671
+ return;
672
+ }
673
+ await this.syncEntity(entities, time, userSuppliedTag);
674
+ }
675
+
676
+ async deleteObject<T extends object>(
677
+ instance: ObjectInstanceAdapter<T>,
678
+ userSuppliedTag: Uint8Array = new Uint8Array(),
679
+ time?: LogicalTime
680
+ ): Promise<void> {
681
+ const rti = this._requireRti();
682
+ await instance.deleteInstance(rti, userSuppliedTag, time);
683
+ this.logger.info("Deleted object instance.", {
684
+ className: instance.className,
685
+ instanceName: instance.objectInstanceName ?? "(unnamed)",
686
+ });
687
+ }
688
+
689
+ async advanceTo(timeMicros: bigint): Promise<bigint> {
690
+ const rti = this._requireRti();
691
+ if (this._debugEnabled) {
692
+ this.logger.debug("Requesting time advance.", { timeMicros: timeMicros.toString() });
693
+ }
694
+ const result = await this.timeController.advanceTo(rti, timeMicros);
695
+ if (this._debugEnabled) {
696
+ this.logger.debug("Time advance grant received.", { timeMicros: result.toString() });
697
+ }
698
+ return result;
699
+ }
700
+
701
+ async queryLogicalTimeMicros(): Promise<bigint> {
702
+ const rti = this._requireRti();
703
+ const logicalTime = await rti.queryLogicalTime();
704
+ return decodeHLAinteger64Time(logicalTime);
705
+ }
706
+
707
+ addHandlers(handlers: FederateHandlers): void {
708
+ this.federate.addHandlers(handlers);
709
+ }
710
+
711
+ protected async beforeRegisterEntity<T extends object>(_entity: SpacekitEntity<T>): Promise<void> {
712
+ return;
713
+ }
714
+
715
+ protected shouldRegisterClassInFom(_ctor: Function, _kind: "object" | "interaction"): boolean {
716
+ return true;
717
+ }
718
+
719
+ private get federateConfig(): SpacekitAppConfig {
720
+ return this._appConfig;
721
+ }
722
+
723
+ private _requireRti(): RTIAmbassador {
724
+ const rti = this.rtiAmbassador;
725
+ if (!rti) {
726
+ throw new Error("SpacekitApp is not started yet.");
727
+ }
728
+ return rti;
729
+ }
730
+
731
+ private async _flushPendingEntities(): Promise<void> {
732
+ if (this._pendingEntities.length === 0) return;
733
+ const pending = [...this._pendingEntities];
734
+ this._pendingEntities.length = 0;
735
+ for (const item of pending) {
736
+ try {
737
+ const instance = await this._registerEntityNow(item.entity, item.options);
738
+ item.resolve(instance);
739
+ } catch (err) {
740
+ item.reject(err instanceof Error ? err : new Error(String(err)));
741
+ }
742
+ }
743
+ }
744
+
745
+ private async _registerEntityNow<T extends object>(
746
+ entity: SpacekitEntity<T>,
747
+ options: RegisterObjectOptions = {}
748
+ ): Promise<ObjectInstanceAdapter<T>> {
749
+ await this.beforeRegisterEntity(entity);
750
+ const adapter = this._ensureObjectAdapterFromEntity(entity);
751
+ await this._prepareObjectAdapter(adapter);
752
+
753
+ const resolvedOptions = { ...options };
754
+ if (!resolvedOptions.name) {
755
+ const named = (entity as { name?: string }).name;
756
+ if (typeof named === "string" && named.length > 0) {
757
+ resolvedOptions.name = named;
758
+ }
759
+ }
760
+ if (resolvedOptions.reserveName === undefined && resolvedOptions.name) {
761
+ resolvedOptions.reserveName = true;
762
+ }
763
+
764
+ const instance = await this.registerObject(adapter, resolvedOptions);
765
+ bindEntity(entity, { app: this, adapter, instance });
766
+ this._entities.add(entity as unknown as SpacekitEntity<object>);
767
+ return instance;
768
+ }
769
+
770
+ private _ensureObjectAdapterFromEntity<T extends object>(
771
+ entity: SpacekitEntity<T>
772
+ ): ObjectClassAdapter<T> {
773
+ const ctor = entity.constructor as FomClassConstructor;
774
+ return this._ensureObjectAdapterFromClass(ctor);
775
+ }
776
+
777
+ private _ensureObjectAdapterFromClass<T extends object>(
778
+ ctor: FomClassConstructor
779
+ ): ObjectClassAdapter<T> {
780
+ const className = this._resolveObjectClassName(ctor);
781
+ if (!className) {
782
+ throw new Error(`Unable to resolve object class name for ${ctor.name}.`);
783
+ }
784
+ let adapter = this.declarative.getObjectAdapter<T>(className);
785
+ if (!adapter) {
786
+ const shouldRegister = this.shouldRegisterClassInFom(ctor, "object");
787
+ if (getDecoratedObjectClassMetadata(ctor)) {
788
+ registerDecoratedClasses(this.declarative, {
789
+ objectClasses: [ctor],
790
+ register: shouldRegister,
791
+ });
792
+ } else if (typeof (ctor as ObjectSchemaProvider<T>).schema === "function") {
793
+ const schema = (ctor as ObjectSchemaProvider<T>).schema!();
794
+ adapter = this.declarative.defineObjectClass(schema, { register: shouldRegister });
795
+ } else {
796
+ throw new Error(`Missing schema metadata for ${ctor.name}.`);
797
+ }
798
+ adapter = this.declarative.getObjectAdapter<T>(className);
799
+ }
800
+ if (!adapter) {
801
+ throw new Error(`Object adapter not found for ${className}.`);
802
+ }
803
+ return adapter;
804
+ }
805
+
806
+ private _ensureInteractionAdapterFromClass<T extends object>(
807
+ ctor: FomClassConstructor
808
+ ): InteractionClassAdapter<T> {
809
+ const className = this._resolveInteractionClassName(ctor);
810
+ if (!className) {
811
+ throw new Error(`Unable to resolve interaction class name for ${ctor.name}.`);
812
+ }
813
+ let adapter = this.declarative.getInteractionAdapter<T>(className);
814
+ if (!adapter) {
815
+ const shouldRegister = this.shouldRegisterClassInFom(ctor, "interaction");
816
+ if (getDecoratedInteractionClassMetadata(ctor)) {
817
+ registerDecoratedClasses(this.declarative, {
818
+ interactionClasses: [ctor],
819
+ register: shouldRegister,
820
+ });
821
+ } else if (typeof (ctor as InteractionSchemaProvider<T>).schema === "function") {
822
+ const schema = (ctor as InteractionSchemaProvider<T>).schema!();
823
+ adapter = this.declarative.defineInteractionClass(schema, { register: shouldRegister });
824
+ } else {
825
+ throw new Error(`Missing schema metadata for ${ctor.name}.`);
826
+ }
827
+ adapter = this.declarative.getInteractionAdapter<T>(className);
828
+ }
829
+ if (!adapter) {
830
+ throw new Error(`Interaction adapter not found for ${className}.`);
831
+ }
832
+ return adapter;
833
+ }
834
+
835
+ private async _prepareObjectAdapter<T extends object>(
836
+ adapter: ObjectClassAdapter<T>
837
+ ): Promise<void> {
838
+ if (!this._runtimePrepared || !this.rtiAmbassador) return;
839
+ if (!adapter.isResolved) {
840
+ await adapter.resolve(this.rtiAmbassador);
841
+ }
842
+ const schema = this.declarative.getObjectSchema<T>(adapter.className);
843
+ if (!schema?.sharing) return;
844
+ if (shouldPublish(schema.sharing)) {
845
+ await adapter.publish(this.rtiAmbassador);
846
+ }
847
+ if (shouldSubscribe(schema.sharing)) {
848
+ await adapter.subscribe(this.rtiAmbassador);
849
+ }
850
+ }
851
+
852
+ private async _prepareInteractionAdapter<T extends object>(
853
+ adapter: InteractionClassAdapter<T>
854
+ ): Promise<void> {
855
+ if (!this._runtimePrepared || !this.rtiAmbassador) return;
856
+ if (!adapter.isResolved) {
857
+ await adapter.resolve(this.rtiAmbassador);
858
+ }
859
+ const schema = this.declarative.getInteractionSchema<T>(adapter.className);
860
+ if (!schema?.sharing) return;
861
+ if (shouldPublish(schema.sharing)) {
862
+ await adapter.publish(this.rtiAmbassador);
863
+ }
864
+ if (shouldSubscribe(schema.sharing)) {
865
+ await adapter.subscribe(this.rtiAmbassador);
866
+ }
867
+ }
868
+
869
+ private _resolveObjectClassName(ctor: Function): string | undefined {
870
+ const direct =
871
+ (ctor as { className?: string }).className ??
872
+ getDecoratedObjectClassName(ctor) ??
873
+ getDecoratedObjectClassMetadata(ctor)?.name;
874
+ if (direct) return direct;
875
+ const provider = ctor as ObjectSchemaProvider<object>;
876
+ if (typeof provider.schema === "function") {
877
+ return provider.schema().name;
878
+ }
879
+ return undefined;
880
+ }
881
+
882
+ private _resolveInteractionClassName(ctor: Function): string | undefined {
883
+ const direct =
884
+ (ctor as { className?: string }).className ??
885
+ getDecoratedInteractionClassName(ctor) ??
886
+ getDecoratedInteractionClassMetadata(ctor)?.name;
887
+ if (direct) return direct;
888
+ const provider = ctor as InteractionSchemaProvider<object>;
889
+ if (typeof provider.schema === "function") {
890
+ return provider.schema().name;
891
+ }
892
+ return undefined;
893
+ }
894
+
895
+ private _createReservationHandlers(): FederateHandlers {
896
+ return {
897
+ objectInstanceNameReservationSucceeded: (name) => {
898
+ const pending = this._pendingReservations.get(name);
899
+ if (!pending) return;
900
+ if (pending.timeout) clearTimeout(pending.timeout);
901
+ pending.resolve();
902
+ this._pendingReservations.delete(name);
903
+ this.logger.info("Object instance name reserved.", { name });
904
+ },
905
+ objectInstanceNameReservationFailed: (name) => {
906
+ const pending = this._pendingReservations.get(name);
907
+ if (!pending) return;
908
+ if (pending.timeout) clearTimeout(pending.timeout);
909
+ pending.reject(new Error(`Object instance name reservation failed: ${name}`));
910
+ this._pendingReservations.delete(name);
911
+ this.logger.error("Object instance name reservation failed.", { name });
912
+ },
913
+ };
914
+ }
915
+
916
+ private _logPublicationSummary(summary: DeclarativePublicationSummary): void {
917
+ this.logger.info("Publish/subscribe configured.", {
918
+ objectPublish: summary.objectClasses.publish,
919
+ objectSubscribe: summary.objectClasses.subscribe,
920
+ interactionPublish: summary.interactions.publish,
921
+ interactionSubscribe: summary.interactions.subscribe,
922
+ });
923
+ }
924
+
925
+ private _resolveFomExportPath(override?: string): string {
926
+ if (override) return path.resolve(override);
927
+ const federateType = this.federateConfig.federate.federateType;
928
+ return path.resolve("fom", `${federateType}.xml`);
929
+ }
930
+
931
+ private _buildFomXml(options: {
932
+ format: FomOutputFormat;
933
+ modelIdentification?: ModelIdentification;
934
+ indent?: string;
935
+ inferParentStubs?: boolean;
936
+ }): string {
937
+ const extension = this.federateConfig.federate.fom?.extension;
938
+ if (extension?.xml) {
939
+ return extension.xml;
940
+ }
941
+ if (extension?.registry) {
942
+ return this._registryToXml(extension.registry, options);
943
+ }
944
+ if (extension?.model) {
945
+ return this._modelToXml(extension.model, options);
946
+ }
947
+ return this._registryToXml(this.declarative.registry, options);
948
+ }
949
+
950
+ private _registryToXml(
951
+ registry: import("@hla4ts/fom-codegen").FomRegistry,
952
+ options: {
953
+ format: FomOutputFormat;
954
+ modelIdentification?: ModelIdentification;
955
+ indent?: string;
956
+ inferParentStubs?: boolean;
957
+ }
958
+ ): string {
959
+ const outputOptions: FomOutputOptions = {
960
+ format: options.format,
961
+ indent: options.indent,
962
+ inferParentStubs: options.inferParentStubs,
963
+ };
964
+ if (options.format === "omt" && options.modelIdentification) {
965
+ registry.setModelIdentification(options.modelIdentification);
966
+ }
967
+ if (options.format === "omt" && !options.modelIdentification) {
968
+ registry.setModelIdentification(this._defaultModelIdentification());
969
+ }
970
+ return registry.toXml(outputOptions);
971
+ }
972
+
973
+ private _modelToXml(
974
+ model: FomModel,
975
+ options: {
976
+ format: FomOutputFormat;
977
+ modelIdentification?: ModelIdentification;
978
+ indent?: string;
979
+ inferParentStubs?: boolean;
980
+ }
981
+ ): string {
982
+ const outputOptions: FomOutputOptions = {
983
+ format: options.format,
984
+ indent: options.indent,
985
+ inferParentStubs: options.inferParentStubs,
986
+ };
987
+ if (options.format === "omt") {
988
+ const modelIdentification = options.modelIdentification ?? this._defaultModelIdentification();
989
+ const merged: FomModel = { ...model, modelIdentification };
990
+ return renderFomXml(merged, outputOptions);
991
+ }
992
+ return renderFomXml(model, outputOptions);
993
+ }
994
+
995
+ private _defaultModelIdentification(): ModelIdentification {
996
+ const federateType = this.federateConfig.federate.federateType;
997
+ const now = new Date();
998
+ const y = now.getUTCFullYear();
999
+ const m = String(now.getUTCMonth() + 1).padStart(2, "0");
1000
+ const d = String(now.getUTCDate()).padStart(2, "0");
1001
+ return {
1002
+ name: `${federateType}-extension`,
1003
+ type: "FOM",
1004
+ version: "1.0.0",
1005
+ modificationDate: `${y}-${m}-${d}`,
1006
+ securityClassification: "U",
1007
+ description: "Generated by @hla4ts/spacekit",
1008
+ };
1009
+ }
1010
+ }
1011
+
1012
+ function shouldPublish(sharing: SharingType): boolean {
1013
+ return sharing === "Publish" || sharing === "PublishSubscribe";
1014
+ }
1015
+
1016
+ function shouldSubscribe(sharing: SharingType): boolean {
1017
+ return sharing === "Subscribe" || sharing === "PublishSubscribe";
1018
+ }