@fraqjs/fraq 0.5.1 → 0.6.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/dist/index.d.mts CHANGED
@@ -5620,6 +5620,10 @@ declare function combineLogHandlers(...handlers: LogHandler[]): LogHandler;
5620
5620
  //#region src/core/service.d.ts
5621
5621
  /** biome-ignore-all lint/suspicious/noExplicitAny: Service constructors may accept any arguments. */
5622
5622
  type ServiceClass<T extends object = object> = abstract new (...args: any[]) => T;
5623
+ interface Disposable {
5624
+ dispose(): void | Promise<void>;
5625
+ }
5626
+ declare function isDisposable(service: object): service is Disposable;
5623
5627
  //#endregion
5624
5628
  //#region src/core/plugin.d.ts
5625
5629
  type ParameterList = Array<any>;
@@ -5671,12 +5675,19 @@ declare class Context {
5671
5675
  private readonly plugins;
5672
5676
  private readonly services;
5673
5677
  private readonly subContexts;
5678
+ private readonly parentEventForwarder?;
5674
5679
  private readonly initialReconnectDelayMs;
5675
5680
  private readonly maxReconnectDelayMs;
5676
5681
  private readonly logHandler?;
5677
- private isStarted;
5682
+ private state;
5683
+ private startPromise?;
5684
+ private stopPromise?;
5685
+ private eventSubscription?;
5686
+ private eventStreamTask?;
5687
+ private reconnectTimer?;
5688
+ private resolveReconnectTimer?;
5678
5689
  private constructor();
5679
- on<K extends keyof EventMap>(type: K, handler: (event: EventMap[K]) => void | Promise<void>): void;
5690
+ on<K extends keyof EventMap>(type: K, handler: (event: EventMap[K]) => void | Promise<void>): () => void;
5680
5691
  install<T extends ParameterList, I extends Injection | undefined, OI extends Injection | undefined>(
5681
5692
  plugin: Plugin<T, I, OI>,
5682
5693
  ...args: T
@@ -5688,6 +5699,11 @@ declare class Context {
5688
5699
  fork(name: string, filter?: Filter): Context;
5689
5700
  createSession(message: IncomingMessage): Session;
5690
5701
  start(): Promise<void>;
5702
+ stop(): Promise<void>;
5703
+ private startInternal;
5704
+ private getState;
5705
+ private stopInternal;
5706
+ private stopEventStream;
5691
5707
  private acceptsParentEvent;
5692
5708
  private applyPlugins;
5693
5709
  private sortPlugins;
@@ -5698,6 +5714,8 @@ declare class Context {
5698
5714
  private createUnresolvablePluginError;
5699
5715
  private createProxyContextForPlugin;
5700
5716
  private runEventStream;
5717
+ private waitForReconnectDelay;
5718
+ private resolveReconnectDelay;
5701
5719
  static fromUrl(baseUrl: string | URL, options?: ContextOptions & ContextUrlOptions): Context;
5702
5720
  static fromClient(client: MilkyClient, options?: ContextOptions): Context;
5703
5721
  }
@@ -5748,6 +5766,7 @@ export {
5748
5766
  Context,
5749
5767
  ContextOptions,
5750
5768
  ContextUrlOptions,
5769
+ Disposable,
5751
5770
  EventMap,
5752
5771
  Filter,
5753
5772
  Handler,
@@ -5768,13 +5787,14 @@ export {
5768
5787
  RouteEntry,
5769
5788
  RouteMatchResult,
5770
5789
  Router,
5771
- type ServiceClass,
5790
+ ServiceClass,
5772
5791
  Session,
5773
5792
  SessionPredicate,
5774
5793
  combineLogHandlers,
5775
5794
  createMilkyClient,
5776
5795
  definePlugin,
5777
5796
  filter,
5797
+ isDisposable,
5778
5798
  type types_d_exports as milky,
5779
5799
  milkyPackageVersion,
5780
5800
  milkyVersion,
package/dist/index.mjs CHANGED
@@ -360,8 +360,8 @@ function combineLogHandlers(...handlers) {
360
360
  }
361
361
  //#endregion
362
362
  //#region src/core/service.ts
363
- function getServiceName(service) {
364
- return service.name || "<anonymous service>";
363
+ function isDisposable(service) {
364
+ return "dispose" in service && typeof service.dispose === "function";
365
365
  }
366
366
  //#endregion
367
367
  //#region src/core/context.ts
@@ -378,10 +378,17 @@ var Context = class Context {
378
378
  plugins = [];
379
379
  services = /* @__PURE__ */ new Map();
380
380
  subContexts = [];
381
+ parentEventForwarder;
381
382
  initialReconnectDelayMs;
382
383
  maxReconnectDelayMs;
383
384
  logHandler;
384
- isStarted = false;
385
+ state = "idle";
386
+ startPromise;
387
+ stopPromise;
388
+ eventSubscription;
389
+ eventStreamTask;
390
+ reconnectTimer;
391
+ resolveReconnectTimer;
385
392
  constructor(client, options, name, parent, filter) {
386
393
  this.client = client;
387
394
  this.initialReconnectDelayMs = options?.reconnect?.initialDelayMs ?? DEFAULT_INITIAL_RECONNECT_DELAY_MS;
@@ -391,12 +398,16 @@ var Context = class Context {
391
398
  this.logger = new Logger((message) => this.logHandler?.(message), this.name);
392
399
  this.parent = parent;
393
400
  this.filter = filter;
394
- parent?.eventBus.on("*", (type, event) => {
395
- if (!this.acceptsParentEvent(type, event)) return;
396
- this.eventBus.emit(type, event);
397
- });
401
+ if (parent) {
402
+ this.parentEventForwarder = (type, event) => {
403
+ if (!this.acceptsParentEvent(type, event)) return;
404
+ this.eventBus.emit(type, event);
405
+ };
406
+ parent.eventBus.on("*", this.parentEventForwarder);
407
+ }
398
408
  this.eventBus.on("message_receive", async ({ data: message }) => {
399
409
  try {
410
+ if (this.state === "stopping" || this.state === "stopped") return;
400
411
  await this.router.dispatch(this.createSession(message), message);
401
412
  } catch (error) {
402
413
  this.logger.error(`Error routing command (scene=${message.message_scene} peer=${message.peer_id} sender=${message.sender_id} seq=${message.message_seq})`, error);
@@ -404,13 +415,18 @@ var Context = class Context {
404
415
  });
405
416
  }
406
417
  on(type, handler) {
407
- this.eventBus.on(type, async (event) => {
418
+ const wrappedHandler = async (event) => {
408
419
  try {
420
+ if (this.state === "stopping" || this.state === "stopped") return;
409
421
  await handler(event);
410
422
  } catch (error) {
411
423
  this.logger.error(`Error handling event ${type}`, error);
412
424
  }
413
- });
425
+ };
426
+ this.eventBus.on(type, wrappedHandler);
427
+ return () => {
428
+ this.eventBus.off(type, wrappedHandler);
429
+ };
414
430
  }
415
431
  install(plugin, ...args) {
416
432
  this.plugins.push({
@@ -419,12 +435,12 @@ var Context = class Context {
419
435
  });
420
436
  }
421
437
  provide(service, instance) {
422
- if (this.services.has(service)) throw new Error(`Service ${getServiceName(service)} has already been provided in this context.`);
438
+ if (this.services.has(service)) throw new Error(`Service ${service.name} has already been provided in this context.`);
423
439
  this.services.set(service, instance);
424
440
  }
425
441
  resolve(service) {
426
442
  const instance = this.tryResolve(service);
427
- if (instance === void 0) throw new Error(`Service ${getServiceName(service)} has not been provided.`);
443
+ if (instance === void 0) throw new Error(`Service ${service.name} has not been provided.`);
428
444
  return instance;
429
445
  }
430
446
  tryResolve(service) {
@@ -465,11 +481,88 @@ var Context = class Context {
465
481
  };
466
482
  }
467
483
  async start() {
468
- if (this.isStarted) return;
469
- await this.applyPlugins();
484
+ if (this.state === "started") return;
485
+ if (this.state === "starting") {
486
+ await this.startPromise;
487
+ return;
488
+ }
489
+ if (this.state === "stopping") throw new Error(`Context "${this.name}" cannot be started while it is stopping.`);
490
+ if (this.state === "stopped") throw new Error(`Context "${this.name}" cannot be restarted after it has been stopped.`);
491
+ this.state = "starting";
492
+ this.startPromise = this.startInternal();
493
+ try {
494
+ await this.startPromise;
495
+ } finally {
496
+ this.startPromise = void 0;
497
+ }
498
+ }
499
+ async stop() {
500
+ if (this.state === "idle" || this.state === "stopped") return;
501
+ if (this.state === "starting") await this.startPromise;
502
+ const stateAfterStart = this.getState();
503
+ if (stateAfterStart === "idle" || stateAfterStart === "stopped") return;
504
+ if (stateAfterStart === "stopping") {
505
+ await this.stopPromise;
506
+ return;
507
+ }
508
+ this.state = "stopping";
509
+ this.stopPromise = this.stopInternal();
510
+ try {
511
+ await this.stopPromise;
512
+ } finally {
513
+ this.state = "stopped";
514
+ this.stopPromise = void 0;
515
+ }
516
+ }
517
+ async startInternal() {
518
+ try {
519
+ await this.applyPlugins();
520
+ } catch (error) {
521
+ this.state = "idle";
522
+ throw error;
523
+ }
470
524
  for (const subContext of this.subContexts) await subContext.start();
471
- this.isStarted = true;
472
- if (!this.parent) this.runEventStream();
525
+ this.state = "started";
526
+ if (!this.parent) this.eventStreamTask = this.runEventStream();
527
+ }
528
+ getState() {
529
+ return this.state;
530
+ }
531
+ async stopInternal() {
532
+ const errors = [];
533
+ await this.stopEventStream(errors);
534
+ for (const subContext of [...this.subContexts].reverse()) try {
535
+ await subContext.stop();
536
+ } catch (error) {
537
+ errors.push(error);
538
+ }
539
+ if (this.parent && this.parentEventForwarder) this.parent.eventBus.off("*", this.parentEventForwarder);
540
+ for (const service of [...this.services.values()].reverse()) {
541
+ if (!isDisposable(service)) continue;
542
+ try {
543
+ await service.dispose();
544
+ } catch (error) {
545
+ errors.push(error);
546
+ }
547
+ }
548
+ if (errors.length === 1) throw errors[0];
549
+ if (errors.length > 1) throw new AggregateError(errors, `Context "${this.name}" failed to stop cleanly.`);
550
+ }
551
+ async stopEventStream(errors) {
552
+ this.resolveReconnectDelay();
553
+ const subscription = this.eventSubscription;
554
+ if (subscription) try {
555
+ await subscription.stop();
556
+ } catch (error) {
557
+ errors.push(error);
558
+ }
559
+ if (this.eventStreamTask) try {
560
+ await this.eventStreamTask;
561
+ } catch (error) {
562
+ errors.push(error);
563
+ } finally {
564
+ this.eventStreamTask = void 0;
565
+ }
473
566
  }
474
567
  acceptsParentEvent(type, event) {
475
568
  if (!this.filter) return true;
@@ -481,7 +574,7 @@ var Context = class Context {
481
574
  const providedBeforeApply = new Set(this.services.keys());
482
575
  this.logger.info(`Applying plugin ${plugin.name}`);
483
576
  await plugin.apply(this.createProxyContextForPlugin(plugin), ...args);
484
- for (const service of plugin.provides ?? []) if (!this.services.has(service) || providedBeforeApply.has(service)) throw new Error(`${plugin.name} declares service ${getServiceName(service)} but did not provide it.`);
577
+ for (const service of plugin.provides ?? []) if (!this.services.has(service) || providedBeforeApply.has(service)) throw new Error(`${plugin.name} declares service ${service.name} but did not provide it.`);
485
578
  this.logger.debug(`Applied plugin ${plugin.name}`);
486
579
  }
487
580
  }
@@ -489,7 +582,7 @@ var Context = class Context {
489
582
  const providers = /* @__PURE__ */ new Map();
490
583
  for (const { plugin } of this.plugins) for (const service of plugin.provides ?? []) {
491
584
  const existingProvider = providers.get(service);
492
- if (existingProvider) throw new Error(`Service ${getServiceName(service)} is declared by multiple plugins: ${existingProvider.name} and ${plugin.name}.`);
585
+ if (existingProvider) throw new Error(`Service ${service.name} is declared by multiple plugins: ${existingProvider.name} and ${plugin.name}.`);
493
586
  providers.set(service, plugin);
494
587
  }
495
588
  const pending = [...this.plugins];
@@ -540,7 +633,7 @@ var Context = class Context {
540
633
  const lines = [...missingRequirements].map(([service, plugins]) => {
541
634
  const dependents = plugins.map((plugin) => plugin.name).join(", ");
542
635
  const reason = pendingProviders.has(service) ? "blocked by a dependency cycle" : "no installed plugin provides it";
543
- return `${getServiceName(service)} required by ${dependents} (${reason})`;
636
+ return `${service.name} required by ${dependents} (${reason})`;
544
637
  });
545
638
  return /* @__PURE__ */ new Error(`Unable to resolve plugin service dependencies: ${lines.join("; ")}.`);
546
639
  }
@@ -564,29 +657,58 @@ var Context = class Context {
564
657
  async runEventStream() {
565
658
  let reconnectDelay = this.initialReconnectDelayMs;
566
659
  let reconnectAttempt = 1;
567
- while (this.isStarted) {
660
+ while (this.state === "started") {
568
661
  try {
569
662
  this.logger.debug(`Connecting event stream (attempt=${reconnectAttempt})`);
570
663
  const subscription = await this.client.startEvents((event) => {
571
664
  try {
665
+ if (this.state !== "started") return;
572
666
  this.eventBus.emit(event.event_type, event);
573
667
  } catch (error) {
574
668
  this.logger.error("Error handling event stream event", error);
575
669
  }
576
670
  });
671
+ if (this.state !== "started") {
672
+ await subscription.stop();
673
+ break;
674
+ }
675
+ this.eventSubscription = subscription;
577
676
  this.logger.info("Event stream connected");
578
677
  reconnectDelay = this.initialReconnectDelayMs;
579
678
  reconnectAttempt = 1;
580
679
  await subscription.closed;
680
+ this.eventSubscription = void 0;
681
+ if (this.state !== "started") break;
581
682
  this.logger.warn(`Event stream disconnected; reconnecting in ${reconnectDelay}ms`);
582
683
  } catch (error) {
684
+ this.eventSubscription = void 0;
685
+ if (this.state !== "started") break;
583
686
  this.logger.error(`Error connecting event stream; reconnecting in ${reconnectDelay}ms`, error);
584
687
  }
585
- await new Promise((resolve) => setTimeout(resolve, reconnectDelay));
688
+ await this.waitForReconnectDelay(reconnectDelay);
586
689
  reconnectDelay = Math.min(reconnectDelay * 2, this.maxReconnectDelayMs);
587
690
  reconnectAttempt += 1;
588
691
  }
589
692
  }
693
+ waitForReconnectDelay(delay) {
694
+ return new Promise((resolve) => {
695
+ this.resolveReconnectTimer = resolve;
696
+ this.reconnectTimer = setTimeout(() => {
697
+ this.reconnectTimer = void 0;
698
+ this.resolveReconnectTimer = void 0;
699
+ resolve();
700
+ }, delay);
701
+ });
702
+ }
703
+ resolveReconnectDelay() {
704
+ if (this.reconnectTimer) {
705
+ clearTimeout(this.reconnectTimer);
706
+ this.reconnectTimer = void 0;
707
+ }
708
+ const resolve = this.resolveReconnectTimer;
709
+ this.resolveReconnectTimer = void 0;
710
+ resolve?.();
711
+ }
590
712
  static fromUrl(baseUrl, options) {
591
713
  return new Context(createMilkyClient(baseUrl, { accessToken: options?.accessToken }), options);
592
714
  }
@@ -966,4 +1088,4 @@ let param;
966
1088
  _param.segment = segment;
967
1089
  })(param || (param = {}));
968
1090
  //#endregion
969
- export { Context, Logger, Parameter, Router, combineLogHandlers, createMilkyClient, definePlugin, filter, milkyPackageVersion, milkyVersion, msg, param, seg };
1091
+ export { Context, Logger, Parameter, Router, combineLogHandlers, createMilkyClient, definePlugin, filter, isDisposable, milkyPackageVersion, milkyVersion, msg, param, seg };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@fraqjs/fraq",
3
3
  "type": "module",
4
- "version": "0.5.1",
4
+ "version": "0.6.0",
5
5
  "description": "Milky Bot framework",
6
6
  "files": [
7
7
  "dist"