@fraqjs/fraq 0.13.0 → 0.13.2

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
@@ -5542,6 +5542,7 @@ type RouteActivation =
5542
5542
  }
5543
5543
  | {
5544
5544
  type: 'mention';
5545
+ prefix?: string;
5545
5546
  }
5546
5547
  | {
5547
5548
  type: 'prefix';
@@ -5828,7 +5829,7 @@ declare function definePlugin<
5828
5829
  OI extends Injection | undefined,
5829
5830
  >(plugin: Plugin<T, I, OI>): Plugin<T, I, OI>;
5830
5831
  //#endregion
5831
- //#region src/core/context.d.ts
5832
+ //#region src/core/context/index.d.ts
5832
5833
  interface ContextOptions {
5833
5834
  reconnect?: {
5834
5835
  initialDelayMs?: number;
@@ -5851,19 +5852,14 @@ declare class Context {
5851
5852
  private readonly parent?;
5852
5853
  private readonly filter?;
5853
5854
  private readonly eventBus;
5854
- private readonly plugins;
5855
- private readonly services;
5856
- private readonly apiHookEntries;
5857
5855
  private readonly subContexts;
5858
- private readonly eventSourceRuntimes;
5859
5856
  private readonly parentEventForwarder?;
5860
- private readonly timers;
5861
- private readonly initialReconnectDelayMs;
5862
- private readonly maxReconnectDelayMs;
5863
5857
  private readonly logHandler?;
5864
- private state;
5865
- private startPromise?;
5866
- private stopPromise?;
5858
+ private readonly plugins;
5859
+ private readonly apiHooks;
5860
+ private readonly eventSources;
5861
+ private readonly timers;
5862
+ private readonly lifecycle;
5867
5863
  private constructor();
5868
5864
  on<K extends keyof EventMap>(type: K, handler: (event: EventMap[K]) => void | Promise<void>): () => void;
5869
5865
  install<T extends ParameterList, I extends Injection | undefined, OI extends Injection | undefined>(
@@ -5883,36 +5879,6 @@ declare class Context {
5883
5879
  createSession(selfId: number, message: IncomingMessage): Session;
5884
5880
  start(): Promise<void>;
5885
5881
  stop(): Promise<void>;
5886
- private startInternal;
5887
- private getState;
5888
- private stopInternal;
5889
- private assertCanRegisterApiHook;
5890
- private assertCanScheduleTimer;
5891
- private runTimerCallback;
5892
- private clearTimers;
5893
- private stopEventSources;
5894
- private acceptsParentEvent;
5895
- private applyPlugins;
5896
- private recursiveApplyPlugins;
5897
- private startPlugins;
5898
- private recursiveStartPlugins;
5899
- private sortPlugins;
5900
- private collectPendingProvidedServices;
5901
- private areRequiredServicesAvailable;
5902
- private areOptionalServicesReady;
5903
- private collectAvailableServices;
5904
- private createUnresolvablePluginError;
5905
- private getPluginContext;
5906
- private createProxyContextForPlugin;
5907
- private startEventSource;
5908
- private runEventSource;
5909
- private waitForReconnectDelay;
5910
- private resolveReconnectDelay;
5911
- private createHookClient;
5912
- private wrapAnyApiHook;
5913
- private callHookedApi;
5914
- private collectApiHooks;
5915
- private callBaseApi;
5916
5882
  static fromUrl(baseUrl: string | URL, options?: ContextOptions & ContextUrlOptions): Context;
5917
5883
  static fromClient(client: MilkyClient, options?: ContextOptions): Context;
5918
5884
  }
package/dist/index.mjs CHANGED
@@ -314,12 +314,16 @@ var Router = class Router {
314
314
  }
315
315
  match(session, message) {
316
316
  for (const branch of this.branchesFrom(session, [], { includeHidden: true })) {
317
- const descriptor = this.describeBranch(branch);
318
- const activations = this.activationResolver(descriptor, session) ?? DEFAULT_ACTIVATIONS;
319
- for (const activation of routeActivationInputs(activations)) {
317
+ const literalActivationIndex = branch.type === "rawPattern" ? Object.values(branch.rawPattern.pattern).findIndex((parameter) => parameter.capturer.typeInstruction.type === "literal") : -1;
318
+ let activationInputs = DEFAULT_ACTIVATIONS;
319
+ if (branch.type === "command" || literalActivationIndex !== -1) {
320
+ const descriptor = this.describeBranch(branch);
321
+ const activations = this.activationResolver(descriptor, session) ?? DEFAULT_ACTIVATIONS;
322
+ activationInputs = Array.isArray(activations) ? activations : [activations];
323
+ }
324
+ for (const activation of activationInputs) {
320
325
  const tokenizer = new Tokenizer(message.segments);
321
- if (!this.consumeActivation(tokenizer, activation, session)) continue;
322
- const match = this.matchBranch(branch, tokenizer, activation);
326
+ const match = this.matchBranch(branch, tokenizer, activation, session, literalActivationIndex);
323
327
  if (match !== void 0) return match;
324
328
  }
325
329
  }
@@ -362,15 +366,18 @@ var Router = class Router {
362
366
  consumeActivation(tokenizer, activation, session) {
363
367
  switch (activation.type) {
364
368
  case "direct": return true;
365
- case "mention": return tokenizer.consumeMention(session.selfId);
369
+ case "mention":
370
+ if (!tokenizer.consumeMention(session.selfId)) return false;
371
+ return activation.prefix === void 0 || tokenizer.consumeTextPrefix(activation.prefix);
366
372
  case "prefix": return tokenizer.consumeTextPrefix(activation.prefix);
367
373
  }
368
374
  }
369
- matchBranch(branch, tokenizer, activation) {
375
+ matchBranch(branch, tokenizer, activation, session, literalActivationIndex) {
376
+ if (branch.type === "command" && !this.consumeActivation(tokenizer, activation, session)) return;
370
377
  if (!this.matchPath(branch.path, tokenizer)) return;
371
378
  switch (branch.type) {
372
379
  case "command": return this.matchCommand(branch.command, tokenizer, branch.path, activation);
373
- case "rawPattern": return this.matchRawPattern(branch.rawPattern, tokenizer, branch.path, activation);
380
+ case "rawPattern": return this.matchRawPattern(branch.rawPattern, tokenizer, branch.path, activation, session, literalActivationIndex);
374
381
  }
375
382
  }
376
383
  matchPath(path, tokenizer) {
@@ -395,9 +402,15 @@ var Router = class Router {
395
402
  activation
396
403
  };
397
404
  }
398
- matchRawPattern(rawPattern, tokenizer, path, activation) {
399
- const params = this.capturePattern(rawPattern.pattern, tokenizer);
400
- if (params === void 0 || tokenizer.hasNext()) return;
405
+ matchRawPattern(rawPattern, tokenizer, path, activation, session, literalActivationIndex) {
406
+ const params = {};
407
+ for (const [index, [name, parameter]] of Object.entries(rawPattern.pattern).entries()) {
408
+ if (index === literalActivationIndex && !this.consumeActivation(tokenizer, activation, session)) return;
409
+ const value = parameter.capturer.capture(tokenizer);
410
+ if (value === void 0) return;
411
+ params[name] = value;
412
+ }
413
+ if (tokenizer.hasNext()) return;
401
414
  return {
402
415
  type: "rawPattern",
403
416
  path: [...path],
@@ -465,7 +478,6 @@ var Router = class Router {
465
478
  validatePattern(pattern, options) {
466
479
  const entries = Object.entries(pattern);
467
480
  if (options?.rawPattern && entries.length === 0) throw new Error("Raw pattern must have at least one parameter.");
468
- if (options?.rawPattern && entries[0]?.[1].capturer.typeInstruction.type === "literal") throw new Error("The first parameter of a raw pattern cannot be a literal, as it would conflict with command patterns.");
469
481
  const catchAllEntryIndex = entries.findIndex(([, parameter]) => {
470
482
  return parameter.capturer.typeInstruction.type === "catchAll";
471
483
  });
@@ -485,12 +497,6 @@ var Router = class Router {
485
497
  return params;
486
498
  }
487
499
  };
488
- function routeActivationInputs(activations) {
489
- return isRouteActivationArray(activations) ? activations : [activations];
490
- }
491
- function isRouteActivationArray(activations) {
492
- return Array.isArray(activations);
493
- }
494
500
  //#endregion
495
501
  //#region src/core/activation.ts
496
502
  function createContextRouteActivationResolver(routing, fallback = defaultRouteActivationResolver) {
@@ -502,7 +508,8 @@ function createContextRouteActivationResolver(routing, fallback = defaultRouteAc
502
508
  }
503
509
  function compileContextRouteActivationConfig(config, fallback) {
504
510
  return (route, session) => {
505
- for (const rule of contextRouteActivationRules(config.rules)) {
511
+ const rules = config.rules === void 0 ? [] : Array.isArray(config.rules) ? config.rules : [config.rules];
512
+ for (const rule of rules) {
506
513
  if (!matchesContextRouteActivationRule(route, session, rule)) continue;
507
514
  const activation = resolveContextRouteActivation(rule.activation, session.raw.message_scene);
508
515
  if (activation !== void 0) return activation;
@@ -514,22 +521,10 @@ function compileContextRouteActivationConfig(config, fallback) {
514
521
  return fallback(route, session);
515
522
  };
516
523
  }
517
- function contextRouteActivationRules(rules) {
518
- if (rules === void 0) return [];
519
- return isContextRouteActivationRuleArray(rules) ? rules : [rules];
520
- }
521
- function isContextRouteActivationRuleArray(rules) {
522
- return Array.isArray(rules);
523
- }
524
524
  function resolveContextRouteActivation(activation, scene) {
525
- if (isRouteActivationInput(activation)) return activation;
526
- return activation[scene] ?? activation.default;
527
- }
528
- function isRouteActivationInput(activation) {
529
- return Array.isArray(activation) || isRouteActivation(activation);
530
- }
531
- function isRouteActivation(activation) {
532
- return "type" in activation;
525
+ if (Array.isArray(activation) || "type" in activation) return activation;
526
+ const activationByScene = activation;
527
+ return activationByScene[scene] ?? activationByScene.default;
533
528
  }
534
529
  function matchesContextRouteActivationRule(route, session, rule) {
535
530
  const match = rule.match;
@@ -808,97 +803,29 @@ function combineLogHandlers(...handlers) {
808
803
  };
809
804
  }
810
805
  //#endregion
811
- //#region src/core/service.ts
812
- function isDisposable(service) {
813
- return "dispose" in service && typeof service.dispose === "function";
814
- }
815
- function implementsESNextDisposable(service) {
816
- return Symbol.dispose in service && typeof service[Symbol.dispose] === "function";
817
- }
818
- //#endregion
819
- //#region src/core/context.ts
820
- const DEFAULT_INITIAL_RECONNECT_DELAY_MS = 1e3;
821
- const DEFAULT_MAX_RECONNECT_DELAY_MS = 3e4;
822
- var Context = class Context {
823
- router = new Router();
824
- logger;
825
- name;
826
- routeActivationResolver;
827
- client;
806
+ //#region src/core/context/api-hooks.ts
807
+ var ApiHookRegistry = class {
828
808
  baseClient;
829
809
  parent;
830
- filter;
831
- eventBus = mitt();
832
- plugins = [];
833
- services = /* @__PURE__ */ new Map();
834
- apiHookEntries = [];
835
- subContexts = /* @__PURE__ */ new Map();
836
- eventSourceRuntimes = /* @__PURE__ */ new Map();
837
- parentEventForwarder;
838
- timers = /* @__PURE__ */ new Set();
839
- initialReconnectDelayMs;
840
- maxReconnectDelayMs;
841
- logHandler;
842
- state = "idle";
843
- startPromise;
844
- stopPromise;
845
- constructor(baseClient, options, name, parent, filter) {
810
+ contextName;
811
+ getState;
812
+ client;
813
+ entries = [];
814
+ constructor(baseClient, parent, contextName, getState) {
846
815
  this.baseClient = baseClient;
847
- this.client = this.createHookClient();
848
- this.initialReconnectDelayMs = options?.reconnect?.initialDelayMs ?? DEFAULT_INITIAL_RECONNECT_DELAY_MS;
849
- this.maxReconnectDelayMs = options?.reconnect?.maxDelayMs ?? DEFAULT_MAX_RECONNECT_DELAY_MS;
850
- this.logHandler = options?.logHandler ?? parent?.logHandler;
851
- this.name = name ?? "root";
852
- this.logger = new Logger((message) => this.logHandler?.(message), `context:${this.name}`);
853
- this.routeActivationResolver = createContextRouteActivationResolver(options?.routing, parent?.routeActivationResolver);
854
- this.router.setActivationResolver(this.routeActivationResolver);
855
816
  this.parent = parent;
856
- this.filter = filter;
857
- if (parent) {
858
- this.parentEventForwarder = (type, event) => {
859
- if (!this.acceptsParentEvent(type, event)) return;
860
- this.eventBus.emit(type, event);
861
- };
862
- parent.eventBus.on("*", this.parentEventForwarder);
863
- }
864
- this.eventBus.on("message_receive", async ({ self_id, data: message }) => {
865
- try {
866
- if (this.state === "stopping" || this.state === "stopped") return;
867
- await this.router.dispatch(this.createSession(self_id, message), message);
868
- } catch (error) {
869
- this.logger.error(`Error routing command (scene=${message.message_scene} peer=${message.peer_id} sender=${message.sender_id} seq=${message.message_seq})`, error);
870
- }
871
- });
872
- }
873
- on(type, handler) {
874
- const wrappedHandler = async (event) => {
875
- try {
876
- if (this.state === "stopping" || this.state === "stopped") return;
877
- await handler(event);
878
- } catch (error) {
879
- this.logger.error(`Error handling event ${type}`, error);
880
- }
881
- };
882
- this.eventBus.on(type, wrappedHandler);
883
- return () => {
884
- this.eventBus.off(type, wrappedHandler);
885
- };
886
- }
887
- install(plugin, ...args) {
888
- this.plugins.push({
889
- plugin,
890
- args
891
- });
892
- }
893
- installEventSource(eventSource) {
894
- if (this.eventSourceRuntimes.has(eventSource)) return;
895
- this.eventSourceRuntimes.set(eventSource, {});
896
- if (this.state === "started") this.startEventSource(eventSource);
817
+ this.contextName = contextName;
818
+ this.getState = getState;
819
+ this.client = this.createHookClient();
897
820
  }
898
- hookApi(endpointOrHook, hook) {
899
- this.assertCanRegisterApiHook();
821
+ register(endpointOrHook, hook) {
822
+ const state = this.getState();
823
+ if (state === "stopping") throw new Error(`Context "${this.contextName}" cannot register API hooks while it is stopping.`);
824
+ if (state === "stopped") throw new Error(`Context "${this.contextName}" cannot register API hooks after it has stopped.`);
900
825
  let entry;
901
- if (typeof endpointOrHook === "function") entry = { hook: this.wrapAnyApiHook(endpointOrHook) };
826
+ if (typeof endpointOrHook === "function") entry = { hook: (params, next, call) => {
827
+ return endpointOrHook(call, (nextParams = params) => next(nextParams));
828
+ } };
902
829
  else {
903
830
  if (!hook) throw new Error(`API hook for endpoint ${endpointOrHook} is missing a handler.`);
904
831
  entry = {
@@ -906,213 +833,98 @@ var Context = class Context {
906
833
  hook
907
834
  };
908
835
  }
909
- this.apiHookEntries.push(entry);
836
+ this.entries.push(entry);
910
837
  let disposed = false;
911
838
  return () => {
912
839
  if (disposed) return;
913
840
  disposed = true;
914
- const index = this.apiHookEntries.indexOf(entry);
915
- if (index !== -1) this.apiHookEntries.splice(index, 1);
841
+ const index = this.entries.indexOf(entry);
842
+ if (index !== -1) this.entries.splice(index, 1);
916
843
  };
917
844
  }
918
- provide(service, instance) {
919
- if (this.services.has(service)) throw new Error(`Service ${service.name} has already been provided in this context.`);
920
- if (implementsESNextDisposable(instance) && !isDisposable(instance)) throw new Error(`
921
- Service ${service.name} implements ESNext Disposable but not Fraq Disposable.
922
- Please explicitly import the interface like this:
923
-
924
- import type { Disposable } from '@fraqjs/fraq';
925
-
926
- and implement the dispose method to clean up resources when the context stops.
927
- `.trim());
928
- this.services.set(service, instance);
929
- }
930
- resolve(service) {
931
- const instance = this.tryResolve(service);
932
- if (instance === void 0) throw new Error(`Service ${service.name} has not been provided.`);
933
- return instance;
845
+ clear() {
846
+ this.entries.length = 0;
934
847
  }
935
- tryResolve(service) {
936
- if (this.services.has(service)) return this.services.get(service);
937
- return this.parent?.tryResolve(service);
848
+ createHookClient() {
849
+ return new Proxy(this.baseClient, { get: (target, prop, receiver) => {
850
+ if (typeof prop === "string" && prop.includes("_")) return (params) => this.callHookedApi(prop, params);
851
+ return Reflect.get(target, prop, receiver);
852
+ } });
938
853
  }
939
- isProvided(service) {
940
- return this.tryResolve(service) !== void 0;
854
+ async callHookedApi(endpoint, params) {
855
+ const hooks = this.collectApiHooks(endpoint);
856
+ const dispatch = async (index, currentParams) => {
857
+ const hook = hooks[index];
858
+ if (!hook) return await this.callBaseApi(endpoint, currentParams);
859
+ let nextCalled = false;
860
+ return await hook(currentParams, async (nextParams = currentParams) => {
861
+ if (nextCalled) throw new Error(`API hook for endpoint ${endpoint} called next() multiple times.`);
862
+ nextCalled = true;
863
+ return await dispatch(index + 1, nextParams);
864
+ }, {
865
+ endpoint,
866
+ params: currentParams
867
+ });
868
+ };
869
+ return await dispatch(0, params);
941
870
  }
942
- fork(name, filter) {
943
- if (this.subContexts.has(name)) {
944
- if (filter) throw new Error(`Sub context "${name}" already exists, so the provided filter cannot be applied. Please use fork('${name}') without a filter to get the existing subcontext.`);
945
- return this.subContexts.get(name);
871
+ collectApiHooks(endpoint) {
872
+ const hooks = [];
873
+ let registry = this;
874
+ while (registry) {
875
+ for (const entry of registry.entries.toReversed()) if (entry.endpoint === void 0 || entry.endpoint === endpoint) hooks.push(entry.hook);
876
+ registry = registry.parent;
946
877
  }
947
- const subContext = new Context(this.baseClient, void 0, name, this, filter);
948
- this.subContexts.set(name, subContext);
949
- return subContext;
878
+ return hooks;
950
879
  }
951
- timeout(delayMs, callback) {
952
- this.assertCanScheduleTimer();
953
- const timeout = setTimeout(() => {
954
- this.timers.delete(timeout);
955
- this.runTimerCallback(callback);
956
- }, delayMs);
957
- this.timers.add(timeout);
958
- return timeout;
880
+ async callBaseApi(endpoint, params) {
881
+ const callApi = this.baseClient.callApi;
882
+ if (typeof callApi === "function") return await callApi.call(this.baseClient, endpoint, params);
883
+ const method = this.baseClient[endpoint];
884
+ if (typeof method !== "function") throw new Error(`Milky client does not implement API endpoint ${endpoint}.`);
885
+ return await method.call(this.baseClient, params);
959
886
  }
960
- interval(intervalMs, callback) {
961
- this.assertCanScheduleTimer();
962
- const interval = setInterval(() => {
963
- this.runTimerCallback(callback);
964
- }, intervalMs);
965
- this.timers.add(interval);
966
- return interval;
887
+ };
888
+ //#endregion
889
+ //#region src/core/context/event-sources.ts
890
+ const DEFAULT_INITIAL_RECONNECT_DELAY_MS = 1e3;
891
+ const DEFAULT_MAX_RECONNECT_DELAY_MS = 3e4;
892
+ var EventSourceRegistry = class {
893
+ logger;
894
+ getState;
895
+ emit;
896
+ runtimes = /* @__PURE__ */ new Map();
897
+ initialReconnectDelayMs;
898
+ maxReconnectDelayMs;
899
+ constructor(options, logger, getState, emit) {
900
+ this.logger = logger;
901
+ this.getState = getState;
902
+ this.emit = emit;
903
+ this.initialReconnectDelayMs = options?.initialDelayMs ?? DEFAULT_INITIAL_RECONNECT_DELAY_MS;
904
+ this.maxReconnectDelayMs = options?.maxDelayMs ?? DEFAULT_MAX_RECONNECT_DELAY_MS;
967
905
  }
968
- createSession(selfId, message) {
969
- return {
970
- selfId,
971
- raw: message,
972
- reply: async (textOrSegments, options) => {
973
- const actualSegments = [];
974
- if (typeof textOrSegments === "string") actualSegments.push({
975
- type: "text",
976
- data: { text: textOrSegments }
977
- });
978
- else actualSegments.push(...textOrSegments);
979
- if (options?.withMention && message.message_scene === "group") actualSegments.unshift(seg.mention(message.sender_id));
980
- if (options?.withQuote) actualSegments.unshift(seg.reply(message.message_seq));
981
- switch (message.message_scene) {
982
- case "friend": {
983
- const { message_seq } = await this.client.send_private_message({
984
- user_id: message.peer_id,
985
- message: actualSegments
986
- });
987
- return { messageSeq: message_seq };
988
- }
989
- case "group": {
990
- const { message_seq } = await this.client.send_group_message({
991
- group_id: message.peer_id,
992
- message: actualSegments
993
- });
994
- return { messageSeq: message_seq };
995
- }
996
- }
997
- return { messageSeq: 0 };
998
- },
999
- reaction: async (type, reactionId) => {
1000
- if (message.message_scene === "group") await this.client.send_group_message_reaction({
1001
- group_id: message.peer_id,
1002
- message_seq: message.message_seq,
1003
- reaction_type: type,
1004
- reaction: reactionId
1005
- });
1006
- }
1007
- };
906
+ install(eventSource) {
907
+ if (this.runtimes.has(eventSource)) return;
908
+ this.runtimes.set(eventSource, {});
909
+ if (this.getState() === "started") this.startEventSource(eventSource);
1008
910
  }
1009
- async start() {
1010
- if (this.state === "started") return;
1011
- if (this.state === "starting") {
1012
- await this.startPromise;
1013
- return;
1014
- }
1015
- if (this.state === "stopping") throw new Error(`Context "${this.name}" cannot be started while it is stopping.`);
1016
- if (this.state === "stopped") throw new Error(`Context "${this.name}" cannot be restarted after it has been stopped.`);
1017
- this.state = "starting";
1018
- this.startPromise = this.startInternal();
1019
- try {
1020
- await this.startPromise;
1021
- } finally {
1022
- this.startPromise = void 0;
1023
- }
911
+ startAll() {
912
+ for (const eventSource of this.runtimes.keys()) this.startEventSource(eventSource);
1024
913
  }
1025
914
  async stop() {
1026
- if (this.state === "idle" && this.timers.size === 0 || this.state === "stopped") return;
1027
- if (this.state === "starting") await this.startPromise;
1028
- const stateAfterStart = this.getState();
1029
- if (stateAfterStart === "idle" && this.timers.size === 0 || stateAfterStart === "stopped") return;
1030
- if (stateAfterStart === "stopping") {
1031
- await this.stopPromise;
1032
- return;
1033
- }
1034
- this.state = "stopping";
1035
- this.stopPromise = this.stopInternal();
1036
- try {
1037
- await this.stopPromise;
1038
- } finally {
1039
- this.state = "stopped";
1040
- this.stopPromise = void 0;
1041
- }
1042
- }
1043
- async startInternal() {
1044
- const appliedContextPlugins = [];
1045
- const startingContexts = [];
1046
- try {
1047
- await this.recursiveApplyPlugins(appliedContextPlugins, startingContexts);
1048
- await this.recursiveStartPlugins(appliedContextPlugins);
1049
- } catch (error) {
1050
- for (const context of startingContexts) if (context.state === "starting") context.state = "idle";
1051
- throw error;
1052
- }
1053
- for (const { context } of appliedContextPlugins) {
1054
- context.state = "started";
1055
- for (const eventSource of context.eventSourceRuntimes.keys()) context.startEventSource(eventSource);
1056
- }
1057
- }
1058
- getState() {
1059
- return this.state;
1060
- }
1061
- async stopInternal() {
1062
915
  const errors = [];
1063
- this.clearTimers();
1064
- for (const subContext of [...this.subContexts.values()].reverse()) try {
1065
- await subContext.stop();
1066
- } catch (error) {
1067
- errors.push(error);
1068
- }
1069
- await this.stopEventSources(errors);
1070
- if (this.parent && this.parentEventForwarder) this.parent.eventBus.off("*", this.parentEventForwarder);
1071
- for (const service of [...this.services.values()].reverse()) {
1072
- if (!isDisposable(service)) continue;
916
+ for (const runtime of this.runtimes.values()) {
917
+ this.resolveReconnectDelay(runtime);
918
+ const subscription = runtime.subscription;
919
+ if (!subscription) continue;
1073
920
  try {
1074
- await service.dispose();
921
+ await subscription.stop();
1075
922
  } catch (error) {
1076
923
  errors.push(error);
1077
924
  }
925
+ runtime.subscription = void 0;
1078
926
  }
1079
- this.apiHookEntries.length = 0;
1080
- if (errors.length === 1) throw errors[0];
1081
- if (errors.length > 1) throw new AggregateError(errors, `Context "${this.name}" failed to stop cleanly.`);
1082
- }
1083
- assertCanRegisterApiHook() {
1084
- if (this.state === "stopping") throw new Error(`Context "${this.name}" cannot register API hooks while it is stopping.`);
1085
- if (this.state === "stopped") throw new Error(`Context "${this.name}" cannot register API hooks after it has stopped.`);
1086
- }
1087
- assertCanScheduleTimer() {
1088
- if (this.state === "stopping") throw new Error(`Context "${this.name}" cannot schedule timers while it is stopping.`);
1089
- if (this.state === "stopped") throw new Error(`Context "${this.name}" cannot schedule timers after it has stopped.`);
1090
- }
1091
- async runTimerCallback(callback) {
1092
- if (this.state === "stopping" || this.state === "stopped") return;
1093
- try {
1094
- await callback();
1095
- } catch (error) {
1096
- this.logger.error("Error handling timer callback", error);
1097
- }
1098
- }
1099
- clearTimers() {
1100
- for (const timer of this.timers) clearTimeout(timer);
1101
- this.timers.clear();
1102
- }
1103
- async stopEventSources(errors) {
1104
- for (const [_, runtime] of this.eventSourceRuntimes) {
1105
- this.resolveReconnectDelay(runtime);
1106
- const subscription = runtime.subscription;
1107
- if (!subscription) continue;
1108
- try {
1109
- await subscription.stop();
1110
- } catch (error) {
1111
- errors.push(error);
1112
- }
1113
- runtime.subscription = void 0;
1114
- }
1115
- for (const runtime of this.eventSourceRuntimes.values()) {
927
+ for (const runtime of this.runtimes.values()) {
1116
928
  if (!runtime.task) continue;
1117
929
  try {
1118
930
  await runtime.task;
@@ -1122,13 +934,259 @@ and implement the dispose method to clean up resources when the context stops.
1122
934
  runtime.task = void 0;
1123
935
  }
1124
936
  }
937
+ return errors;
938
+ }
939
+ startEventSource(eventSource) {
940
+ const runtime = this.runtimes.get(eventSource);
941
+ if (!runtime) return;
942
+ runtime.task = this.runEventSource(eventSource, runtime);
943
+ }
944
+ async runEventSource(eventSource, runtime) {
945
+ let reconnectDelay = this.initialReconnectDelayMs;
946
+ let reconnectAttempt = 1;
947
+ while (this.getState() === "started") {
948
+ try {
949
+ this.logger.debug(`Connecting ${eventSource.name ?? "event source"} (attempt=${reconnectAttempt})`);
950
+ const subscription = await eventSource.start((event) => {
951
+ try {
952
+ if (this.getState() !== "started") return;
953
+ this.emit(event);
954
+ } catch (error) {
955
+ this.logger.error("Error handling event stream event", error);
956
+ }
957
+ });
958
+ if (this.getState() !== "started") {
959
+ await subscription.stop();
960
+ break;
961
+ }
962
+ runtime.subscription = subscription;
963
+ this.logger.info(`${eventSource.name ?? "Event source"} connected`);
964
+ reconnectDelay = this.initialReconnectDelayMs;
965
+ reconnectAttempt = 1;
966
+ await subscription.closed;
967
+ if (runtime.subscription === subscription) runtime.subscription = void 0;
968
+ if (this.getState() !== "started") break;
969
+ this.logger.warn(`${eventSource.name ?? "Event source"} disconnected; reconnecting in ${reconnectDelay}ms`);
970
+ } catch (error) {
971
+ if (this.getState() !== "started") break;
972
+ this.logger.error(`Error connecting ${eventSource.name ?? "event source"}; reconnecting in ${reconnectDelay}ms`, error);
973
+ }
974
+ await this.waitForReconnectDelay(runtime, reconnectDelay);
975
+ reconnectDelay = Math.min(reconnectDelay * 2, this.maxReconnectDelayMs);
976
+ reconnectAttempt += 1;
977
+ }
978
+ }
979
+ waitForReconnectDelay(runtime, delay) {
980
+ return new Promise((resolve) => {
981
+ runtime.resolveReconnectTimer = resolve;
982
+ runtime.reconnectTimer = setTimeout(() => {
983
+ runtime.reconnectTimer = void 0;
984
+ runtime.resolveReconnectTimer = void 0;
985
+ resolve();
986
+ }, delay);
987
+ });
988
+ }
989
+ resolveReconnectDelay(runtime) {
990
+ if (runtime.reconnectTimer) {
991
+ clearTimeout(runtime.reconnectTimer);
992
+ runtime.reconnectTimer = void 0;
993
+ }
994
+ const resolve = runtime.resolveReconnectTimer;
995
+ runtime.resolveReconnectTimer = void 0;
996
+ resolve?.();
997
+ }
998
+ };
999
+ //#endregion
1000
+ //#region src/core/context/lifecycle.ts
1001
+ var LifecycleManager = class {
1002
+ contextName;
1003
+ plugins;
1004
+ timers;
1005
+ eventSources;
1006
+ apiHooks;
1007
+ detachParentEvents;
1008
+ children = /* @__PURE__ */ new Set();
1009
+ currentState = "idle";
1010
+ startPromise;
1011
+ stopPromise;
1012
+ constructor(contextName, plugins, timers, eventSources, apiHooks, detachParentEvents) {
1013
+ this.contextName = contextName;
1014
+ this.plugins = plugins;
1015
+ this.timers = timers;
1016
+ this.eventSources = eventSources;
1017
+ this.apiHooks = apiHooks;
1018
+ this.detachParentEvents = detachParentEvents;
1019
+ }
1020
+ get state() {
1021
+ return this.currentState;
1022
+ }
1023
+ addChild(child) {
1024
+ this.children.add(child);
1025
+ }
1026
+ async start() {
1027
+ if (this.currentState === "started") return;
1028
+ if (this.currentState === "starting") {
1029
+ await this.startPromise;
1030
+ return;
1031
+ }
1032
+ if (this.currentState === "stopping") throw new Error(`Context "${this.contextName}" cannot be started while it is stopping.`);
1033
+ if (this.currentState === "stopped") throw new Error(`Context "${this.contextName}" cannot be restarted after it has been stopped.`);
1034
+ this.currentState = "starting";
1035
+ this.startPromise = this.startInternal();
1036
+ try {
1037
+ await this.startPromise;
1038
+ } finally {
1039
+ this.startPromise = void 0;
1040
+ }
1041
+ }
1042
+ async stop() {
1043
+ if (this.currentState === "idle" && !this.timers.hasTimers || this.currentState === "stopped") return;
1044
+ let stateAfterStart = this.currentState;
1045
+ if (stateAfterStart === "starting") {
1046
+ await this.startPromise;
1047
+ stateAfterStart = this.currentState;
1048
+ }
1049
+ if (stateAfterStart === "idle" && !this.timers.hasTimers || stateAfterStart === "stopped") return;
1050
+ if (stateAfterStart === "stopping") {
1051
+ await this.stopPromise;
1052
+ return;
1053
+ }
1054
+ this.currentState = "stopping";
1055
+ this.stopPromise = this.stopInternal();
1056
+ try {
1057
+ await this.stopPromise;
1058
+ } finally {
1059
+ this.currentState = "stopped";
1060
+ this.stopPromise = void 0;
1061
+ }
1062
+ }
1063
+ async startInternal() {
1064
+ const appliedContextPlugins = [];
1065
+ const startingContexts = [];
1066
+ try {
1067
+ await this.recursiveApplyPlugins(appliedContextPlugins, startingContexts);
1068
+ for (const { lifecycle, sortedPlugins } of appliedContextPlugins) await lifecycle.plugins.start(sortedPlugins);
1069
+ } catch (error) {
1070
+ for (const lifecycle of startingContexts) if (lifecycle.currentState === "starting") lifecycle.currentState = "idle";
1071
+ throw error;
1072
+ }
1073
+ for (const { lifecycle } of appliedContextPlugins) {
1074
+ lifecycle.currentState = "started";
1075
+ lifecycle.eventSources.startAll();
1076
+ }
1077
+ }
1078
+ async stopInternal() {
1079
+ const errors = [];
1080
+ this.timers.clear();
1081
+ for (const child of [...this.children].reverse()) try {
1082
+ await child.stop();
1083
+ } catch (error) {
1084
+ errors.push(error);
1085
+ }
1086
+ errors.push(...await this.eventSources.stop());
1087
+ this.detachParentEvents();
1088
+ errors.push(...await this.plugins.disposeServices());
1089
+ this.apiHooks.clear();
1090
+ if (errors.length === 1) throw errors[0];
1091
+ if (errors.length > 1) throw new AggregateError(errors, `Context "${this.contextName}" failed to stop cleanly.`);
1092
+ }
1093
+ async recursiveApplyPlugins(appliedContextPlugins, startingContexts) {
1094
+ if (this.currentState === "started") return;
1095
+ if (this.currentState === "starting" && this.startPromise) {
1096
+ await this.startPromise;
1097
+ return;
1098
+ }
1099
+ if (this.currentState === "stopping") throw new Error(`Context "${this.contextName}" cannot be started while it is stopping.`);
1100
+ if (this.currentState === "stopped") throw new Error(`Context "${this.contextName}" cannot be restarted after it has been stopped.`);
1101
+ if (this.currentState === "idle") this.currentState = "starting";
1102
+ startingContexts.push(this);
1103
+ const sortedPlugins = await this.plugins.apply();
1104
+ appliedContextPlugins.push({
1105
+ lifecycle: this,
1106
+ sortedPlugins
1107
+ });
1108
+ for (const child of this.children) await child.recursiveApplyPlugins(appliedContextPlugins, startingContexts);
1109
+ }
1110
+ };
1111
+ //#endregion
1112
+ //#region src/core/service.ts
1113
+ function isDisposable(service) {
1114
+ return "dispose" in service && typeof service.dispose === "function";
1115
+ }
1116
+ function implementsESNextDisposable(service) {
1117
+ return Symbol.dispose in service && typeof service[Symbol.dispose] === "function";
1118
+ }
1119
+ //#endregion
1120
+ //#region src/core/context/plugins.ts
1121
+ function areRequiredServicesAvailable(plugin, available) {
1122
+ return (plugin.requires ?? []).every((service) => available.has(service));
1123
+ }
1124
+ function areOptionalServicesReady(plugin, available, pendingProviders) {
1125
+ return (plugin.optionalRequires ?? []).every((service) => {
1126
+ if (available.has(service)) return true;
1127
+ const pendingProvider = pendingProviders.get(service);
1128
+ return pendingProvider === void 0 || pendingProvider.plugin === plugin;
1129
+ });
1130
+ }
1131
+ function createUnresolvablePluginError(pending, available) {
1132
+ const missingRequirements = /* @__PURE__ */ new Map();
1133
+ const pendingProviders = /* @__PURE__ */ new Set();
1134
+ for (const { plugin } of pending) for (const service of plugin.provides ?? []) pendingProviders.add(service);
1135
+ for (const { plugin } of pending) for (const service of plugin.requires ?? []) if (!available.has(service)) {
1136
+ const plugins = missingRequirements.get(service) ?? [];
1137
+ plugins.push(plugin);
1138
+ missingRequirements.set(service, plugins);
1139
+ }
1140
+ const lines = [...missingRequirements].map(([service, plugins]) => {
1141
+ const dependents = plugins.map((plugin) => plugin.name).join(", ");
1142
+ const reason = pendingProviders.has(service) ? "blocked by a dependency cycle" : "no installed plugin provides it";
1143
+ return `${service.name} required by ${dependents} (${reason})`;
1144
+ });
1145
+ return /* @__PURE__ */ new Error(`Unable to resolve plugin service dependencies: ${lines.join("; ")}.`);
1146
+ }
1147
+ var PluginRegistry = class {
1148
+ context;
1149
+ parent;
1150
+ logHandler;
1151
+ plugins = [];
1152
+ services = /* @__PURE__ */ new Map();
1153
+ constructor(context, parent, logHandler) {
1154
+ this.context = context;
1155
+ this.parent = parent;
1156
+ this.logHandler = logHandler;
1157
+ }
1158
+ install(plugin, ...args) {
1159
+ this.plugins.push({
1160
+ plugin,
1161
+ args
1162
+ });
1163
+ }
1164
+ provide(service, instance) {
1165
+ if (this.services.has(service)) throw new Error(`Service ${service.name} has already been provided in this context.`);
1166
+ if (implementsESNextDisposable(instance) && !isDisposable(instance)) throw new Error(`
1167
+ Service ${service.name} implements ESNext Disposable but not Fraq Disposable.
1168
+ Please explicitly import the interface like this:
1169
+
1170
+ import type { Disposable } from '@fraqjs/fraq';
1171
+
1172
+ and implement the dispose method to clean up resources when the context stops.
1173
+ `.trim());
1174
+ this.services.set(service, instance);
1125
1175
  }
1126
- acceptsParentEvent(type, event) {
1127
- if (!this.filter) return true;
1128
- const predicate = this.filter[type];
1129
- return predicate?.(event) === true;
1176
+ resolve(service) {
1177
+ const instance = this.tryResolve(service);
1178
+ if (instance === void 0) throw new Error(`Service ${service.name} has not been provided.`);
1179
+ return instance;
1130
1180
  }
1131
- async applyPlugins(sortedPlugins) {
1181
+ tryResolve(service) {
1182
+ if (this.services.has(service)) return this.services.get(service);
1183
+ return this.parent?.tryResolve(service);
1184
+ }
1185
+ isProvided(service) {
1186
+ return this.tryResolve(service) !== void 0;
1187
+ }
1188
+ async apply() {
1189
+ const sortedPlugins = this.sortPlugins();
1132
1190
  for (const installedPlugin of sortedPlugins) {
1133
1191
  const { plugin, args } = installedPlugin;
1134
1192
  const providedBeforeApply = new Set(this.services.keys());
@@ -1140,39 +1198,31 @@ and implement the dispose method to clean up resources when the context stops.
1140
1198
  if (plugin.provides) for (const service of plugin.provides) providedServices.push(service.name);
1141
1199
  if (requiredServices.length > 0) applyingMessage += `, requires: [${requiredServices.join(", ")}]`;
1142
1200
  if (providedServices.length > 0) applyingMessage += `, provides: [${providedServices.join(", ")}]`;
1143
- this.logger.info(applyingMessage);
1201
+ this.context.logger.info(applyingMessage);
1144
1202
  await plugin.apply(this.getPluginContext(installedPlugin), ...args);
1145
1203
  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.`);
1146
1204
  }
1205
+ return sortedPlugins;
1147
1206
  }
1148
- async recursiveApplyPlugins(appliedContextPlugins, startingContexts) {
1149
- if (this.state === "started") return;
1150
- if (this.state === "starting" && this.startPromise) {
1151
- await this.startPromise;
1152
- return;
1153
- }
1154
- if (this.state === "stopping") throw new Error(`Context "${this.name}" cannot be started while it is stopping.`);
1155
- if (this.state === "stopped") throw new Error(`Context "${this.name}" cannot be restarted after it has been stopped.`);
1156
- if (this.state === "idle") this.state = "starting";
1157
- startingContexts.push(this);
1158
- const sortedPlugins = this.sortPlugins();
1159
- await this.applyPlugins(sortedPlugins);
1160
- appliedContextPlugins.push({
1161
- context: this,
1162
- sortedPlugins
1163
- });
1164
- for (const subContext of this.subContexts.values()) await subContext.recursiveApplyPlugins(appliedContextPlugins, startingContexts);
1165
- }
1166
- async startPlugins(sortedPlugins) {
1207
+ async start(sortedPlugins) {
1167
1208
  for (const installedPlugin of sortedPlugins) {
1168
1209
  const { plugin } = installedPlugin;
1169
1210
  if (!plugin.start) continue;
1170
- this.logger.debug(`Plugin ${plugin.name} is starting...`);
1211
+ this.context.logger.debug(`Plugin ${plugin.name} is starting...`);
1171
1212
  await plugin.start(this.getPluginContext(installedPlugin));
1172
1213
  }
1173
1214
  }
1174
- async recursiveStartPlugins(appliedContextPlugins) {
1175
- for (const { context, sortedPlugins } of appliedContextPlugins) await context.startPlugins(sortedPlugins);
1215
+ async disposeServices() {
1216
+ const errors = [];
1217
+ for (const service of [...this.services.values()].reverse()) {
1218
+ if (!isDisposable(service)) continue;
1219
+ try {
1220
+ await service.dispose();
1221
+ } catch (error) {
1222
+ errors.push(error);
1223
+ }
1224
+ }
1225
+ return errors;
1176
1226
  }
1177
1227
  sortPlugins() {
1178
1228
  const providers = /* @__PURE__ */ new Map();
@@ -1183,64 +1233,33 @@ and implement the dispose method to clean up resources when the context stops.
1183
1233
  }
1184
1234
  const pending = [...this.plugins];
1185
1235
  const sorted = [];
1186
- const available = /* @__PURE__ */ new Set();
1187
- for (const service of this.collectAvailableServices()) available.add(service);
1236
+ const available = new Set(this.collectAvailableServices());
1188
1237
  while (pending.length > 0) {
1189
- const availableByPendingPlugins = this.collectPendingProvidedServices(pending);
1190
- const requiredReadyIndex = pending.findIndex(({ plugin }) => this.areRequiredServicesAvailable(plugin, available));
1191
- const optionalReadyIndex = pending.findIndex(({ plugin }) => this.areRequiredServicesAvailable(plugin, available) && this.areOptionalServicesReady(plugin, available, availableByPendingPlugins));
1238
+ const availableByPendingPlugins = /* @__PURE__ */ new Map();
1239
+ for (const installedPlugin of pending) for (const service of installedPlugin.plugin.provides ?? []) availableByPendingPlugins.set(service, installedPlugin);
1240
+ const requiredReadyIndex = pending.findIndex(({ plugin }) => areRequiredServicesAvailable(plugin, available));
1241
+ const optionalReadyIndex = pending.findIndex(({ plugin }) => areRequiredServicesAvailable(plugin, available) && areOptionalServicesReady(plugin, available, availableByPendingPlugins));
1192
1242
  const nextIndex = optionalReadyIndex === -1 ? requiredReadyIndex : optionalReadyIndex;
1193
- if (nextIndex === -1) throw this.createUnresolvablePluginError(pending, available);
1243
+ if (nextIndex === -1) throw createUnresolvablePluginError(pending, available);
1194
1244
  const [next] = pending.splice(nextIndex, 1);
1195
1245
  sorted.push(next);
1196
1246
  for (const service of next.plugin.provides ?? []) available.add(service);
1197
1247
  }
1198
1248
  return sorted;
1199
1249
  }
1200
- collectPendingProvidedServices(pending) {
1201
- const providedServices = /* @__PURE__ */ new Map();
1202
- for (const installedPlugin of pending) for (const service of installedPlugin.plugin.provides ?? []) providedServices.set(service, installedPlugin);
1203
- return providedServices;
1204
- }
1205
- areRequiredServicesAvailable(plugin, available) {
1206
- return (plugin.requires ?? []).every((service) => available.has(service));
1207
- }
1208
- areOptionalServicesReady(plugin, available, pendingProviders) {
1209
- return (plugin.optionalRequires ?? []).every((service) => {
1210
- if (available.has(service)) return true;
1211
- const pendingProvider = pendingProviders.get(service);
1212
- return pendingProvider === void 0 || pendingProvider.plugin === plugin;
1213
- });
1214
- }
1215
1250
  collectAvailableServices() {
1216
1251
  const services = [...this.services.keys()];
1217
1252
  if (this.parent) services.push(...this.parent.collectAvailableServices());
1218
1253
  return services;
1219
1254
  }
1220
- createUnresolvablePluginError(pending, available) {
1221
- const missingRequirements = /* @__PURE__ */ new Map();
1222
- const pendingProviders = /* @__PURE__ */ new Set();
1223
- for (const { plugin } of pending) for (const service of plugin.provides ?? []) pendingProviders.add(service);
1224
- for (const { plugin } of pending) for (const service of plugin.requires ?? []) if (!available.has(service)) {
1225
- const plugins = missingRequirements.get(service) ?? [];
1226
- plugins.push(plugin);
1227
- missingRequirements.set(service, plugins);
1228
- }
1229
- const lines = [...missingRequirements].map(([service, plugins]) => {
1230
- const dependents = plugins.map((plugin) => plugin.name).join(", ");
1231
- const reason = pendingProviders.has(service) ? "blocked by a dependency cycle" : "no installed plugin provides it";
1232
- return `${service.name} required by ${dependents} (${reason})`;
1233
- });
1234
- return /* @__PURE__ */ new Error(`Unable to resolve plugin service dependencies: ${lines.join("; ")}.`);
1235
- }
1236
1255
  getPluginContext(installedPlugin) {
1237
1256
  installedPlugin.proxy ??= this.createProxyContextForPlugin(installedPlugin.plugin);
1238
1257
  return installedPlugin.proxy;
1239
1258
  }
1240
1259
  createProxyContextForPlugin(plugin) {
1241
- const proxyLogger = new Logger((message) => this.logHandler?.(message), `plugin:${this.name ? `${this.name}/` : ""}${plugin.name}`);
1242
- const proxyRouter = this.router.withMeta({
1243
- context: this.name,
1260
+ const proxyLogger = new Logger((message) => this.logHandler?.(message), `plugin:${this.context.name ? `${this.context.name}/` : ""}${plugin.name}`);
1261
+ const proxyRouter = this.context.router.withMeta({
1262
+ context: this.context.name,
1244
1263
  plugin: plugin.name
1245
1264
  });
1246
1265
  let proxyInjections;
@@ -1252,115 +1271,221 @@ and implement the dispose method to clean up resources when the context stops.
1252
1271
  proxyInjections ??= {};
1253
1272
  for (const [key, service] of Object.entries(plugin.optionalInject)) proxyInjections[key] = this.tryResolve(service);
1254
1273
  }
1255
- return new Proxy(this, { get(target, prop, receiver) {
1274
+ return new Proxy(this.context, { get(target, prop, receiver) {
1256
1275
  if (prop === "logger") return proxyLogger;
1257
- else if (prop === "router") return proxyRouter;
1258
- else if (proxyInjections && prop in proxyInjections) return proxyInjections[prop];
1259
- else return Reflect.get(target, prop, receiver);
1276
+ if (prop === "router") return proxyRouter;
1277
+ if (proxyInjections && prop in proxyInjections) return proxyInjections[prop];
1278
+ return Reflect.get(target, prop, receiver);
1260
1279
  } });
1261
1280
  }
1262
- startEventSource(eventSource) {
1263
- const runtime = this.eventSourceRuntimes.get(eventSource);
1264
- if (!runtime) return;
1265
- runtime.task = this.runEventSource(eventSource, runtime);
1281
+ };
1282
+ //#endregion
1283
+ //#region src/core/context/timers.ts
1284
+ var TimerRegistry = class {
1285
+ contextName;
1286
+ logger;
1287
+ getState;
1288
+ timers = /* @__PURE__ */ new Set();
1289
+ constructor(contextName, logger, getState) {
1290
+ this.contextName = contextName;
1291
+ this.logger = logger;
1292
+ this.getState = getState;
1266
1293
  }
1267
- async runEventSource(eventSource, runtime) {
1268
- let reconnectDelay = this.initialReconnectDelayMs;
1269
- let reconnectAttempt = 1;
1270
- while (this.state === "started") {
1271
- try {
1272
- this.logger.debug(`Connecting ${eventSource.name ?? "event source"} (attempt=${reconnectAttempt})`);
1273
- const subscription = await eventSource.start((event) => {
1274
- try {
1275
- if (this.state !== "started") return;
1276
- this.eventBus.emit(event.event_type, event);
1277
- } catch (error) {
1278
- this.logger.error("Error handling event stream event", error);
1279
- }
1280
- });
1281
- if (this.state !== "started") {
1282
- await subscription.stop();
1283
- break;
1294
+ get hasTimers() {
1295
+ return this.timers.size > 0;
1296
+ }
1297
+ timeout(delayMs, callback) {
1298
+ this.assertCanScheduleTimer();
1299
+ const timeout = setTimeout(() => {
1300
+ this.timers.delete(timeout);
1301
+ this.runTimerCallback(callback);
1302
+ }, delayMs);
1303
+ this.timers.add(timeout);
1304
+ return timeout;
1305
+ }
1306
+ interval(intervalMs, callback) {
1307
+ this.assertCanScheduleTimer();
1308
+ const interval = setInterval(() => {
1309
+ this.runTimerCallback(callback);
1310
+ }, intervalMs);
1311
+ this.timers.add(interval);
1312
+ return interval;
1313
+ }
1314
+ clear() {
1315
+ for (const timer of this.timers) clearTimeout(timer);
1316
+ this.timers.clear();
1317
+ }
1318
+ assertCanScheduleTimer() {
1319
+ const state = this.getState();
1320
+ if (state === "stopping") throw new Error(`Context "${this.contextName}" cannot schedule timers while it is stopping.`);
1321
+ if (state === "stopped") throw new Error(`Context "${this.contextName}" cannot schedule timers after it has stopped.`);
1322
+ }
1323
+ async runTimerCallback(callback) {
1324
+ const state = this.getState();
1325
+ if (state === "stopping" || state === "stopped") return;
1326
+ try {
1327
+ await callback();
1328
+ } catch (error) {
1329
+ this.logger.error("Error handling timer callback", error);
1330
+ }
1331
+ }
1332
+ };
1333
+ //#endregion
1334
+ //#region src/core/context/index.ts
1335
+ var Context = class Context {
1336
+ router = new Router();
1337
+ logger;
1338
+ name;
1339
+ routeActivationResolver;
1340
+ client;
1341
+ baseClient;
1342
+ parent;
1343
+ filter;
1344
+ eventBus = mitt();
1345
+ subContexts = /* @__PURE__ */ new Map();
1346
+ parentEventForwarder;
1347
+ logHandler;
1348
+ plugins;
1349
+ apiHooks;
1350
+ eventSources;
1351
+ timers;
1352
+ lifecycle;
1353
+ constructor(baseClient, options, name, parent, filter) {
1354
+ this.baseClient = baseClient;
1355
+ this.logHandler = options?.logHandler ?? parent?.logHandler;
1356
+ this.name = name ?? "root";
1357
+ this.logger = new Logger((message) => this.logHandler?.(message), `context:${this.name}`);
1358
+ this.routeActivationResolver = createContextRouteActivationResolver(options?.routing, parent?.routeActivationResolver);
1359
+ this.router.setActivationResolver(this.routeActivationResolver);
1360
+ this.parent = parent;
1361
+ this.filter = filter;
1362
+ if (parent) {
1363
+ this.parentEventForwarder = (type, event) => {
1364
+ if (this.filter) {
1365
+ const predicate = this.filter[type];
1366
+ if (predicate?.(event) !== true) return;
1284
1367
  }
1285
- runtime.subscription = subscription;
1286
- this.logger.info(`${eventSource.name ?? "Event source"} connected`);
1287
- reconnectDelay = this.initialReconnectDelayMs;
1288
- reconnectAttempt = 1;
1289
- await subscription.closed;
1290
- if (runtime.subscription === subscription) runtime.subscription = void 0;
1291
- if (this.state !== "started") break;
1292
- this.logger.warn(`${eventSource.name ?? "Event source"} disconnected; reconnecting in ${reconnectDelay}ms`);
1368
+ this.eventBus.emit(type, event);
1369
+ };
1370
+ parent.eventBus.on("*", this.parentEventForwarder);
1371
+ }
1372
+ const getState = () => this.lifecycle.state;
1373
+ this.plugins = new PluginRegistry(this, parent?.plugins, this.logHandler);
1374
+ this.apiHooks = new ApiHookRegistry(baseClient, parent?.apiHooks, this.name, getState);
1375
+ this.client = this.apiHooks.client;
1376
+ this.timers = new TimerRegistry(this.name, this.logger, getState);
1377
+ this.eventSources = new EventSourceRegistry(options?.reconnect, this.logger, getState, (event) => {
1378
+ this.eventBus.emit(event.event_type, event);
1379
+ });
1380
+ this.lifecycle = new LifecycleManager(this.name, this.plugins, this.timers, this.eventSources, this.apiHooks, () => {
1381
+ if (this.parent && this.parentEventForwarder) this.parent.eventBus.off("*", this.parentEventForwarder);
1382
+ });
1383
+ parent?.lifecycle.addChild(this.lifecycle);
1384
+ this.eventBus.on("message_receive", async ({ self_id, data: message }) => {
1385
+ try {
1386
+ if (this.lifecycle.state === "stopping" || this.lifecycle.state === "stopped") return;
1387
+ await this.router.dispatch(this.createSession(self_id, message), message);
1293
1388
  } catch (error) {
1294
- if (this.state !== "started") break;
1295
- this.logger.error(`Error connecting ${eventSource.name ?? "event source"}; reconnecting in ${reconnectDelay}ms`, error);
1389
+ this.logger.error(`Error routing command (scene=${message.message_scene} peer=${message.peer_id} sender=${message.sender_id} seq=${message.message_seq})`, error);
1296
1390
  }
1297
- await this.waitForReconnectDelay(runtime, reconnectDelay);
1298
- reconnectDelay = Math.min(reconnectDelay * 2, this.maxReconnectDelayMs);
1299
- reconnectAttempt += 1;
1300
- }
1301
- }
1302
- waitForReconnectDelay(runtime, delay) {
1303
- return new Promise((resolve) => {
1304
- runtime.resolveReconnectTimer = resolve;
1305
- runtime.reconnectTimer = setTimeout(() => {
1306
- runtime.reconnectTimer = void 0;
1307
- runtime.resolveReconnectTimer = void 0;
1308
- resolve();
1309
- }, delay);
1310
1391
  });
1311
1392
  }
1312
- resolveReconnectDelay(runtime) {
1313
- if (runtime.reconnectTimer) {
1314
- clearTimeout(runtime.reconnectTimer);
1315
- runtime.reconnectTimer = void 0;
1393
+ on(type, handler) {
1394
+ const wrappedHandler = async (event) => {
1395
+ try {
1396
+ if (this.lifecycle.state === "stopping" || this.lifecycle.state === "stopped") return;
1397
+ await handler(event);
1398
+ } catch (error) {
1399
+ this.logger.error(`Error handling event ${type}`, error);
1400
+ }
1401
+ };
1402
+ this.eventBus.on(type, wrappedHandler);
1403
+ return () => {
1404
+ this.eventBus.off(type, wrappedHandler);
1405
+ };
1406
+ }
1407
+ install(plugin, ...args) {
1408
+ this.plugins.install(plugin, ...args);
1409
+ }
1410
+ installEventSource(eventSource) {
1411
+ this.eventSources.install(eventSource);
1412
+ }
1413
+ hookApi(endpointOrHook, hook) {
1414
+ return this.apiHooks.register(endpointOrHook, hook);
1415
+ }
1416
+ provide(service, instance) {
1417
+ this.plugins.provide(service, instance);
1418
+ }
1419
+ resolve(service) {
1420
+ return this.plugins.resolve(service);
1421
+ }
1422
+ tryResolve(service) {
1423
+ return this.plugins.tryResolve(service);
1424
+ }
1425
+ isProvided(service) {
1426
+ return this.plugins.isProvided(service);
1427
+ }
1428
+ fork(name, filter) {
1429
+ if (this.subContexts.has(name)) {
1430
+ if (filter) throw new Error(`Sub context "${name}" already exists, so the provided filter cannot be applied. Please use fork('${name}') without a filter to get the existing subcontext.`);
1431
+ return this.subContexts.get(name);
1316
1432
  }
1317
- const resolve = runtime.resolveReconnectTimer;
1318
- runtime.resolveReconnectTimer = void 0;
1319
- resolve?.();
1433
+ const subContext = new Context(this.baseClient, void 0, name, this, filter);
1434
+ this.subContexts.set(name, subContext);
1435
+ return subContext;
1320
1436
  }
1321
- createHookClient() {
1322
- return new Proxy(this.baseClient, { get: (target, prop, receiver) => {
1323
- if (typeof prop === "string" && prop.includes("_")) return (params) => this.callHookedApi(prop, params);
1324
- return Reflect.get(target, prop, receiver);
1325
- } });
1437
+ timeout(delayMs, callback) {
1438
+ return this.timers.timeout(delayMs, callback);
1326
1439
  }
1327
- wrapAnyApiHook(hook) {
1328
- return (params, next, call) => {
1329
- return hook(call, (nextParams = params) => next(nextParams));
1330
- };
1440
+ interval(intervalMs, callback) {
1441
+ return this.timers.interval(intervalMs, callback);
1331
1442
  }
1332
- async callHookedApi(endpoint, params) {
1333
- const hooks = this.collectApiHooks(endpoint);
1334
- const dispatch = async (index, currentParams) => {
1335
- const hook = hooks[index];
1336
- if (!hook) return await this.callBaseApi(endpoint, currentParams);
1337
- let nextCalled = false;
1338
- return await hook(currentParams, async (nextParams = currentParams) => {
1339
- if (nextCalled) throw new Error(`API hook for endpoint ${endpoint} called next() multiple times.`);
1340
- nextCalled = true;
1341
- return await dispatch(index + 1, nextParams);
1342
- }, {
1343
- endpoint,
1344
- params: currentParams
1345
- });
1443
+ createSession(selfId, message) {
1444
+ return {
1445
+ selfId,
1446
+ raw: message,
1447
+ reply: async (textOrSegments, options) => {
1448
+ const actualSegments = [];
1449
+ if (typeof textOrSegments === "string") actualSegments.push({
1450
+ type: "text",
1451
+ data: { text: textOrSegments }
1452
+ });
1453
+ else actualSegments.push(...textOrSegments);
1454
+ if (options?.withMention && message.message_scene === "group") actualSegments.unshift(seg.mention(message.sender_id));
1455
+ if (options?.withQuote) actualSegments.unshift(seg.reply(message.message_seq));
1456
+ switch (message.message_scene) {
1457
+ case "friend": {
1458
+ const { message_seq } = await this.client.send_private_message({
1459
+ user_id: message.peer_id,
1460
+ message: actualSegments
1461
+ });
1462
+ return { messageSeq: message_seq };
1463
+ }
1464
+ case "group": {
1465
+ const { message_seq } = await this.client.send_group_message({
1466
+ group_id: message.peer_id,
1467
+ message: actualSegments
1468
+ });
1469
+ return { messageSeq: message_seq };
1470
+ }
1471
+ }
1472
+ return { messageSeq: 0 };
1473
+ },
1474
+ reaction: async (type, reactionId) => {
1475
+ if (message.message_scene === "group") await this.client.send_group_message_reaction({
1476
+ group_id: message.peer_id,
1477
+ message_seq: message.message_seq,
1478
+ reaction_type: type,
1479
+ reaction: reactionId
1480
+ });
1481
+ }
1346
1482
  };
1347
- return await dispatch(0, params);
1348
1483
  }
1349
- collectApiHooks(endpoint) {
1350
- const hooks = [];
1351
- let context = this;
1352
- while (context) {
1353
- for (const entry of context.apiHookEntries.toReversed()) if (entry.endpoint === void 0 || entry.endpoint === endpoint) hooks.push(entry.hook);
1354
- context = context.parent;
1355
- }
1356
- return hooks;
1484
+ async start() {
1485
+ await this.lifecycle.start();
1357
1486
  }
1358
- async callBaseApi(endpoint, params) {
1359
- const callApi = this.baseClient.callApi;
1360
- if (typeof callApi === "function") return await callApi.call(this.baseClient, endpoint, params);
1361
- const method = this.baseClient[endpoint];
1362
- if (typeof method !== "function") throw new Error(`Milky client does not implement API endpoint ${endpoint}.`);
1363
- return await method.call(this.baseClient, params);
1487
+ async stop() {
1488
+ await this.lifecycle.stop();
1364
1489
  }
1365
1490
  static fromUrl(baseUrl, options) {
1366
1491
  const client = createMilkyClient(baseUrl, { accessToken: options?.accessToken });
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@fraqjs/fraq",
3
3
  "type": "module",
4
- "version": "0.13.0",
4
+ "version": "0.13.2",
5
5
  "description": "Milky Bot framework",
6
6
  "files": [
7
7
  "dist"