@talex-touch/utils 1.0.18 → 1.0.21

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.
Files changed (80) hide show
  1. package/channel/index.ts +49 -1
  2. package/common/index.ts +2 -0
  3. package/common/search/gather.ts +45 -0
  4. package/common/search/index.ts +67 -0
  5. package/common/storage/constants.ts +16 -2
  6. package/common/storage/entity/index.ts +2 -1
  7. package/common/storage/entity/openers.ts +32 -0
  8. package/common/storage/entity/shortcut-settings.ts +22 -0
  9. package/common/storage/shortcut-storage.ts +58 -0
  10. package/common/utils/file.ts +62 -0
  11. package/common/{utils.ts → utils/index.ts} +14 -2
  12. package/common/utils/polling.ts +184 -0
  13. package/common/utils/task-queue.ts +108 -0
  14. package/common/utils/time.ts +374 -0
  15. package/core-box/README.md +8 -8
  16. package/core-box/builder/index.ts +6 -0
  17. package/core-box/builder/tuff-builder.example.ts.bak +258 -0
  18. package/core-box/builder/tuff-builder.ts +1162 -0
  19. package/core-box/index.ts +5 -2
  20. package/core-box/run-tests.sh +7 -0
  21. package/core-box/search.ts +1 -536
  22. package/core-box/tuff/index.ts +6 -0
  23. package/core-box/tuff/tuff-dsl.ts +1412 -0
  24. package/electron/clipboard-helper.ts +199 -0
  25. package/electron/env-tool.ts +36 -2
  26. package/electron/file-parsers/index.ts +8 -0
  27. package/electron/file-parsers/parsers/text-parser.ts +109 -0
  28. package/electron/file-parsers/registry.ts +92 -0
  29. package/electron/file-parsers/types.ts +58 -0
  30. package/electron/index.ts +3 -0
  31. package/eventbus/index.ts +0 -7
  32. package/index.ts +3 -1
  33. package/package.json +4 -29
  34. package/plugin/channel.ts +48 -16
  35. package/plugin/index.ts +194 -30
  36. package/plugin/log/types.ts +11 -0
  37. package/plugin/node/index.ts +4 -0
  38. package/plugin/node/logger-manager.ts +113 -0
  39. package/plugin/{log → node}/logger.ts +41 -7
  40. package/plugin/plugin-source.ts +74 -0
  41. package/plugin/preload.ts +5 -15
  42. package/plugin/providers/index.ts +2 -0
  43. package/plugin/providers/registry.ts +47 -0
  44. package/plugin/providers/types.ts +54 -0
  45. package/plugin/risk/index.ts +1 -0
  46. package/plugin/risk/types.ts +20 -0
  47. package/plugin/sdk/enum/bridge-event.ts +4 -0
  48. package/plugin/sdk/enum/index.ts +1 -0
  49. package/plugin/sdk/hooks/bridge.ts +68 -0
  50. package/plugin/sdk/hooks/index.ts +2 -1
  51. package/plugin/sdk/hooks/life-cycle.ts +2 -4
  52. package/plugin/sdk/index.ts +2 -0
  53. package/plugin/sdk/storage.ts +84 -0
  54. package/plugin/sdk/types.ts +2 -2
  55. package/plugin/sdk/window/index.ts +5 -3
  56. package/preload/index.ts +2 -0
  57. package/preload/loading.ts +15 -0
  58. package/preload/renderer.ts +41 -0
  59. package/renderer/hooks/arg-mapper.ts +79 -0
  60. package/renderer/hooks/index.ts +2 -0
  61. package/renderer/hooks/initialize.ts +198 -0
  62. package/renderer/index.ts +3 -0
  63. package/renderer/storage/app-settings.ts +2 -0
  64. package/renderer/storage/base-storage.ts +1 -0
  65. package/renderer/storage/openers.ts +11 -0
  66. package/renderer/touch-sdk/env.ts +106 -0
  67. package/renderer/touch-sdk/index.ts +108 -0
  68. package/renderer/touch-sdk/terminal.ts +85 -0
  69. package/renderer/touch-sdk/utils.ts +61 -0
  70. package/search/levenshtein-utils.ts +39 -0
  71. package/search/types.ts +16 -16
  72. package/types/index.ts +2 -1
  73. package/types/modules/base.ts +146 -0
  74. package/types/modules/index.ts +4 -0
  75. package/types/modules/module-lifecycle.ts +148 -0
  76. package/types/modules/module-manager.ts +99 -0
  77. package/types/modules/module.ts +112 -0
  78. package/types/touch-app-core.ts +16 -93
  79. package/core-box/types.ts +0 -384
  80. package/plugin/log/logger-manager.ts +0 -60
