@fraqjs/fraq 0.5.0 → 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
@@ -5686,7 +5697,13 @@ declare class Context {
5686
5697
  tryResolve<T extends object>(service: ServiceClass<T>): T | undefined;
5687
5698
  isProvided<T extends object>(service: ServiceClass<T>): boolean;
5688
5699
  fork(name: string, filter?: Filter): Context;
5700
+ createSession(message: IncomingMessage): Session;
5689
5701
  start(): Promise<void>;
5702
+ stop(): Promise<void>;
5703
+ private startInternal;
5704
+ private getState;
5705
+ private stopInternal;
5706
+ private stopEventStream;
5690
5707
  private acceptsParentEvent;
5691
5708
  private applyPlugins;
5692
5709
  private sortPlugins;
@@ -5697,7 +5714,8 @@ declare class Context {
5697
5714
  private createUnresolvablePluginError;
5698
5715
  private createProxyContextForPlugin;
5699
5716
  private runEventStream;
5700
- private createSession;
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) {
@@ -439,12 +455,114 @@ var Context = class Context {
439
455
  this.subContexts.push(subContext);
440
456
  return subContext;
441
457
  }
458
+ createSession(message) {
459
+ return {
460
+ raw: message,
461
+ reply: async (segments) => {
462
+ try {
463
+ switch (message.message_scene) {
464
+ case "friend":
465
+ await this.client.send_private_message({
466
+ user_id: message.peer_id,
467
+ message: segments
468
+ });
469
+ break;
470
+ case "group":
471
+ await this.client.send_group_message({
472
+ group_id: message.peer_id,
473
+ message: segments
474
+ });
475
+ break;
476
+ }
477
+ } catch (error) {
478
+ this.logger.error(`Error sending reply (source msg: scene=${message.message_scene} peer=${message.peer_id} sender=${message.sender_id} seq=${message.message_seq})`, error);
479
+ }
480
+ }
481
+ };
482
+ }
442
483
  async start() {
443
- if (this.isStarted) return;
444
- 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
+ }
445
524
  for (const subContext of this.subContexts) await subContext.start();
446
- this.isStarted = true;
447
- 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
+ }
448
566
  }
449
567
  acceptsParentEvent(type, event) {
450
568
  if (!this.filter) return true;
@@ -456,7 +574,7 @@ var Context = class Context {
456
574
  const providedBeforeApply = new Set(this.services.keys());
457
575
  this.logger.info(`Applying plugin ${plugin.name}`);
458
576
  await plugin.apply(this.createProxyContextForPlugin(plugin), ...args);
459
- 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.`);
460
578
  this.logger.debug(`Applied plugin ${plugin.name}`);
461
579
  }
462
580
  }
@@ -464,7 +582,7 @@ var Context = class Context {
464
582
  const providers = /* @__PURE__ */ new Map();
465
583
  for (const { plugin } of this.plugins) for (const service of plugin.provides ?? []) {
466
584
  const existingProvider = providers.get(service);
467
- 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}.`);
468
586
  providers.set(service, plugin);
469
587
  }
470
588
  const pending = [...this.plugins];
@@ -515,7 +633,7 @@ var Context = class Context {
515
633
  const lines = [...missingRequirements].map(([service, plugins]) => {
516
634
  const dependents = plugins.map((plugin) => plugin.name).join(", ");
517
635
  const reason = pendingProviders.has(service) ? "blocked by a dependency cycle" : "no installed plugin provides it";
518
- return `${getServiceName(service)} required by ${dependents} (${reason})`;
636
+ return `${service.name} required by ${dependents} (${reason})`;
519
637
  });
520
638
  return /* @__PURE__ */ new Error(`Unable to resolve plugin service dependencies: ${lines.join("; ")}.`);
521
639
  }
@@ -539,53 +657,57 @@ var Context = class Context {
539
657
  async runEventStream() {
540
658
  let reconnectDelay = this.initialReconnectDelayMs;
541
659
  let reconnectAttempt = 1;
542
- while (this.isStarted) {
660
+ while (this.state === "started") {
543
661
  try {
544
662
  this.logger.debug(`Connecting event stream (attempt=${reconnectAttempt})`);
545
663
  const subscription = await this.client.startEvents((event) => {
546
664
  try {
665
+ if (this.state !== "started") return;
547
666
  this.eventBus.emit(event.event_type, event);
548
667
  } catch (error) {
549
668
  this.logger.error("Error handling event stream event", error);
550
669
  }
551
670
  });
671
+ if (this.state !== "started") {
672
+ await subscription.stop();
673
+ break;
674
+ }
675
+ this.eventSubscription = subscription;
552
676
  this.logger.info("Event stream connected");
553
677
  reconnectDelay = this.initialReconnectDelayMs;
554
678
  reconnectAttempt = 1;
555
679
  await subscription.closed;
680
+ this.eventSubscription = void 0;
681
+ if (this.state !== "started") break;
556
682
  this.logger.warn(`Event stream disconnected; reconnecting in ${reconnectDelay}ms`);
557
683
  } catch (error) {
684
+ this.eventSubscription = void 0;
685
+ if (this.state !== "started") break;
558
686
  this.logger.error(`Error connecting event stream; reconnecting in ${reconnectDelay}ms`, error);
559
687
  }
560
- await new Promise((resolve) => setTimeout(resolve, reconnectDelay));
688
+ await this.waitForReconnectDelay(reconnectDelay);
561
689
  reconnectDelay = Math.min(reconnectDelay * 2, this.maxReconnectDelayMs);
562
690
  reconnectAttempt += 1;
563
691
  }
564
692
  }
565
- createSession(message) {
566
- return {
567
- raw: message,
568
- reply: async (segments) => {
569
- try {
570
- switch (message.message_scene) {
571
- case "friend":
572
- await this.client.send_private_message({
573
- user_id: message.peer_id,
574
- message: segments
575
- });
576
- break;
577
- case "group":
578
- await this.client.send_group_message({
579
- group_id: message.peer_id,
580
- message: segments
581
- });
582
- break;
583
- }
584
- } catch (error) {
585
- this.logger.error(`Error sending reply (source msg: scene=${message.message_scene} peer=${message.peer_id} sender=${message.sender_id} seq=${message.message_seq})`, error);
586
- }
587
- }
588
- };
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?.();
589
711
  }
590
712
  static fromUrl(baseUrl, options) {
591
713
  return new Context(createMilkyClient(baseUrl, { accessToken: options?.accessToken }), options);
@@ -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.0",
4
+ "version": "0.6.0",
5
5
  "description": "Milky Bot framework",
6
6
  "files": [
7
7
  "dist"