xkit-utils 2.0.0-alpha.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.
@@ -0,0 +1,163 @@
1
+ /**
2
+ * 将平铺列表转为森林(多根树)。
3
+ * 1. 对每一项做浅拷贝后再挂 `children`,**不修改**入参 `list` 中的对象。
4
+ * 2. 浅拷贝:嵌套对象/数组仍与平铺项中的引用相同;仅 `children` 树结构是新建的。
5
+ */
6
+ export declare const buildTree: <T extends Record<string, any>>(list: T[], { idField, parentIdField }?: BuildTreeConfig) => TreeNode<T>[];
7
+
8
+ export declare interface BuildTreeConfig {
9
+ idField?: string;
10
+ parentIdField?: string;
11
+ }
12
+
13
+ declare type Constructor<T> = new (...args: any[]) => T;
14
+
15
+ /**
16
+ * 将纯文本写入系统剪贴板。
17
+ *
18
+ * 优先使用 Clipboard API(`navigator.clipboard.writeText`);在不可用或失败时
19
+ *(非安全上下文、权限被拒等)回退到 `document.execCommand('copy')` + 离屏 textarea,
20
+ * 以兼容旧浏览器与受限环境。
21
+ *
22
+ * 在非浏览器环境(如 SSR、无 DOM 的 Worker)中安全返回 `false`,不抛错。
23
+ */
24
+ export declare const copyToClipboard: (text: string) => Promise<boolean>;
25
+
26
+ export declare function createSSEConnection(options: SSEOptions): SSEConnection;
27
+
28
+ /** 多根树的深度优先遍历(前序,子节点从左到右)。 */
29
+ export declare class Forest<T extends Record<string, unknown> = Record<string, unknown>> {
30
+ readonly trees: TreeNode<T>[];
31
+ constructor(trees: TreeNode<T>[]);
32
+ [Symbol.iterator](): Generator<TreeNode<T>, void, undefined>;
33
+ /** 根节点数组(与构造时传入的引用相同)。 */
34
+ toArray(): TreeNode<T>[];
35
+ }
36
+
37
+ /**
38
+ * 将 `Record<string, string>` 的键值对调:key → value 变为 value → key。
39
+ * 若多个 key 对应同一 value,后者覆盖前者(与 `Object.fromEntries` 行为一致)。
40
+ * @param obj 键值对对象(value 须为 string,且通常唯一)
41
+ * @returns 反向映射对象,类型可推断
42
+ */
43
+ export declare function invertRecord<T extends Record<string, string>>(obj: T): {
44
+ [V in T[keyof T]]: {
45
+ [K in keyof T]: T[K] extends V ? K : never;
46
+ }[keyof T];
47
+ };
48
+
49
+ /**
50
+ * 拼接路径。去除每个路径首尾的斜杠,并确保路径之间只有一个斜杠
51
+ * @param paths 路径
52
+ * @returns 拼接后的路径
53
+ */
54
+ export declare function joinPaths(...paths: string[]): string;
55
+
56
+ /**
57
+ * 解析单个 SSE 事件块(不含块之间的空行分隔)。
58
+ */
59
+ export declare function parseSSEBlock(block: string): SSEMessage | null;
60
+
61
+ /**
62
+ * 刷新 token 的回调函数,如果刷新失败,请抛出异常
63
+ */
64
+ declare type RefreshHandler = () => Promise<void>;
65
+
66
+ export declare type RefreshOptions = {
67
+ /**
68
+ * 刷新 token 的回调函数,如果刷新失败,请抛出异常
69
+ */
70
+ refresh: RefreshHandler;
71
+ /**
72
+ * 最大刷新次数
73
+ */
74
+ maxAttempts?: number;
75
+ /**
76
+ * 冷却时间(分钟)
77
+ */
78
+ cooldownMinutes?: number;
79
+ /**
80
+ * 刷新失败回调
81
+ */
82
+ onFail?: (error?: unknown) => void;
83
+ };
84
+
85
+ export declare class ServiceProvider {
86
+ private cache;
87
+ private factories;
88
+ private defaultArgs;
89
+ constructor(defaultArgs?: any[]);
90
+ register<T>(clazz: Constructor<T>, factory: (provider: ServiceProvider) => T): void;
91
+ get<T>(clazz: Constructor<T>): T;
92
+ clear(): void;
93
+ }
94
+
95
+ export declare class SSEConnection {
96
+ private options;
97
+ private retryCount;
98
+ private isConnecting;
99
+ private isConnected;
100
+ private abortController?;
101
+ constructor(options: SSEOptions);
102
+ setHeader(key: string, value: string): void;
103
+ connect(): Promise<void>;
104
+ private handleMessage;
105
+ private processStream;
106
+ disconnect(): void;
107
+ get isActive(): boolean;
108
+ }
109
+
110
+ export declare interface SSEMessage {
111
+ data?: string;
112
+ event?: string;
113
+ id?: string;
114
+ retry?: number;
115
+ }
116
+
117
+ export declare interface SSEOptions {
118
+ url: string;
119
+ headers?: Record<string, string>;
120
+ method?: "GET" | "POST";
121
+ body?: unknown;
122
+ onMessage?: (message: SSEMessage) => void;
123
+ onOpen?: () => void;
124
+ onError?: (error: Error) => void;
125
+ onClose?: () => void;
126
+ retryAttempts?: number;
127
+ retryDelay?: number;
128
+ onException?: (error: unknown) => void;
129
+ }
130
+
131
+ export declare class TokenRefreshManager {
132
+ private readonly refresh;
133
+ private readonly maxAttempts;
134
+ private readonly cooldownMinutes;
135
+ private readonly onFail?;
136
+ private tasks;
137
+ private refreshing;
138
+ private attemptCount;
139
+ private lastRefreshAt;
140
+ constructor(options: RefreshOptions);
141
+ private isCooldownExpired;
142
+ private markAttempt;
143
+ private clearTasks;
144
+ private runTasks;
145
+ private resetAttempts;
146
+ /** 复位当前实例内部的刷新队列与计数 */
147
+ resetState(): void;
148
+ /** 统一处理失败收口:复位状态并触发失败回调 */
149
+ handleFail(error?: unknown): void;
150
+ enqueueTask(task: () => void): void;
151
+ /**
152
+ * 在刷新 token 后执行任务,防止并发刷新token
153
+ * @param task 需要执行的任务
154
+ * @returns 任务的返回值
155
+ */
156
+ runAfterRefresh<T>(task: () => Promise<T> | T): Promise<T>;
157
+ }
158
+
159
+ export declare type TreeNode<T> = T & {
160
+ children?: TreeNode<T>[];
161
+ };
162
+
163
+ export { }
@@ -0,0 +1,339 @@
1
+ //#region src/clipboard/index.ts
2
+ /**
3
+ * 将纯文本写入系统剪贴板。
4
+ *
5
+ * 优先使用 Clipboard API(`navigator.clipboard.writeText`);在不可用或失败时
6
+ *(非安全上下文、权限被拒等)回退到 `document.execCommand('copy')` + 离屏 textarea,
7
+ * 以兼容旧浏览器与受限环境。
8
+ *
9
+ * 在非浏览器环境(如 SSR、无 DOM 的 Worker)中安全返回 `false`,不抛错。
10
+ */
11
+ var copyToClipboard = async (text) => {
12
+ if (typeof window === "undefined" || typeof document === "undefined") return false;
13
+ if (navigator.clipboard?.writeText) try {
14
+ await navigator.clipboard.writeText(text);
15
+ return true;
16
+ } catch (err) {
17
+ console.error("navigator.clipboard.writeText failed", err);
18
+ }
19
+ const textArea = document.createElement("textarea");
20
+ textArea.value = text;
21
+ textArea.readOnly = true;
22
+ textArea.style.position = "fixed";
23
+ textArea.style.left = "-9999px";
24
+ textArea.style.top = "-9999px";
25
+ textArea.style.opacity = "0";
26
+ document.body.appendChild(textArea);
27
+ textArea.focus();
28
+ textArea.select();
29
+ try {
30
+ return document.execCommand("copy");
31
+ } catch (err) {
32
+ console.error("Fallback: copyToClipboard failed", err);
33
+ return false;
34
+ } finally {
35
+ textArea.remove();
36
+ }
37
+ };
38
+ //#endregion
39
+ //#region src/record/index.ts
40
+ /**
41
+ * 将 `Record<string, string>` 的键值对调:key → value 变为 value → key。
42
+ * 若多个 key 对应同一 value,后者覆盖前者(与 `Object.fromEntries` 行为一致)。
43
+ * @param obj 键值对对象(value 须为 string,且通常唯一)
44
+ * @returns 反向映射对象,类型可推断
45
+ */
46
+ function invertRecord(obj) {
47
+ return Object.fromEntries(Object.entries(obj).map(([k, v]) => [v, k]));
48
+ }
49
+ //#endregion
50
+ //#region src/path/index.ts
51
+ /**
52
+ * 拼接路径。去除每个路径首尾的斜杠,并确保路径之间只有一个斜杠
53
+ * @param paths 路径
54
+ * @returns 拼接后的路径
55
+ */
56
+ function joinPaths(...paths) {
57
+ return paths.map((path) => path.replace(/(^\/+|\/+$)/g, "")).filter(Boolean).join("/");
58
+ }
59
+ //#endregion
60
+ //#region src/tree/build-tree.ts
61
+ /**
62
+ * 将平铺列表转为森林(多根树)。
63
+ * 1. 对每一项做浅拷贝后再挂 `children`,**不修改**入参 `list` 中的对象。
64
+ * 2. 浅拷贝:嵌套对象/数组仍与平铺项中的引用相同;仅 `children` 树结构是新建的。
65
+ */
66
+ var buildTree = (list, { idField = "id", parentIdField = "pId" } = {}) => {
67
+ const idToNode = /* @__PURE__ */ new Map();
68
+ for (const elem of list) idToNode.set(elem[idField], { ...elem });
69
+ for (const elem of list) {
70
+ const node = idToNode.get(elem[idField]);
71
+ const parent = idToNode.get(elem[parentIdField]);
72
+ if (node && parent) {
73
+ if (!parent.children) parent.children = [];
74
+ parent.children.push(node);
75
+ }
76
+ }
77
+ return list.filter((item) => !idToNode.get(item[parentIdField])).map((item) => idToNode.get(item[idField])).filter((n) => n != null);
78
+ };
79
+ //#endregion
80
+ //#region src/tree/forest.ts
81
+ /** 多根树的深度优先遍历(前序,子节点从左到右)。 */
82
+ var Forest = class {
83
+ constructor(trees) {
84
+ this.trees = trees;
85
+ }
86
+ *[Symbol.iterator]() {
87
+ const { trees } = this;
88
+ for (let i = trees.length - 1; i >= 0; i--) {
89
+ const stack = [trees[i]];
90
+ while (stack.length > 0) {
91
+ const current = stack.pop();
92
+ if (current === void 0) continue;
93
+ yield current;
94
+ if (current.children && current.children.length > 0) for (let j = current.children.length - 1; j >= 0; j--) stack.push(current.children[j]);
95
+ }
96
+ }
97
+ }
98
+ /** 根节点数组(与构造时传入的引用相同)。 */
99
+ toArray() {
100
+ return this.trees;
101
+ }
102
+ };
103
+ //#endregion
104
+ //#region src/sse/parse-sse-block.ts
105
+ /**
106
+ * 解析单个 SSE 事件块(不含块之间的空行分隔)。
107
+ */
108
+ function parseSSEBlock(block) {
109
+ const message = {};
110
+ const lines = block.split("\n");
111
+ for (const line of lines) if (line.startsWith("data:")) {
112
+ const dataPart = line.replace(/^data:\s*/, "");
113
+ message.data = (message.data ?? "") + dataPart + "\n";
114
+ } else if (line.startsWith("event:")) message.event = line.replace(/^event:\s*/, "").trim();
115
+ else if (line.startsWith("id:")) message.id = line.replace(/^id:\s*/, "").trim();
116
+ else if (line.startsWith("retry:")) {
117
+ const retry = parseInt(line.replace(/^retry:\s*/, "").trim(), 10);
118
+ if (!isNaN(retry)) message.retry = retry;
119
+ }
120
+ if (message.data) message.data = message.data.trim();
121
+ if (!message.data && !message.event) return null;
122
+ return message;
123
+ }
124
+ //#endregion
125
+ //#region src/sse/sse-connection.ts
126
+ var SSEConnection = class {
127
+ options;
128
+ retryCount = 0;
129
+ isConnecting = false;
130
+ isConnected = false;
131
+ abortController;
132
+ constructor(options) {
133
+ this.options = {
134
+ retryAttempts: 3,
135
+ retryDelay: 1e3,
136
+ ...options
137
+ };
138
+ }
139
+ setHeader(key, value) {
140
+ if (!this.options.headers) this.options.headers = {};
141
+ this.options.headers[key] = value;
142
+ }
143
+ async connect() {
144
+ if (this.isConnecting || this.isConnected) return;
145
+ this.isConnecting = true;
146
+ this.abortController = new AbortController();
147
+ try {
148
+ const headers = {
149
+ Accept: "text/event-stream",
150
+ "Cache-Control": "no-cache",
151
+ ...this.options.headers
152
+ };
153
+ if (this.options.body) headers["Content-Type"] = "application/json";
154
+ const response = await fetch(this.options.url, {
155
+ method: this.options.method || "GET",
156
+ headers,
157
+ body: this.options.body ? JSON.stringify(this.options.body) : void 0,
158
+ signal: this.abortController.signal
159
+ });
160
+ if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.statusText}`);
161
+ if (!response.body) throw new Error("Response body is null");
162
+ this.isConnected = true;
163
+ this.isConnecting = false;
164
+ this.retryCount = 0;
165
+ this.options.onOpen?.();
166
+ await this.processStream(response.body);
167
+ } catch (error) {
168
+ this.isConnecting = false;
169
+ this.isConnected = false;
170
+ if (error instanceof Error) {
171
+ if (error.name === "AbortError") return;
172
+ if (error.message.includes("401")) {
173
+ this.options.onError?.(error);
174
+ return;
175
+ }
176
+ }
177
+ this.options.onError?.(error);
178
+ if (this.retryCount < this.options.retryAttempts) {
179
+ this.retryCount++;
180
+ console.log(`SSE 连接失败,${this.options.retryDelay}ms 后重试 (${this.retryCount}/${this.options.retryAttempts})`);
181
+ setTimeout(() => {
182
+ this.connect();
183
+ }, this.options.retryDelay);
184
+ } else console.error("SSE 连接失败,已达到最大重试次数");
185
+ }
186
+ }
187
+ handleMessage(message) {
188
+ if (!message) return;
189
+ if (message.event === "exception" || message.event === "error") try {
190
+ const errorObj = JSON.parse(message.data || "{}");
191
+ this.options.onException?.(errorObj);
192
+ } catch {
193
+ this.options.onException?.({ error: message.data });
194
+ }
195
+ else this.options.onMessage?.(message);
196
+ }
197
+ async processStream(stream) {
198
+ const reader = stream.getReader();
199
+ const decoder = new TextDecoder("utf-8");
200
+ let buffer = "";
201
+ try {
202
+ while (true) {
203
+ const { value, done } = await reader.read();
204
+ if (done) break;
205
+ const chunk = decoder.decode(value, { stream: true });
206
+ buffer += chunk;
207
+ const parts = buffer.split("\n\n");
208
+ buffer = parts.pop() || "";
209
+ for (const part of parts) this.handleMessage(parseSSEBlock(part));
210
+ }
211
+ if (buffer.trim()) this.handleMessage(parseSSEBlock(buffer));
212
+ } finally {
213
+ reader.releaseLock();
214
+ this.isConnected = false;
215
+ this.options.onClose?.();
216
+ }
217
+ }
218
+ disconnect() {
219
+ this.isConnected = false;
220
+ this.isConnecting = false;
221
+ this.abortController?.abort();
222
+ }
223
+ get isActive() {
224
+ return this.isConnected || this.isConnecting;
225
+ }
226
+ };
227
+ function createSSEConnection(options) {
228
+ return new SSEConnection(options);
229
+ }
230
+ //#endregion
231
+ //#region src/token-refresh/index.ts
232
+ var TokenRefreshManager = class {
233
+ refresh;
234
+ maxAttempts;
235
+ cooldownMinutes;
236
+ onFail;
237
+ tasks = [];
238
+ refreshing = false;
239
+ attemptCount = 0;
240
+ lastRefreshAt = 0;
241
+ constructor(options) {
242
+ this.refresh = options.refresh;
243
+ this.maxAttempts = options.maxAttempts ?? 3;
244
+ this.cooldownMinutes = options.cooldownMinutes ?? 5;
245
+ this.onFail = options.onFail;
246
+ }
247
+ isCooldownExpired() {
248
+ if (this.lastRefreshAt === 0) return true;
249
+ const cooldownMs = this.cooldownMinutes * 60 * 1e3;
250
+ return Date.now() - this.lastRefreshAt > cooldownMs;
251
+ }
252
+ markAttempt() {
253
+ this.attemptCount += 1;
254
+ this.lastRefreshAt = Date.now();
255
+ console.log("刷新次数=>", this.attemptCount);
256
+ }
257
+ clearTasks() {
258
+ this.tasks = [];
259
+ }
260
+ runTasks() {
261
+ this.tasks.forEach((task) => task());
262
+ this.tasks = [];
263
+ }
264
+ resetAttempts() {
265
+ this.attemptCount = 0;
266
+ this.lastRefreshAt = 0;
267
+ }
268
+ /** 复位当前实例内部的刷新队列与计数 */
269
+ resetState() {
270
+ this.clearTasks();
271
+ this.resetAttempts();
272
+ this.refreshing = false;
273
+ }
274
+ /** 统一处理失败收口:复位状态并触发失败回调 */
275
+ handleFail(error) {
276
+ this.resetState();
277
+ this.onFail?.(error);
278
+ }
279
+ enqueueTask(task) {
280
+ this.tasks.push(task);
281
+ }
282
+ /**
283
+ * 在刷新 token 后执行任务,防止并发刷新token
284
+ * @param task 需要执行的任务
285
+ * @returns 任务的返回值
286
+ */
287
+ async runAfterRefresh(task) {
288
+ if (this.isCooldownExpired()) this.resetAttempts();
289
+ if (this.attemptCount >= this.maxAttempts) {
290
+ const error = /* @__PURE__ */ new Error("刷新token次数已达上限");
291
+ this.handleFail(error);
292
+ throw error;
293
+ }
294
+ if (!this.refreshing) {
295
+ this.refreshing = true;
296
+ try {
297
+ this.markAttempt();
298
+ await this.refresh();
299
+ this.runTasks();
300
+ this.refreshing = false;
301
+ return await task();
302
+ } catch (error) {
303
+ this.handleFail(error);
304
+ throw error;
305
+ }
306
+ }
307
+ return new Promise((resolve) => {
308
+ this.enqueueTask(() => resolve(task()));
309
+ });
310
+ }
311
+ };
312
+ //#endregion
313
+ //#region src/service-provider/index.ts
314
+ var ServiceProvider = class {
315
+ cache = /* @__PURE__ */ new Map();
316
+ factories = /* @__PURE__ */ new Map();
317
+ defaultArgs = [];
318
+ constructor(defaultArgs = []) {
319
+ this.defaultArgs = defaultArgs;
320
+ }
321
+ register(clazz, factory) {
322
+ this.factories.set(clazz, factory);
323
+ }
324
+ get(clazz) {
325
+ if (this.cache.has(clazz)) return this.cache.get(clazz);
326
+ let instance;
327
+ if (this.factories.has(clazz)) instance = this.factories.get(clazz)(this);
328
+ else instance = new clazz(...this.defaultArgs);
329
+ this.cache.set(clazz, instance);
330
+ return instance;
331
+ }
332
+ clear() {
333
+ this.cache.clear();
334
+ }
335
+ };
336
+ //#endregion
337
+ export { Forest, SSEConnection, ServiceProvider, TokenRefreshManager, buildTree, copyToClipboard, createSSEConnection, invertRecord, joinPaths, parseSSEBlock };
338
+
339
+ //# sourceMappingURL=xkit-utils.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"xkit-utils.js","names":[],"sources":["../src/clipboard/index.ts","../src/record/index.ts","../src/path/index.ts","../src/tree/build-tree.ts","../src/tree/forest.ts","../src/sse/parse-sse-block.ts","../src/sse/sse-connection.ts","../src/token-refresh/index.ts","../src/service-provider/index.ts"],"sourcesContent":["/**\n * 将纯文本写入系统剪贴板。\n *\n * 优先使用 Clipboard API(`navigator.clipboard.writeText`);在不可用或失败时\n *(非安全上下文、权限被拒等)回退到 `document.execCommand('copy')` + 离屏 textarea,\n * 以兼容旧浏览器与受限环境。\n *\n * 在非浏览器环境(如 SSR、无 DOM 的 Worker)中安全返回 `false`,不抛错。\n */\nexport const copyToClipboard = async (text: string): Promise<boolean> => {\n if (typeof window === \"undefined\" || typeof document === \"undefined\") {\n return false;\n }\n\n if (navigator.clipboard?.writeText) {\n try {\n await navigator.clipboard.writeText(text);\n return true;\n } catch (err) {\n console.error(\"navigator.clipboard.writeText failed\", err);\n }\n }\n\n const textArea = document.createElement(\"textarea\");\n textArea.value = text;\n textArea.readOnly = true;\n textArea.style.position = \"fixed\";\n textArea.style.left = \"-9999px\";\n textArea.style.top = \"-9999px\";\n textArea.style.opacity = \"0\";\n\n document.body.appendChild(textArea);\n textArea.focus();\n textArea.select();\n\n try {\n return document.execCommand(\"copy\");\n } catch (err) {\n console.error(\"Fallback: copyToClipboard failed\", err);\n return false;\n } finally {\n textArea.remove();\n }\n};\n","/**\n * 将 `Record<string, string>` 的键值对调:key → value 变为 value → key。\n * 若多个 key 对应同一 value,后者覆盖前者(与 `Object.fromEntries` 行为一致)。\n * @param obj 键值对对象(value 须为 string,且通常唯一)\n * @returns 反向映射对象,类型可推断\n */\nexport function invertRecord<\n T extends Record<string, string>,\n>(\n obj: T,\n): { [V in T[keyof T]]: { [K in keyof T]: T[K] extends V ? K : never }[keyof T] } {\n return Object.fromEntries(\n Object.entries(obj).map(([k, v]) => [v, k]),\n ) as { [V in T[keyof T]]: { [K in keyof T]: T[K] extends V ? K : never }[keyof T] };\n}\n","/**\n * 拼接路径。去除每个路径首尾的斜杠,并确保路径之间只有一个斜杠\n * @param paths 路径\n * @returns 拼接后的路径\n */\nexport function joinPaths(...paths: string[]): string {\n return paths\n .map((path) => path.replace(/(^\\/+|\\/+$)/g, \"\"))\n .filter(Boolean)\n .join(\"/\");\n}\n","import type { BuildTreeConfig, TreeNode } from \"./type\";\n\n/**\n * 将平铺列表转为森林(多根树)。\n * 1. 对每一项做浅拷贝后再挂 `children`,**不修改**入参 `list` 中的对象。\n * 2. 浅拷贝:嵌套对象/数组仍与平铺项中的引用相同;仅 `children` 树结构是新建的。\n */\nexport const buildTree = <T extends Record<string, any>>(\n list: T[],\n { idField = \"id\", parentIdField = \"pId\" }: BuildTreeConfig = {},\n): TreeNode<T>[] => {\n const idToNode = new Map<PropertyKey, TreeNode<T>>();\n\n for (const elem of list) {\n idToNode.set(elem[idField] as PropertyKey, { ...elem } as TreeNode<T>);\n }\n\n for (const elem of list) {\n const node = idToNode.get(elem[idField] as PropertyKey);\n const parent = idToNode.get(elem[parentIdField] as PropertyKey);\n if (node && parent) {\n if (!parent.children) parent.children = [];\n parent.children.push(node);\n }\n }\n\n return list\n .filter((item) => !idToNode.get(item[parentIdField] as PropertyKey))\n .map((item) => idToNode.get(item[idField] as PropertyKey))\n .filter((n): n is TreeNode<T> => n != null);\n};\n","import type { TreeNode } from \"./type\";\n\n/** 多根树的深度优先遍历(前序,子节点从左到右)。 */\nexport class Forest<T extends Record<string, unknown> = Record<string, unknown>> {\n constructor(readonly trees: TreeNode<T>[]) {}\n\n *[Symbol.iterator](): Generator<TreeNode<T>, void, undefined> {\n const { trees } = this;\n for (let i = trees.length - 1; i >= 0; i--) {\n const stack: TreeNode<T>[] = [trees[i]!];\n while (stack.length > 0) {\n const current = stack.pop();\n if (current === undefined) continue;\n yield current;\n if (current.children && current.children.length > 0) {\n for (let j = current.children.length - 1; j >= 0; j--) {\n stack.push(current.children[j]!);\n }\n }\n }\n }\n }\n\n /** 根节点数组(与构造时传入的引用相同)。 */\n toArray(): TreeNode<T>[] {\n return this.trees;\n }\n}\n","import type { SSEMessage } from \"./type\";\n\n/**\n * 解析单个 SSE 事件块(不含块之间的空行分隔)。\n */\nexport function parseSSEBlock(block: string): SSEMessage | null {\n const message: SSEMessage = {};\n const lines = block.split(\"\\n\");\n\n for (const line of lines) {\n if (line.startsWith(\"data:\")) {\n const dataPart = line.replace(/^data:\\s*/, \"\");\n message.data = (message.data ?? \"\") + dataPart + \"\\n\";\n } else if (line.startsWith(\"event:\")) {\n message.event = line.replace(/^event:\\s*/, \"\").trim();\n } else if (line.startsWith(\"id:\")) {\n message.id = line.replace(/^id:\\s*/, \"\").trim();\n } else if (line.startsWith(\"retry:\")) {\n const retry = parseInt(line.replace(/^retry:\\s*/, \"\").trim(), 10);\n if (!isNaN(retry)) message.retry = retry;\n }\n }\n\n if (message.data) message.data = message.data.trim();\n if (!message.data && !message.event) return null;\n return message;\n}\n","import { parseSSEBlock } from \"./parse-sse-block\";\nimport type { SSEMessage, SSEOptions } from \"./type\";\n\nexport class SSEConnection {\n private options: SSEOptions;\n private retryCount = 0;\n private isConnecting = false;\n private isConnected = false;\n private abortController?: AbortController;\n\n constructor(options: SSEOptions) {\n this.options = {\n retryAttempts: 3,\n retryDelay: 1000,\n ...options,\n };\n }\n\n setHeader(key: string, value: string) {\n if (!this.options.headers) this.options.headers = {};\n this.options.headers[key] = value;\n }\n\n async connect(): Promise<void> {\n if (this.isConnecting || this.isConnected) {\n return;\n }\n\n this.isConnecting = true;\n this.abortController = new AbortController();\n\n try {\n const headers: Record<string, string> = {\n Accept: \"text/event-stream\",\n \"Cache-Control\": \"no-cache\",\n ...this.options.headers,\n };\n\n if (this.options.body) {\n headers[\"Content-Type\"] = \"application/json\";\n }\n\n const response = await fetch(this.options.url, {\n method: this.options.method || \"GET\",\n headers,\n body: this.options.body ? JSON.stringify(this.options.body) : undefined,\n signal: this.abortController.signal,\n });\n\n if (!response.ok) {\n throw new Error(`HTTP ${response.status}: ${response.statusText}`);\n }\n\n if (!response.body) {\n throw new Error(\"Response body is null\");\n }\n\n this.isConnected = true;\n this.isConnecting = false;\n this.retryCount = 0;\n this.options.onOpen?.();\n\n await this.processStream(response.body);\n } catch (error) {\n this.isConnecting = false;\n this.isConnected = false;\n if (error instanceof Error) {\n if (error.name === \"AbortError\") {\n return;\n }\n if (error.message.includes(\"401\")) {\n this.options.onError?.(error);\n return;\n }\n }\n\n this.options.onError?.(error as Error);\n\n if (this.retryCount < this.options.retryAttempts!) {\n this.retryCount++;\n console.log(\n `SSE 连接失败,${this.options.retryDelay}ms 后重试 (${this.retryCount}/${this.options.retryAttempts})`,\n );\n\n setTimeout(() => {\n void this.connect();\n }, this.options.retryDelay);\n } else {\n console.error(\"SSE 连接失败,已达到最大重试次数\");\n }\n }\n }\n\n private handleMessage(message: SSEMessage | null) {\n if (!message) return;\n if (message.event === \"exception\" || message.event === \"error\") {\n try {\n const errorObj = JSON.parse(message.data || \"{}\");\n this.options.onException?.(errorObj);\n } catch {\n this.options.onException?.({ error: message.data });\n }\n } else {\n this.options.onMessage?.(message);\n }\n }\n\n private async processStream(stream: ReadableStream<Uint8Array>): Promise<void> {\n const reader = stream.getReader();\n const decoder = new TextDecoder(\"utf-8\");\n let buffer = \"\";\n\n try {\n while (true) {\n const { value, done } = await reader.read();\n if (done) break;\n\n const chunk = decoder.decode(value, { stream: true });\n buffer += chunk;\n\n const parts = buffer.split(\"\\n\\n\");\n buffer = parts.pop() || \"\";\n for (const part of parts) {\n this.handleMessage(parseSSEBlock(part));\n }\n }\n\n if (buffer.trim()) {\n this.handleMessage(parseSSEBlock(buffer));\n }\n } finally {\n reader.releaseLock();\n this.isConnected = false;\n this.options.onClose?.();\n }\n }\n\n disconnect(): void {\n this.isConnected = false;\n this.isConnecting = false;\n this.abortController?.abort();\n }\n\n get isActive(): boolean {\n return this.isConnected || this.isConnecting;\n }\n}\n\nexport function createSSEConnection(options: SSEOptions): SSEConnection {\n return new SSEConnection(options);\n}\n","/**\n * 刷新 token 的回调函数,如果刷新失败,请抛出异常\n */\ntype RefreshHandler = () => Promise<void>;\n\nexport type RefreshOptions = {\n /**\n * 刷新 token 的回调函数,如果刷新失败,请抛出异常\n */\n refresh: RefreshHandler;\n /**\n * 最大刷新次数\n */\n maxAttempts?: number;\n /**\n * 冷却时间(分钟)\n */\n cooldownMinutes?: number;\n /**\n * 刷新失败回调\n */\n onFail?: (error?: unknown) => void;\n};\n\nexport class TokenRefreshManager {\n private readonly refresh: RefreshHandler;\n private readonly maxAttempts: number;\n private readonly cooldownMinutes: number;\n private readonly onFail?: (error?: unknown) => void;\n\n private tasks: Array<() => void> = [];\n private refreshing = false;\n private attemptCount = 0;\n private lastRefreshAt = 0;\n\n constructor(options: RefreshOptions) {\n this.refresh = options.refresh;\n this.maxAttempts = options.maxAttempts ?? 3;\n this.cooldownMinutes = options.cooldownMinutes ?? 5;\n this.onFail = options.onFail;\n }\n\n private isCooldownExpired(): boolean {\n if (this.lastRefreshAt === 0) {\n return true;\n }\n const cooldownMs = this.cooldownMinutes * 60 * 1000;\n return Date.now() - this.lastRefreshAt > cooldownMs;\n }\n\n private markAttempt(): void {\n this.attemptCount += 1;\n this.lastRefreshAt = Date.now();\n console.log(\"刷新次数=>\", this.attemptCount);\n }\n\n private clearTasks(): void {\n this.tasks = [];\n }\n\n private runTasks(): void {\n this.tasks.forEach((task) => task());\n this.tasks = [];\n }\n\n private resetAttempts(): void {\n this.attemptCount = 0;\n this.lastRefreshAt = 0;\n }\n\n /** 复位当前实例内部的刷新队列与计数 */\n resetState(): void {\n this.clearTasks();\n this.resetAttempts();\n this.refreshing = false;\n }\n\n /** 统一处理失败收口:复位状态并触发失败回调 */\n handleFail(error?: unknown): void {\n this.resetState();\n this.onFail?.(error);\n }\n\n enqueueTask(task: () => void): void {\n this.tasks.push(task);\n }\n\n /**\n * 在刷新 token 后执行任务,防止并发刷新token\n * @param task 需要执行的任务\n * @returns 任务的返回值\n */\n async runAfterRefresh<T>(task: () => Promise<T> | T): Promise<T> {\n if (this.isCooldownExpired()) {\n this.resetAttempts();\n }\n\n if (this.attemptCount >= this.maxAttempts) {\n const error = new Error(\"刷新token次数已达上限\");\n this.handleFail(error);\n throw error;\n }\n if (!this.refreshing) {\n this.refreshing = true;\n try {\n this.markAttempt();\n await this.refresh();\n this.runTasks();\n this.refreshing = false;\n return await task();\n } catch (error) {\n this.handleFail(error);\n throw error;\n }\n }\n return new Promise<T>((resolve) => {\n this.enqueueTask(() => resolve(task()));\n });\n }\n}\n\n","export type Constructor<T> = new (...args: any[]) => T\n\nexport class ServiceProvider {\n private cache = new Map<any, any>()\n private factories = new Map<any, (provider: ServiceProvider) => any>()\n private defaultArgs: any[] = []\n\n constructor(defaultArgs: any[] = []) {\n this.defaultArgs = defaultArgs\n }\n\n register<T>(clazz: Constructor<T>, factory: (provider: ServiceProvider) => T) {\n this.factories.set(clazz, factory)\n }\n\n get<T>(clazz: Constructor<T>): T {\n if (this.cache.has(clazz)) {\n return this.cache.get(clazz)\n }\n\n let instance: T\n if (this.factories.has(clazz)) {\n instance = this.factories.get(clazz)!(this)\n } else {\n instance = new clazz(...this.defaultArgs)\n }\n\n this.cache.set(clazz, instance)\n return instance\n }\n\n clear() {\n this.cache.clear()\n }\n}\n"],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "xkit-utils",
3
+ "version": "2.0.0-alpha.0",
4
+ "description": "Daily tools for web development",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "private": false,
8
+ "main": "./dist/xkit-utils.js",
9
+ "module": "./dist/xkit-utils.js",
10
+ "types": "./dist/index.d.ts",
11
+ "exports": {
12
+ ".": {
13
+ "types": "./dist/index.d.ts",
14
+ "import": "./dist/xkit-utils.js"
15
+ }
16
+ },
17
+ "files": [
18
+ "dist"
19
+ ],
20
+ "devDependencies": {
21
+ "@vitest/coverage-v8": "^4.1.1",
22
+ "happy-dom": "^20.0.2",
23
+ "typescript": "~5.6.3",
24
+ "vite": "^8.0.8",
25
+ "vite-plugin-dts": "^4.5.4",
26
+ "vitest": "^4.1.1"
27
+ },
28
+ "scripts": {
29
+ "build": "vite build",
30
+ "test": "vitest run",
31
+ "test:watch": "vitest",
32
+ "test:coverage": "vitest run --coverage"
33
+ }
34
+ }