@openclaw/mattermost 2026.2.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.
- package/index.ts +17 -0
- package/openclaw.plugin.json +9 -0
- package/package.json +28 -0
- package/src/channel.test.ts +235 -0
- package/src/channel.ts +433 -0
- package/src/config-schema.ts +61 -0
- package/src/group-mentions.ts +15 -0
- package/src/mattermost/accounts.ts +128 -0
- package/src/mattermost/client.test.ts +19 -0
- package/src/mattermost/client.ts +230 -0
- package/src/mattermost/index.ts +9 -0
- package/src/mattermost/monitor-helpers.ts +93 -0
- package/src/mattermost/monitor-onchar.ts +25 -0
- package/src/mattermost/monitor-websocket.test.ts +232 -0
- package/src/mattermost/monitor-websocket.ts +221 -0
- package/src/mattermost/monitor.ts +1030 -0
- package/src/mattermost/probe.ts +73 -0
- package/src/mattermost/reactions.test-helpers.ts +83 -0
- package/src/mattermost/reactions.test.ts +103 -0
- package/src/mattermost/reactions.ts +124 -0
- package/src/mattermost/reconnect.test.ts +192 -0
- package/src/mattermost/reconnect.ts +103 -0
- package/src/mattermost/send.ts +231 -0
- package/src/normalize.ts +46 -0
- package/src/onboarding-helpers.ts +1 -0
- package/src/onboarding.ts +186 -0
- package/src/runtime.ts +14 -0
- package/src/types.ts +57 -0
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { BaseProbeResult } from "openclaw/plugin-sdk";
|
|
2
|
+
import { normalizeMattermostBaseUrl, type MattermostUser } from "./client.js";
|
|
3
|
+
|
|
4
|
+
export type MattermostProbe = BaseProbeResult & {
|
|
5
|
+
status?: number | null;
|
|
6
|
+
elapsedMs?: number | null;
|
|
7
|
+
bot?: MattermostUser;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
async function readMattermostError(res: Response): Promise<string> {
|
|
11
|
+
const contentType = res.headers.get("content-type") ?? "";
|
|
12
|
+
if (contentType.includes("application/json")) {
|
|
13
|
+
const data = (await res.json()) as { message?: string } | undefined;
|
|
14
|
+
if (data?.message) {
|
|
15
|
+
return data.message;
|
|
16
|
+
}
|
|
17
|
+
return JSON.stringify(data);
|
|
18
|
+
}
|
|
19
|
+
return await res.text();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function probeMattermost(
|
|
23
|
+
baseUrl: string,
|
|
24
|
+
botToken: string,
|
|
25
|
+
timeoutMs = 2500,
|
|
26
|
+
): Promise<MattermostProbe> {
|
|
27
|
+
const normalized = normalizeMattermostBaseUrl(baseUrl);
|
|
28
|
+
if (!normalized) {
|
|
29
|
+
return { ok: false, error: "baseUrl missing" };
|
|
30
|
+
}
|
|
31
|
+
const url = `${normalized}/api/v4/users/me`;
|
|
32
|
+
const start = Date.now();
|
|
33
|
+
const controller = timeoutMs > 0 ? new AbortController() : undefined;
|
|
34
|
+
let timer: NodeJS.Timeout | null = null;
|
|
35
|
+
if (controller) {
|
|
36
|
+
timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
37
|
+
}
|
|
38
|
+
try {
|
|
39
|
+
const res = await fetch(url, {
|
|
40
|
+
headers: { Authorization: `Bearer ${botToken}` },
|
|
41
|
+
signal: controller?.signal,
|
|
42
|
+
});
|
|
43
|
+
const elapsedMs = Date.now() - start;
|
|
44
|
+
if (!res.ok) {
|
|
45
|
+
const detail = await readMattermostError(res);
|
|
46
|
+
return {
|
|
47
|
+
ok: false,
|
|
48
|
+
status: res.status,
|
|
49
|
+
error: detail || res.statusText,
|
|
50
|
+
elapsedMs,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
const bot = (await res.json()) as MattermostUser;
|
|
54
|
+
return {
|
|
55
|
+
ok: true,
|
|
56
|
+
status: res.status,
|
|
57
|
+
elapsedMs,
|
|
58
|
+
bot,
|
|
59
|
+
};
|
|
60
|
+
} catch (err) {
|
|
61
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
62
|
+
return {
|
|
63
|
+
ok: false,
|
|
64
|
+
status: null,
|
|
65
|
+
error: message,
|
|
66
|
+
elapsedMs: Date.now() - start,
|
|
67
|
+
};
|
|
68
|
+
} finally {
|
|
69
|
+
if (timer) {
|
|
70
|
+
clearTimeout(timer);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
|
2
|
+
import { expect, vi } from "vitest";
|
|
3
|
+
|
|
4
|
+
export function createMattermostTestConfig(): OpenClawConfig {
|
|
5
|
+
return {
|
|
6
|
+
channels: {
|
|
7
|
+
mattermost: {
|
|
8
|
+
enabled: true,
|
|
9
|
+
botToken: "test-token",
|
|
10
|
+
baseUrl: "https://chat.example.com",
|
|
11
|
+
},
|
|
12
|
+
},
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function createMattermostReactionFetchMock(params: {
|
|
17
|
+
postId: string;
|
|
18
|
+
emojiName: string;
|
|
19
|
+
mode: "add" | "remove" | "both";
|
|
20
|
+
userId?: string;
|
|
21
|
+
status?: number;
|
|
22
|
+
body?: unknown;
|
|
23
|
+
}) {
|
|
24
|
+
const userId = params.userId ?? "BOT123";
|
|
25
|
+
const mode = params.mode;
|
|
26
|
+
const allowAdd = mode === "add" || mode === "both";
|
|
27
|
+
const allowRemove = mode === "remove" || mode === "both";
|
|
28
|
+
const addStatus = params.status ?? 201;
|
|
29
|
+
const removeStatus = params.status ?? 204;
|
|
30
|
+
const removePath = `/api/v4/users/${userId}/posts/${params.postId}/reactions/${encodeURIComponent(params.emojiName)}`;
|
|
31
|
+
|
|
32
|
+
return vi.fn(async (url: any, init?: any) => {
|
|
33
|
+
if (String(url).endsWith("/api/v4/users/me")) {
|
|
34
|
+
return new Response(JSON.stringify({ id: userId }), {
|
|
35
|
+
status: 200,
|
|
36
|
+
headers: { "content-type": "application/json" },
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (allowAdd && String(url).endsWith("/api/v4/reactions")) {
|
|
41
|
+
expect(init?.method).toBe("POST");
|
|
42
|
+
expect(JSON.parse(init?.body)).toEqual({
|
|
43
|
+
user_id: userId,
|
|
44
|
+
post_id: params.postId,
|
|
45
|
+
emoji_name: params.emojiName,
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const responseBody = params.body === undefined ? { ok: true } : params.body;
|
|
49
|
+
return new Response(
|
|
50
|
+
responseBody === null ? null : JSON.stringify(responseBody),
|
|
51
|
+
responseBody === null
|
|
52
|
+
? { status: addStatus, headers: { "content-type": "text/plain" } }
|
|
53
|
+
: { status: addStatus, headers: { "content-type": "application/json" } },
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (allowRemove && String(url).endsWith(removePath)) {
|
|
58
|
+
expect(init?.method).toBe("DELETE");
|
|
59
|
+
const responseBody = params.body === undefined ? null : params.body;
|
|
60
|
+
return new Response(
|
|
61
|
+
responseBody === null ? null : JSON.stringify(responseBody),
|
|
62
|
+
responseBody === null
|
|
63
|
+
? { status: removeStatus, headers: { "content-type": "text/plain" } }
|
|
64
|
+
: { status: removeStatus, headers: { "content-type": "application/json" } },
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
throw new Error(`unexpected url: ${url}`);
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function withMockedGlobalFetch<T>(
|
|
73
|
+
fetchImpl: typeof fetch,
|
|
74
|
+
run: () => Promise<T>,
|
|
75
|
+
): Promise<T> {
|
|
76
|
+
const prevFetch = globalThis.fetch;
|
|
77
|
+
(globalThis as any).fetch = fetchImpl;
|
|
78
|
+
try {
|
|
79
|
+
return await run();
|
|
80
|
+
} finally {
|
|
81
|
+
(globalThis as any).fetch = prevFetch;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
addMattermostReaction,
|
|
4
|
+
removeMattermostReaction,
|
|
5
|
+
resetMattermostReactionBotUserCacheForTests,
|
|
6
|
+
} from "./reactions.js";
|
|
7
|
+
import {
|
|
8
|
+
createMattermostReactionFetchMock,
|
|
9
|
+
createMattermostTestConfig,
|
|
10
|
+
} from "./reactions.test-helpers.js";
|
|
11
|
+
|
|
12
|
+
describe("mattermost reactions", () => {
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
resetMattermostReactionBotUserCacheForTests();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("adds reactions by calling /users/me then POST /reactions", async () => {
|
|
18
|
+
const fetchMock = createMattermostReactionFetchMock({
|
|
19
|
+
mode: "add",
|
|
20
|
+
postId: "POST1",
|
|
21
|
+
emojiName: "thumbsup",
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const result = await addMattermostReaction({
|
|
25
|
+
cfg: createMattermostTestConfig(),
|
|
26
|
+
postId: "POST1",
|
|
27
|
+
emojiName: "thumbsup",
|
|
28
|
+
fetchImpl: fetchMock as unknown as typeof fetch,
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
expect(result).toEqual({ ok: true });
|
|
32
|
+
expect(fetchMock).toHaveBeenCalled();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("returns a Result error when add reaction API call fails", async () => {
|
|
36
|
+
const fetchMock = createMattermostReactionFetchMock({
|
|
37
|
+
mode: "add",
|
|
38
|
+
postId: "POST1",
|
|
39
|
+
emojiName: "thumbsup",
|
|
40
|
+
status: 500,
|
|
41
|
+
body: { id: "err", message: "boom" },
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const result = await addMattermostReaction({
|
|
45
|
+
cfg: createMattermostTestConfig(),
|
|
46
|
+
postId: "POST1",
|
|
47
|
+
emojiName: "thumbsup",
|
|
48
|
+
fetchImpl: fetchMock as unknown as typeof fetch,
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
expect(result.ok).toBe(false);
|
|
52
|
+
if (!result.ok) {
|
|
53
|
+
expect(result.error).toContain("Mattermost add reaction failed");
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("removes reactions by calling /users/me then DELETE /users/:id/posts/:postId/reactions/:emoji", async () => {
|
|
58
|
+
const fetchMock = createMattermostReactionFetchMock({
|
|
59
|
+
mode: "remove",
|
|
60
|
+
postId: "POST1",
|
|
61
|
+
emojiName: "thumbsup",
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const result = await removeMattermostReaction({
|
|
65
|
+
cfg: createMattermostTestConfig(),
|
|
66
|
+
postId: "POST1",
|
|
67
|
+
emojiName: "thumbsup",
|
|
68
|
+
fetchImpl: fetchMock as unknown as typeof fetch,
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
expect(result).toEqual({ ok: true });
|
|
72
|
+
expect(fetchMock).toHaveBeenCalled();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("caches the bot user id across reaction mutations", async () => {
|
|
76
|
+
const fetchMock = createMattermostReactionFetchMock({
|
|
77
|
+
mode: "both",
|
|
78
|
+
postId: "POST1",
|
|
79
|
+
emojiName: "thumbsup",
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const cfg = createMattermostTestConfig();
|
|
83
|
+
const addResult = await addMattermostReaction({
|
|
84
|
+
cfg,
|
|
85
|
+
postId: "POST1",
|
|
86
|
+
emojiName: "thumbsup",
|
|
87
|
+
fetchImpl: fetchMock as unknown as typeof fetch,
|
|
88
|
+
});
|
|
89
|
+
const removeResult = await removeMattermostReaction({
|
|
90
|
+
cfg,
|
|
91
|
+
postId: "POST1",
|
|
92
|
+
emojiName: "thumbsup",
|
|
93
|
+
fetchImpl: fetchMock as unknown as typeof fetch,
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const usersMeCalls = fetchMock.mock.calls.filter((call) =>
|
|
97
|
+
String(call[0]).endsWith("/api/v4/users/me"),
|
|
98
|
+
);
|
|
99
|
+
expect(addResult).toEqual({ ok: true });
|
|
100
|
+
expect(removeResult).toEqual({ ok: true });
|
|
101
|
+
expect(usersMeCalls).toHaveLength(1);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
|
2
|
+
import { resolveMattermostAccount } from "./accounts.js";
|
|
3
|
+
import { createMattermostClient, fetchMattermostMe, type MattermostClient } from "./client.js";
|
|
4
|
+
|
|
5
|
+
type Result = { ok: true } | { ok: false; error: string };
|
|
6
|
+
type ReactionParams = {
|
|
7
|
+
cfg: OpenClawConfig;
|
|
8
|
+
postId: string;
|
|
9
|
+
emojiName: string;
|
|
10
|
+
accountId?: string | null;
|
|
11
|
+
fetchImpl?: typeof fetch;
|
|
12
|
+
};
|
|
13
|
+
type ReactionMutation = (client: MattermostClient, params: MutationPayload) => Promise<void>;
|
|
14
|
+
type MutationPayload = { userId: string; postId: string; emojiName: string };
|
|
15
|
+
|
|
16
|
+
const BOT_USER_CACHE_TTL_MS = 10 * 60_000;
|
|
17
|
+
const botUserIdCache = new Map<string, { userId: string; expiresAt: number }>();
|
|
18
|
+
|
|
19
|
+
async function resolveBotUserId(
|
|
20
|
+
client: MattermostClient,
|
|
21
|
+
cacheKey: string,
|
|
22
|
+
): Promise<string | null> {
|
|
23
|
+
const cached = botUserIdCache.get(cacheKey);
|
|
24
|
+
if (cached && cached.expiresAt > Date.now()) {
|
|
25
|
+
return cached.userId;
|
|
26
|
+
}
|
|
27
|
+
const me = await fetchMattermostMe(client);
|
|
28
|
+
const userId = me?.id?.trim();
|
|
29
|
+
if (!userId) {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
botUserIdCache.set(cacheKey, { userId, expiresAt: Date.now() + BOT_USER_CACHE_TTL_MS });
|
|
33
|
+
return userId;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function addMattermostReaction(params: {
|
|
37
|
+
cfg: OpenClawConfig;
|
|
38
|
+
postId: string;
|
|
39
|
+
emojiName: string;
|
|
40
|
+
accountId?: string | null;
|
|
41
|
+
fetchImpl?: typeof fetch;
|
|
42
|
+
}): Promise<Result> {
|
|
43
|
+
return runMattermostReaction(params, {
|
|
44
|
+
action: "add",
|
|
45
|
+
mutation: createReaction,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function removeMattermostReaction(params: {
|
|
50
|
+
cfg: OpenClawConfig;
|
|
51
|
+
postId: string;
|
|
52
|
+
emojiName: string;
|
|
53
|
+
accountId?: string | null;
|
|
54
|
+
fetchImpl?: typeof fetch;
|
|
55
|
+
}): Promise<Result> {
|
|
56
|
+
return runMattermostReaction(params, {
|
|
57
|
+
action: "remove",
|
|
58
|
+
mutation: deleteReaction,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function resetMattermostReactionBotUserCacheForTests(): void {
|
|
63
|
+
botUserIdCache.clear();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function runMattermostReaction(
|
|
67
|
+
params: ReactionParams,
|
|
68
|
+
options: {
|
|
69
|
+
action: "add" | "remove";
|
|
70
|
+
mutation: ReactionMutation;
|
|
71
|
+
},
|
|
72
|
+
): Promise<Result> {
|
|
73
|
+
const resolved = resolveMattermostAccount({ cfg: params.cfg, accountId: params.accountId });
|
|
74
|
+
const baseUrl = resolved.baseUrl?.trim();
|
|
75
|
+
const botToken = resolved.botToken?.trim();
|
|
76
|
+
if (!baseUrl || !botToken) {
|
|
77
|
+
return { ok: false, error: "Mattermost botToken/baseUrl missing." };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const client = createMattermostClient({
|
|
81
|
+
baseUrl,
|
|
82
|
+
botToken,
|
|
83
|
+
fetchImpl: params.fetchImpl,
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
const cacheKey = `${baseUrl}:${botToken}`;
|
|
87
|
+
const userId = await resolveBotUserId(client, cacheKey);
|
|
88
|
+
if (!userId) {
|
|
89
|
+
return { ok: false, error: "Mattermost reactions failed: could not resolve bot user id." };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
try {
|
|
93
|
+
await options.mutation(client, {
|
|
94
|
+
userId,
|
|
95
|
+
postId: params.postId,
|
|
96
|
+
emojiName: params.emojiName,
|
|
97
|
+
});
|
|
98
|
+
} catch (err) {
|
|
99
|
+
return { ok: false, error: `Mattermost ${options.action} reaction failed: ${String(err)}` };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return { ok: true };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function createReaction(client: MattermostClient, params: MutationPayload): Promise<void> {
|
|
106
|
+
await client.request<Record<string, unknown>>("/reactions", {
|
|
107
|
+
method: "POST",
|
|
108
|
+
body: JSON.stringify({
|
|
109
|
+
user_id: params.userId,
|
|
110
|
+
post_id: params.postId,
|
|
111
|
+
emoji_name: params.emojiName,
|
|
112
|
+
}),
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function deleteReaction(client: MattermostClient, params: MutationPayload): Promise<void> {
|
|
117
|
+
const emoji = encodeURIComponent(params.emojiName);
|
|
118
|
+
await client.request<unknown>(
|
|
119
|
+
`/users/${params.userId}/posts/${params.postId}/reactions/${emoji}`,
|
|
120
|
+
{
|
|
121
|
+
method: "DELETE",
|
|
122
|
+
},
|
|
123
|
+
);
|
|
124
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { runWithReconnect } from "./reconnect.js";
|
|
3
|
+
|
|
4
|
+
beforeEach(() => {
|
|
5
|
+
vi.clearAllMocks();
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
describe("runWithReconnect", () => {
|
|
9
|
+
it("retries after connectFn resolves (normal close)", async () => {
|
|
10
|
+
let callCount = 0;
|
|
11
|
+
const abort = new AbortController();
|
|
12
|
+
const connectFn = vi.fn(async () => {
|
|
13
|
+
callCount++;
|
|
14
|
+
if (callCount >= 3) {
|
|
15
|
+
abort.abort();
|
|
16
|
+
}
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
await runWithReconnect(connectFn, {
|
|
20
|
+
abortSignal: abort.signal,
|
|
21
|
+
initialDelayMs: 1,
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
expect(connectFn).toHaveBeenCalledTimes(3);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("retries after connectFn throws (connection error)", async () => {
|
|
28
|
+
let callCount = 0;
|
|
29
|
+
const abort = new AbortController();
|
|
30
|
+
const onError = vi.fn();
|
|
31
|
+
const connectFn = vi.fn(async () => {
|
|
32
|
+
callCount++;
|
|
33
|
+
if (callCount < 3) {
|
|
34
|
+
throw new Error("fetch failed");
|
|
35
|
+
}
|
|
36
|
+
abort.abort();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
await runWithReconnect(connectFn, {
|
|
40
|
+
abortSignal: abort.signal,
|
|
41
|
+
onError,
|
|
42
|
+
initialDelayMs: 1,
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
expect(connectFn).toHaveBeenCalledTimes(3);
|
|
46
|
+
expect(onError).toHaveBeenCalledTimes(2);
|
|
47
|
+
expect(onError).toHaveBeenCalledWith(expect.objectContaining({ message: "fetch failed" }));
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("uses exponential backoff on consecutive errors, capped at maxDelayMs", async () => {
|
|
51
|
+
const abort = new AbortController();
|
|
52
|
+
const delays: number[] = [];
|
|
53
|
+
let callCount = 0;
|
|
54
|
+
const connectFn = vi.fn(async () => {
|
|
55
|
+
callCount++;
|
|
56
|
+
if (callCount >= 6) {
|
|
57
|
+
abort.abort();
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
throw new Error("connection refused");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
await runWithReconnect(connectFn, {
|
|
64
|
+
abortSignal: abort.signal,
|
|
65
|
+
onReconnect: (delayMs) => delays.push(delayMs),
|
|
66
|
+
// Keep this test fast: validate the exponential pattern, not real-time waiting.
|
|
67
|
+
initialDelayMs: 1,
|
|
68
|
+
maxDelayMs: 10,
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
expect(connectFn).toHaveBeenCalledTimes(6);
|
|
72
|
+
// 5 errors produce delays: 1, 2, 4, 8, 10(cap)
|
|
73
|
+
// 6th succeeds -> delay resets to 100
|
|
74
|
+
// But 6th also aborts → onReconnect NOT called (abort check fires first)
|
|
75
|
+
expect(delays).toEqual([1, 2, 4, 8, 10]);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("resets backoff after successful connection", async () => {
|
|
79
|
+
const abort = new AbortController();
|
|
80
|
+
const delays: number[] = [];
|
|
81
|
+
let callCount = 0;
|
|
82
|
+
const connectFn = vi.fn(async () => {
|
|
83
|
+
callCount++;
|
|
84
|
+
if (callCount === 1) {
|
|
85
|
+
throw new Error("first failure");
|
|
86
|
+
}
|
|
87
|
+
if (callCount === 2) {
|
|
88
|
+
return; // success
|
|
89
|
+
}
|
|
90
|
+
if (callCount === 3) {
|
|
91
|
+
throw new Error("second failure");
|
|
92
|
+
}
|
|
93
|
+
abort.abort();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
await runWithReconnect(connectFn, {
|
|
97
|
+
abortSignal: abort.signal,
|
|
98
|
+
onReconnect: (delayMs) => delays.push(delayMs),
|
|
99
|
+
initialDelayMs: 1,
|
|
100
|
+
maxDelayMs: 60_000,
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
expect(connectFn).toHaveBeenCalledTimes(4);
|
|
104
|
+
// call 1: fail -> delay 1
|
|
105
|
+
// call 2: success → delay resets to 1
|
|
106
|
+
// call 3: fail -> delay 1 (reset held)
|
|
107
|
+
// call 4: success + abort → no onReconnect
|
|
108
|
+
expect(delays).toEqual([1, 1, 1]);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("stops immediately when abort signal is pre-fired", async () => {
|
|
112
|
+
const abort = new AbortController();
|
|
113
|
+
abort.abort();
|
|
114
|
+
const connectFn = vi.fn(async () => {});
|
|
115
|
+
|
|
116
|
+
await runWithReconnect(connectFn, { abortSignal: abort.signal });
|
|
117
|
+
|
|
118
|
+
expect(connectFn).not.toHaveBeenCalled();
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("stops after current connection when abort fires mid-connection", async () => {
|
|
122
|
+
const abort = new AbortController();
|
|
123
|
+
const connectFn = vi.fn(async () => {
|
|
124
|
+
abort.abort();
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
await runWithReconnect(connectFn, {
|
|
128
|
+
abortSignal: abort.signal,
|
|
129
|
+
initialDelayMs: 1,
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
expect(connectFn).toHaveBeenCalledTimes(1);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("abort signal interrupts backoff sleep immediately", async () => {
|
|
136
|
+
const abort = new AbortController();
|
|
137
|
+
const connectFn = vi.fn(async () => {
|
|
138
|
+
// Schedule abort to fire 10ms into the 60s sleep
|
|
139
|
+
setTimeout(() => abort.abort(), 10);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
const start = Date.now();
|
|
143
|
+
await runWithReconnect(connectFn, {
|
|
144
|
+
abortSignal: abort.signal,
|
|
145
|
+
initialDelayMs: 60_000,
|
|
146
|
+
});
|
|
147
|
+
const elapsed = Date.now() - start;
|
|
148
|
+
|
|
149
|
+
expect(connectFn).toHaveBeenCalledTimes(1);
|
|
150
|
+
expect(elapsed).toBeLessThan(5000);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("applies jitter to reconnect delay when configured", async () => {
|
|
154
|
+
const abort = new AbortController();
|
|
155
|
+
const delays: number[] = [];
|
|
156
|
+
let callCount = 0;
|
|
157
|
+
const connectFn = vi.fn(async () => {
|
|
158
|
+
callCount++;
|
|
159
|
+
if (callCount === 1) {
|
|
160
|
+
throw new Error("connection refused");
|
|
161
|
+
}
|
|
162
|
+
abort.abort();
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
await runWithReconnect(connectFn, {
|
|
166
|
+
abortSignal: abort.signal,
|
|
167
|
+
onReconnect: (delayMs) => delays.push(delayMs),
|
|
168
|
+
initialDelayMs: 10,
|
|
169
|
+
jitterRatio: 0.5,
|
|
170
|
+
random: () => 1,
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
expect(connectFn).toHaveBeenCalledTimes(2);
|
|
174
|
+
expect(delays).toEqual([15]);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("supports strategy hook to stop reconnecting after failure", async () => {
|
|
178
|
+
const onReconnect = vi.fn();
|
|
179
|
+
const connectFn = vi.fn(async () => {
|
|
180
|
+
throw new Error("fatal");
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
await runWithReconnect(connectFn, {
|
|
184
|
+
initialDelayMs: 1,
|
|
185
|
+
onReconnect,
|
|
186
|
+
shouldReconnect: (params) => params.outcome !== "rejected",
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
expect(connectFn).toHaveBeenCalledTimes(1);
|
|
190
|
+
expect(onReconnect).not.toHaveBeenCalled();
|
|
191
|
+
});
|
|
192
|
+
});
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
export type ReconnectOutcome = "resolved" | "rejected";
|
|
2
|
+
|
|
3
|
+
export type ShouldReconnectParams = {
|
|
4
|
+
attempt: number;
|
|
5
|
+
delayMs: number;
|
|
6
|
+
outcome: ReconnectOutcome;
|
|
7
|
+
error?: unknown;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export type RunWithReconnectOpts = {
|
|
11
|
+
abortSignal?: AbortSignal;
|
|
12
|
+
onError?: (err: unknown) => void;
|
|
13
|
+
onReconnect?: (delayMs: number) => void;
|
|
14
|
+
initialDelayMs?: number;
|
|
15
|
+
maxDelayMs?: number;
|
|
16
|
+
jitterRatio?: number;
|
|
17
|
+
random?: () => number;
|
|
18
|
+
shouldReconnect?: (params: ShouldReconnectParams) => boolean;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Reconnection loop with exponential backoff.
|
|
23
|
+
*
|
|
24
|
+
* Calls `connectFn` in a while loop. On normal resolve (connection closed),
|
|
25
|
+
* the backoff resets. On thrown error (connection failed), the current delay is
|
|
26
|
+
* used, then doubled for the next retry.
|
|
27
|
+
* The loop exits when `abortSignal` fires.
|
|
28
|
+
*/
|
|
29
|
+
export async function runWithReconnect(
|
|
30
|
+
connectFn: () => Promise<void>,
|
|
31
|
+
opts: RunWithReconnectOpts = {},
|
|
32
|
+
): Promise<void> {
|
|
33
|
+
const { initialDelayMs = 2000, maxDelayMs = 60_000 } = opts;
|
|
34
|
+
const jitterRatio = Math.max(0, opts.jitterRatio ?? 0);
|
|
35
|
+
const random = opts.random ?? Math.random;
|
|
36
|
+
let retryDelay = initialDelayMs;
|
|
37
|
+
let attempt = 0;
|
|
38
|
+
|
|
39
|
+
while (!opts.abortSignal?.aborted) {
|
|
40
|
+
let shouldIncreaseDelay = false;
|
|
41
|
+
let outcome: ReconnectOutcome = "resolved";
|
|
42
|
+
let error: unknown;
|
|
43
|
+
try {
|
|
44
|
+
await connectFn();
|
|
45
|
+
retryDelay = initialDelayMs;
|
|
46
|
+
} catch (err) {
|
|
47
|
+
if (opts.abortSignal?.aborted) {
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
outcome = "rejected";
|
|
51
|
+
error = err;
|
|
52
|
+
opts.onError?.(err);
|
|
53
|
+
shouldIncreaseDelay = true;
|
|
54
|
+
}
|
|
55
|
+
if (opts.abortSignal?.aborted) {
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
const delayMs = withJitter(retryDelay, jitterRatio, random);
|
|
59
|
+
const shouldReconnect =
|
|
60
|
+
opts.shouldReconnect?.({
|
|
61
|
+
attempt,
|
|
62
|
+
delayMs,
|
|
63
|
+
outcome,
|
|
64
|
+
error,
|
|
65
|
+
}) ?? true;
|
|
66
|
+
if (!shouldReconnect) {
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
opts.onReconnect?.(delayMs);
|
|
70
|
+
await sleepAbortable(delayMs, opts.abortSignal);
|
|
71
|
+
if (shouldIncreaseDelay) {
|
|
72
|
+
retryDelay = Math.min(retryDelay * 2, maxDelayMs);
|
|
73
|
+
}
|
|
74
|
+
attempt++;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function withJitter(baseMs: number, jitterRatio: number, random: () => number): number {
|
|
79
|
+
if (jitterRatio <= 0) {
|
|
80
|
+
return baseMs;
|
|
81
|
+
}
|
|
82
|
+
const normalized = Math.max(0, Math.min(1, random()));
|
|
83
|
+
const spread = baseMs * jitterRatio;
|
|
84
|
+
return Math.max(1, Math.round(baseMs - spread + normalized * spread * 2));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function sleepAbortable(ms: number, signal?: AbortSignal): Promise<void> {
|
|
88
|
+
return new Promise((resolve) => {
|
|
89
|
+
if (signal?.aborted) {
|
|
90
|
+
resolve();
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
const onAbort = () => {
|
|
94
|
+
clearTimeout(timer);
|
|
95
|
+
resolve();
|
|
96
|
+
};
|
|
97
|
+
const timer = setTimeout(() => {
|
|
98
|
+
signal?.removeEventListener("abort", onAbort);
|
|
99
|
+
resolve();
|
|
100
|
+
}, ms);
|
|
101
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
102
|
+
});
|
|
103
|
+
}
|