@fraqjs/fraq 0.5.1 → 0.6.1

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