package/channel/index.ts CHANGED
@@ -21,6 +21,7 @@ export interface ITouchChannel {
21
21
  regChannel(type: ChannelType, eventName: string, callback: (data: StandardChannelData) => any): () => void
22
22
 
23
23
  /**
24
+ * @deprecated Use sendMain instead
24
25
  * Send a message to a channel
25
26
  * @param type {@link ChannelType} The type of channel
26
27
  * @param eventName {string} The name of event, must be unique in the channel {@link ChannelType}
@@ -29,6 +30,7 @@ export interface ITouchChannel {
29
30
  send(type: ChannelType, eventName: string, arg?: any): Promise<any>
30
31
 
31
32
  /**
33
+ * @deprecated Use sendToMain instead
32
34
  * Send a message to a channel with settled window
33
35
  * @param win {@link Electron.BrowserWindow} the window you want to sent
34
36
  * @param type {@link ChannelType} The type of channel
@@ -36,6 +38,51 @@ export interface ITouchChannel {
36
38
  * @param arg {any} The arguments of the message
37
39
  */
38
40
  sendTo(win: Electron.BrowserWindow, type: ChannelType, eventName: string, arg: any): Promise<any>
41
+
42
+ /**
43
+ * Send a message to main process
44
+ * @param eventName {string} The name of event, must be unique in the channel {@link ChannelType}
45
+ * @param arg {any} The arguments of the message
46
+ */
47
+ sendMain(eventName: string, arg?: any): Promise<any>
48
+
49
+ /**
50
+ * Send a message to main process with settled window
51
+ * @param win {@link Electron.BrowserWindow} the window you want to sent
52
+ * @param eventName {string} The name of event, must be unique in the channel {@link ChannelType}
53
+ * @param arg {any} The arguments of the message
54
+ */
55
+ sendToMain(win: Electron.BrowserWindow, eventName: string, arg?: any): Promise<any>
56
+
57
+ /**
58
+ * Send a message to all plugin process with settled window
59
+ * @param eventName {string} The name of event, must be unique in the channel {@link ChannelType}
60
+ * @param arg {any} The arguments of the message
61
+ */
62
+ sendPlugin(pluginName: string, eventName: string, arg?: any): Promise<any>
63
+
64
+ /**
65
+ * Send a message to plugin process with settled window
66
+ * @param pluginName {string} The name of plugin
67
+ * @param eventName {string} The name of event, must be unique in the channel {@link ChannelType}
68
+ * @param arg {any} The arguments of the message
69
+ */
70
+ sendToPlugin(pluginName: string, eventName: string, arg?: any): Promise<any>
71
+
72
+ /**
73
+ * Request a encrypted name key. This key cannot decrypted to get the original name.
74
+ * After use, you should revoke this key.
75
+ * @description Request a encrypted name key, and return the encrypted key
76
+ * @param name {string} The name of key
77
+ */
78
+ requestKey: (name: string) => string
79
+
80
+ /**
81
+ * Unregister a encrypted name key
82
+ * @description Unregister a encrypted name key, and return the encrypted key
83
+ * @param key {string} The encrypted key
84
+ */
85
+ revokeKey: (key: string) => boolean
39
86
  }
40
87
 
41
88
  export interface ITouchClientChannel {
@@ -84,6 +131,7 @@ export interface RawChannelHeaderData {
84
131
  status: "reply" | "request";
85
132
  type: ChannelType;
86
133
  _originData?: any;
134
+ uniqueKey?: string
87
135
  event?: Electron.IpcMainEvent | Electron.IpcRendererEvent;
88
136
  }
89
137
 
@@ -105,4 +153,4 @@ export interface StandardChannelData extends RawStandardChannelData {
105
153
 
106
154
  export type IChannelData = any //boolean | number | string | null | undefined | {
107
155
  // [prop: string]: any
108
- // }
156
+ // }
package/common/index.ts CHANGED
@@ -1,2 +1,4 @@
1
1
  export * from './storage/index'
2
2
  export * from './utils'
3
+ export * from './search'
4
+ export * from '../electron/file-parsers'
@@ -0,0 +1,45 @@
1
+ import { TuffUpdate } from "."
2
+
3
+ /**
4
+ * @interface ITuffGatherOptions
5
+ * @description Configuration options for the search result aggregator.
6
+ */
7
+ export interface ITuffGatherOptions {
8
+ /**
9
+ * The number of providers to run in parallel.
10
+ * @default 3
11
+ */
12
+ concurrency?: number
13
+ /**
14
+ * The time to wait for more results before flushing the buffer.
15
+ * @default 100
16
+ */
17
+ coalesceGapMs?: number
18
+ /**
19
+ * A shorter grace period for the first batch to ensure a quick initial response.
20
+ * @default 50
21
+ */
22
+ firstBatchGraceMs?: number
23
+ /**
24
+ * A small debounce delay for the push function to avoid rapid-fire updates.
25
+ * @default 10
26
+ */
27
+ debouncePushMs?: number
28
+ /**
29
+ * The maximum time to wait for a single provider to return results.
30
+ * @default 5000
31
+ */
32
+ taskTimeoutMs?: number
33
+ }
34
+
35
+ /**
36
+ * Defines the type signature for the real-time update callback function.
37
+ * @param update - The data object containing update information.
38
+ */
39
+ export type TuffAggregatorCallback = (update: TuffUpdate) => void
40
+
41
+ export interface IGatherController {
42
+ abort: () => void
43
+ promise: Promise<number>
44
+ signal: AbortSignal
45
+ }
@@ -0,0 +1,67 @@
1
+ import { ISearchProvider, TuffQuery, TuffSearchResult } from '@talex-touch/utils'
2
+
3
+ export * from './gather'
4
+
5
+ /**
6
+ * Represents a single update pushed from the search-gatherer.
7
+ * It provides a snapshot of the search progress at a point in time.
8
+ */
9
+ export interface TuffUpdate {
10
+ /**
11
+ * New search results from the current push batch.
12
+ * Each element is a complete TuffSearchResult from a provider.
13
+ */
14
+ newResults: TuffSearchResult[]
15
+ /**
16
+ * Total number of items aggregated so far.
17
+ */
18
+ totalCount: number
19
+ /**
20
+ * Flag indicating whether all search tasks (both default and fallback queues) have completed.
21
+ */
22
+ isDone: boolean
23
+ /**
24
+ * Flag indicating whether the search was cancelled.
25
+ */
26
+ cancelled?: boolean
27
+ /**
28
+ * Statistics about the performance of each search provider.
29
+ */
30
+ sourceStats?: TuffSearchResult['sources']
31
+ }
32
+
33
+
34
+ /**
35
+ * Search Engine Interface (formerly ISearchEngine)
36
+ *
37
+ * Defines the core functionality of the search aggregator and orchestrator.
38
+ */
39
+ export interface ISearchEngine<C> {
40
+ /**
41
+ * Registers a search provider with the engine.
42
+ * @param provider - An instance of ISearchProvider.
43
+ */
44
+ registerProvider(provider: ISearchProvider<C>): void
45
+
46
+ /**
47
+ * Unregisters a search provider by its unique ID.
48
+ * @param providerId - The unique ID of the provider to remove.
49
+ */
50
+ unregisterProvider(providerId: string): void
51
+
52
+ /**
53
+ * Executes a search across all registered and relevant providers.
54
+ * It aggregates, scores, and ranks the results.
55
+ *
56
+ * @param query - The search query object.
57
+ * @returns A promise that resolves to a TuffSearchResult object,
58
+ * containing the ranked items and metadata about the search operation.
59
+ */
60
+ search(query: TuffQuery): Promise<TuffSearchResult>
61
+
62
+ /**
63
+ * Performs background maintenance tasks, such as pre-heating caches,
64
+ * refreshing indexes, etc.
65
+ */
66
+ maintain(): void
67
+ }
@@ -1,3 +1,17 @@
1
1
  export enum StorageList {
2
- APP_SETTING = "app-setting.ini"
3
- }
2
+ APP_SETTING = 'app-setting.ini',
3
+ SHORTCUT_SETTING = 'shortcut-setting.ini',
4
+ OPENERS = 'openers.json'
5
+ }
6
+
7
+ /**
8
+ * Defines keys for the global configuration stored in the database `config` table.
9
+ * Using an enum prevents magic strings and ensures consistency across the application.
10
+ */
11
+ export enum ConfigKeys {
12
+ /**
13
+ * Stores the timestamp of the last successful full application scan using mdfind.
14
+ * This is used to schedule the next comprehensive scan.
15
+ */
16
+ APP_PROVIDER_LAST_MDFIND_SCAN = 'app_provider_last_mdfind_scan'
17
+ }
@@ -1 +1,2 @@
1
- export * from './app-settings'
1
+ export * from './app-settings'
2
+ export * from './openers'
@@ -0,0 +1,32 @@
1
+ export interface OpenerInfo {
2
+ /**
3
+ * Bundle identifier of the application responsible for handling the file type.
4
+ */
5
+ bundleId: string
6
+
7
+ /**
8
+ * Display name of the application.
9
+ */
10
+ name: string
11
+
12
+ /**
13
+ * Icon for the application (data URL or resolvable path).
14
+ */
15
+ logo: string
16
+
17
+ /**
18
+ * Absolute path of the application bundle/executable.
19
+ */
20
+ path?: string
21
+
22
+ /**
23
+ * ISO timestamp representing when this mapping was last refreshed.
24
+ */
25
+ lastResolvedAt?: string
26
+ }
27
+
28
+ export type OpenersMap = Record<string, OpenerInfo>
29
+
30
+ const _openersOriginData: OpenersMap = {}
31
+
32
+ export const openersOriginData = Object.freeze(_openersOriginData)
@@ -0,0 +1,22 @@
1
+ export enum ShortcutType {
2
+ MAIN = 'main',
3
+ RENDERER = 'renderer',
4
+ }
5
+
6
+ export interface ShortcutMeta {
7
+ creationTime: number;
8
+ modificationTime: number;
9
+ author: string;
10
+ description?: string;
11
+ }
12
+
13
+ export interface Shortcut {
14
+ id: string;
15
+ accelerator: string;
16
+ type: ShortcutType;
17
+ meta: ShortcutMeta;
18
+ }
19
+
20
+ export type ShortcutSetting = Shortcut[];
21
+
22
+ export const shortcutSettingOriginData: ShortcutSetting = [];
@@ -0,0 +1,58 @@
1
+ import { StorageList } from './constants'
2
+ import { shortcutSettingOriginData, ShortcutSetting, Shortcut } from './entity/shortcut-settings'
3
+
4
+ class ShortcutStorage {
5
+ private _config: ShortcutSetting = []
6
+
7
+ constructor(private readonly storage: {
8
+ getConfig: (name: string) => any,
9
+ saveConfig: (name: string, content?: string) => void,
10
+ }) {
11
+ this.init()
12
+ }
13
+
14
+ private init() {
15
+ const config = this.storage.getConfig(StorageList.SHORTCUT_SETTING)
16
+ if (!config || !Array.isArray(config) || config.length === 0) {
17
+ this._config = [...shortcutSettingOriginData]
18
+ this._save()
19
+ } else {
20
+ this._config = config
21
+ }
22
+ }
23
+
24
+ private _save() {
25
+ this.storage.saveConfig(StorageList.SHORTCUT_SETTING, JSON.stringify(this._config, null, 2))
26
+ }
27
+
28
+ getAllShortcuts(): Shortcut[] {
29
+ return this._config
30
+ }
31
+
32
+ getShortcutById(id: string): Shortcut | undefined {
33
+ return this._config.find(s => s.id === id)
34
+ }
35
+
36
+ addShortcut(shortcut: Shortcut): boolean {
37
+ if (this.getShortcutById(shortcut.id)) {
38
+ console.warn(`Shortcut with ID ${shortcut.id} already exists.`)
39
+ return false
40
+ }
41
+ this._config.push(shortcut)
42
+ this._save()
43
+ return true
44
+ }
45
+
46
+ updateShortcutAccelerator(id: string, newAccelerator: string): boolean {
47
+ const shortcut = this.getShortcutById(id)
48
+ if (!shortcut) {
49
+ return false
50
+ }
51
+ shortcut.accelerator = newAccelerator
52
+ shortcut.meta.modificationTime = Date.now()
53
+ this._save()
54
+ return true
55
+ }
56
+ }
57
+
58
+ export default ShortcutStorage
@@ -0,0 +1,62 @@
1
+ import path from 'path-browserify'
2
+
3
+ /**
4
+ * Enum for various file types.
5
+ * @enum {string}
6
+ */
7
+ export enum FileType {
8
+ Image = 'Image',
9
+ Document = 'Document',
10
+ Audio = 'Audio',
11
+ Video = 'Video',
12
+ Archive = 'Archive',
13
+ Code = 'Code',
14
+ Text = 'Text',
15
+ Design = 'Design',
16
+ Model3D = '3D Model',
17
+ Font = 'Font',
18
+ Spreadsheet = 'Spreadsheet',
19
+ Presentation = 'Presentation',
20
+ Ebook = 'Ebook',
21
+ Other = 'Other'
22
+ }
23
+
24
+ const extensionMap: Map<FileType, Set<string>> = new Map([
25
+ [FileType.Image, new Set(['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.svg', '.ico', '.tiff'])],
26
+ [FileType.Document, new Set(['.doc', '.docx', '.pdf', '.odt', '.rtf'])],
27
+ [FileType.Audio, new Set(['.mp3', '.wav', '.ogg', '.flac', '.aac', '.m4a'])],
28
+ [FileType.Video, new Set(['.mp4', '.avi', '.mov', '.wmv', '.mkv', '.flv', '.webm'])],
29
+ [FileType.Archive, new Set(['.zip', '.rar', '.7z', '.tar', '.gz', '.bz2'])],
30
+ [FileType.Code, new Set(['.js', '.ts', '.jsx', '.tsx', '.html', '.css', '.scss', '.json', '.xml', '.java', '.py', '.c', '.cpp', '.go', '.rs', '.php', '.sh'])],
31
+ [FileType.Text, new Set(['.txt', '.md', '.log'])],
32
+ [FileType.Design, new Set(['.psd', '.ai', '.fig', '.sketch', '.xd', '.afdesign'])],
33
+ [FileType.Model3D, new Set(['.obj', '.fbx', '.stl', '.dae', '.blend', '.3ds'])],
34
+ [FileType.Font, new Set(['.ttf', '.otf', '.woff', '.woff2'])],
35
+ [FileType.Spreadsheet, new Set(['.xls', '.xlsx', '.csv', '.numbers'])],
36
+ [FileType.Presentation, new Set(['.ppt', '.pptx', '.key'])],
37
+ [FileType.Ebook, new Set(['.epub', '.mobi', '.azw'])]
38
+ ]);
39
+
40
+ /**
41
+ * Get the file type from a file path.
42
+ * @param {string} filePath - The path to the file.
43
+ * @returns {FileType} The type of the file.
44
+ */
45
+ export function getFileTypeFromPath(filePath: string): FileType {
46
+ const extension = path.extname(filePath).toLowerCase()
47
+ return getFileTypeFromExtension(extension)
48
+ }
49
+
50
+ /**
51
+ * Get the file type from a file extension.
52
+ * @param {string} extension - The file extension, including the dot.
53
+ * @returns {FileType} The type of the file.
54
+ */
55
+ export function getFileTypeFromExtension(extension: string): FileType {
56
+ for (const [type, extensions] of extensionMap.entries()) {
57
+ if (extensions.has(extension)) {
58
+ return type
59
+ }
60
+ }
61
+ return FileType.Other
62
+ }
@@ -101,7 +101,7 @@ export function structuredStrictStringify(value: unknown): string {
101
101
  if (typeof Document !== 'undefined') {
102
102
  if (val instanceof Node) return 'DOMNode';
103
103
  }
104
- if (val instanceof Error) return 'Error';
104
+ // if (val instanceof Error) return 'Error';
105
105
  if (val instanceof WeakMap) return 'WeakMap';
106
106
  if (val instanceof WeakSet) return 'WeakSet';
107
107
  if (typeof val === 'object' && val !== null && val.constructor?.name === 'Proxy') return 'Proxy';
@@ -112,7 +112,7 @@ export function structuredStrictStringify(value: unknown): string {
112
112
  function serialize(val: any, path: string): any {
113
113
  const type = getType(val);
114
114
  // Block disallowed/unsafe types and edge cases for structured-clone
115
- if (badTypes.includes(typeof val) || type === 'DOMNode' || type === 'Error' || type === 'Proxy' || type === 'WeakMap' || type === 'WeakSet' || type === 'BigInt') {
115
+ if (badTypes.includes(typeof val) || type === 'DOMNode' || type === 'Proxy' || type === 'WeakMap' || type === 'WeakSet' || type === 'BigInt') {
116
116
  throw new Error(`Cannot serialize property at path "${path}": type "${type}"`);
117
117
  }
118
118
  // JSON doesn't support undefined, skip it for values in objects, preserve in arrays as null
@@ -130,6 +130,13 @@ export function structuredStrictStringify(value: unknown): string {
130
130
  return `[Circular ~${seen.get(val)}]`; // You could just throw if you dislike this fallback!
131
131
  }
132
132
  seen.set(val, path);
133
+ if (val instanceof Error) {
134
+ return {
135
+ name: val.name,
136
+ message: val.message,
137
+ stack: val.stack,
138
+ };
139
+ }
133
140
  if (Array.isArray(val)) {
134
141
  return val.map((item, idx) => serialize(item, `${path}[${idx}]`));
135
142
  }
@@ -158,3 +165,8 @@ export function structuredStrictStringify(value: unknown): string {
158
165
 
159
166
  return JSON.stringify(serialize(value, 'root'));
160
167
  }
168
+
169
+ export { runAdaptiveTaskQueue, type AdaptiveTaskQueueOptions } from './task-queue'
170
+
171
+ export * from './time'
172
+ export * from './file'
@@ -0,0 +1,184 @@
1
+ /**
2
+ * @module polling
3
+ * A high-precision, efficient, singleton polling service for scheduling periodic tasks.
4
+ * This service uses a dynamic setTimeout approach, ensuring tasks are executed
5
+ * with high accuracy while minimizing CPU usage and battery consumption.
6
+ */
7
+
8
+ interface PollingTask {
9
+ id: string;
10
+ callback: () => void | Promise<void>;
11
+ intervalMs: number;
12
+ nextRunMs: number;
13
+ }
14
+
15
+ type TimeUnit = 'seconds' | 'minutes' | 'hours';
16
+
17
+ export class PollingService {
18
+ private static instance: PollingService;
19
+ private tasks = new Map<string, PollingTask>();
20
+ private timerId: NodeJS.Timeout | null = null;
21
+ private isRunning = false;
22
+
23
+ private constructor() {
24
+ // Private constructor to enforce singleton pattern
25
+ }
26
+
27
+ public static getInstance(): PollingService {
28
+ if (!PollingService.instance) {
29
+ PollingService.instance = new PollingService();
30
+ }
31
+ return PollingService.instance;
32
+ }
33
+
34
+ private convertToMs(interval: number, unit: TimeUnit): number {
35
+ switch (unit) {
36
+ case 'seconds':
37
+ return interval * 1000;
38
+ case 'minutes':
39
+ return interval * 60 * 1000;
40
+ case 'hours':
41
+ return interval * 60 * 60 * 1000;
42
+ default:
43
+ throw new Error(`Invalid time unit: ${unit}`);
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Registers a new periodic task with the service.
49
+ * @param id - A unique identifier for the task.
50
+ * @param callback - The function to be executed.
51
+ * @param options - The execution interval.
52
+ */
53
+ public register(
54
+ id: string,
55
+ callback: () => void | Promise<void>,
56
+ options: { interval: number; unit: TimeUnit; runImmediately?: boolean }
57
+ ): void {
58
+ if (this.tasks.has(id)) {
59
+ console.warn(`[PollingService] Task with ID '${id}' is already registered. Overwriting.`);
60
+ }
61
+
62
+ const intervalMs = this.convertToMs(options.interval, options.unit);
63
+ if (intervalMs <= 0) {
64
+ console.error(`[PollingService] Task '${id}' has an invalid interval of ${intervalMs}ms. Registration aborted.`);
65
+ return;
66
+ }
67
+
68
+ const nextRunMs = options.runImmediately ? Date.now() : Date.now() + intervalMs;
69
+
70
+ this.tasks.set(id, {
71
+ id,
72
+ callback,
73
+ intervalMs,
74
+ nextRunMs,
75
+ });
76
+
77
+ console.log(`[PollingService] Task '${id}' registered to run every ${options.interval} ${options.unit}.`);
78
+
79
+ if (this.isRunning) {
80
+ this._reschedule();
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Unregisters a task, preventing it from being executed in the future.
86
+ * @param id - The unique identifier of the task to remove.
87
+ */
88
+ public unregister(id: string): void {
89
+ if (this.tasks.delete(id)) {
90
+ console.log(`[PollingService] Task '${id}' unregistered.`);
91
+ if (this.isRunning) {
92
+ this._reschedule();
93
+ }
94
+ } else {
95
+ console.warn(`[PollingService] Attempted to unregister a non-existent task with ID '${id}'.`);
96
+ }
97
+ }
98
+
99
+ /**
100
+ * Checks if a task is already registered.
101
+ * @param id - The unique identifier of the task.
102
+ * @returns - True if the task is registered, false otherwise.
103
+ */
104
+ public isRegistered(id: string): boolean {
105
+ return this.tasks.has(id);
106
+ }
107
+
108
+ /**
109
+ * Starts the polling service.
110
+ * It's safe to call this method multiple times.
111
+ */
112
+ public start(): void {
113
+ if (this.isRunning) {
114
+ return;
115
+ }
116
+ this.isRunning = true;
117
+ console.log('[PollingService] Service started.');
118
+ this._reschedule();
119
+ }
120
+
121
+ /**
122
+ * Stops the polling service and clears all scheduled tasks.
123
+ */
124
+ public stop(): void {
125
+ if (!this.isRunning) {
126
+ return;
127
+ }
128
+ this.isRunning = false;
129
+ if (this.timerId) {
130
+ clearTimeout(this.timerId);
131
+ this.timerId = null;
132
+ }
133
+ console.log('[PollingService] Service stopped.');
134
+ }
135
+
136
+ private _reschedule(): void {
137
+ if (this.timerId) {
138
+ clearTimeout(this.timerId);
139
+ this.timerId = null;
140
+ }
141
+
142
+ if (!this.isRunning || this.tasks.size === 0) {
143
+ return;
144
+ }
145
+
146
+ const now = Date.now();
147
+ const nextTask = Array.from(this.tasks.values()).reduce((prev, curr) =>
148
+ prev.nextRunMs < curr.nextRunMs ? prev : curr
149
+ );
150
+
151
+ const delay = Math.max(0, nextTask.nextRunMs - now);
152
+ this.timerId = setTimeout(() => this._tick(), delay);
153
+ }
154
+
155
+ private async _tick(): Promise<void> {
156
+ const now = Date.now();
157
+ const tasksToRun: PollingTask[] = [];
158
+
159
+ for (const task of this.tasks.values()) {
160
+ if (task.nextRunMs <= now) {
161
+ tasksToRun.push(task);
162
+ }
163
+ }
164
+
165
+ if (tasksToRun.length > 0) {
166
+ // console.debug(`[PollingService] Executing ${tasksToRun.length} tasks.`);
167
+ for (const task of tasksToRun) {
168
+ try {
169
+ await task.callback();
170
+ } catch (error) {
171
+ console.error(`[PollingService] Error executing task '${task.id}':`, error);
172
+ }
173
+ // Update next run time based on its last scheduled run time, not 'now'.
174
+ // This prevents drift if a task takes a long time to execute.
175
+ task.nextRunMs += task.intervalMs;
176
+ }
177
+ }
178
+
179
+
180
+ this._reschedule();
181
+ }
182
+ }
183
+
184
+ export const pollingService = PollingService.getInstance();