@checkstack/notification-backend 1.0.5 â 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +206 -0
- package/drizzle/0007_funny_hobgoblin.sql +14 -0
- package/drizzle/meta/0007_snapshot.json +644 -0
- package/drizzle/meta/_journal.json +7 -0
- package/package.json +8 -8
- package/src/delivery-attempts.test.ts +482 -0
- package/src/delivery-attempts.ts +146 -0
- package/src/index.ts +8 -0
- package/src/post-json.test.ts +133 -0
- package/src/post-json.ts +86 -0
- package/src/render.ts +29 -0
- package/src/router.test.ts +1021 -25
- package/src/router.ts +81 -9
- package/src/schema.ts +52 -0
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { describe, it, expect, mock } from "bun:test";
|
|
2
|
+
import type { Logger } from "@checkstack/backend-api";
|
|
3
|
+
import { postJson } from "./post-json";
|
|
4
|
+
|
|
5
|
+
function makeLogger(): Logger {
|
|
6
|
+
return {
|
|
7
|
+
info: mock(() => {}),
|
|
8
|
+
error: mock(() => {}),
|
|
9
|
+
warn: mock(() => {}),
|
|
10
|
+
debug: mock(() => {}),
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe("postJson", () => {
|
|
15
|
+
it("returns ok:true with the response on 2xx", async () => {
|
|
16
|
+
const responses: string[] = [];
|
|
17
|
+
const originalFetch = globalThis.fetch;
|
|
18
|
+
globalThis.fetch = mock(async (url: RequestInfo | URL) => {
|
|
19
|
+
responses.push(String(url));
|
|
20
|
+
return new Response(null, { status: 204 });
|
|
21
|
+
}) as unknown as typeof fetch;
|
|
22
|
+
try {
|
|
23
|
+
const logger = makeLogger();
|
|
24
|
+
const result = await postJson({
|
|
25
|
+
url: "https://example.test/hook",
|
|
26
|
+
body: { hello: "world" },
|
|
27
|
+
serviceName: "Test",
|
|
28
|
+
logger,
|
|
29
|
+
});
|
|
30
|
+
expect(result.ok).toBe(true);
|
|
31
|
+
if (result.ok) {
|
|
32
|
+
expect(result.response.status).toBe(204);
|
|
33
|
+
}
|
|
34
|
+
expect(responses).toEqual(["https://example.test/hook"]);
|
|
35
|
+
expect(logger.error).not.toHaveBeenCalled();
|
|
36
|
+
} finally {
|
|
37
|
+
globalThis.fetch = originalFetch;
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("returns ok:false with a service-tagged error on non-2xx", async () => {
|
|
42
|
+
const originalFetch = globalThis.fetch;
|
|
43
|
+
globalThis.fetch = mock(async () =>
|
|
44
|
+
new Response("Bad request body", { status: 400 }),
|
|
45
|
+
) as unknown as typeof fetch;
|
|
46
|
+
try {
|
|
47
|
+
const logger = makeLogger();
|
|
48
|
+
const result = await postJson({
|
|
49
|
+
url: "https://example.test/hook",
|
|
50
|
+
body: { hello: "world" },
|
|
51
|
+
serviceName: "Discord",
|
|
52
|
+
logger,
|
|
53
|
+
});
|
|
54
|
+
expect(result.ok).toBe(false);
|
|
55
|
+
if (!result.ok) {
|
|
56
|
+
expect(result.error).toBe("Failed to send Discord message: 400");
|
|
57
|
+
}
|
|
58
|
+
expect(logger.error).toHaveBeenCalledTimes(1);
|
|
59
|
+
} finally {
|
|
60
|
+
globalThis.fetch = originalFetch;
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("returns ok:false when fetch rejects (network / timeout)", async () => {
|
|
65
|
+
const originalFetch = globalThis.fetch;
|
|
66
|
+
globalThis.fetch = mock(async () => {
|
|
67
|
+
throw new Error("ECONNREFUSED");
|
|
68
|
+
}) as unknown as typeof fetch;
|
|
69
|
+
try {
|
|
70
|
+
const logger = makeLogger();
|
|
71
|
+
const result = await postJson({
|
|
72
|
+
url: "https://example.test/hook",
|
|
73
|
+
body: {},
|
|
74
|
+
serviceName: "Slack",
|
|
75
|
+
logger,
|
|
76
|
+
});
|
|
77
|
+
expect(result.ok).toBe(false);
|
|
78
|
+
if (!result.ok) {
|
|
79
|
+
expect(result.error).toBe(
|
|
80
|
+
"Failed to send Slack notification: ECONNREFUSED",
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
expect(logger.error).toHaveBeenCalledTimes(1);
|
|
84
|
+
} finally {
|
|
85
|
+
globalThis.fetch = originalFetch;
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("merges caller headers on top of the default Content-Type", async () => {
|
|
90
|
+
let observedHeaders: Headers | undefined;
|
|
91
|
+
const originalFetch = globalThis.fetch;
|
|
92
|
+
globalThis.fetch = mock(async (_url: RequestInfo | URL, init?: RequestInit) => {
|
|
93
|
+
observedHeaders = new Headers(init?.headers);
|
|
94
|
+
return new Response(null, { status: 200 });
|
|
95
|
+
}) as unknown as typeof fetch;
|
|
96
|
+
try {
|
|
97
|
+
await postJson({
|
|
98
|
+
url: "https://example.test/hook",
|
|
99
|
+
body: {},
|
|
100
|
+
headers: { Authorization: "Bearer token" },
|
|
101
|
+
serviceName: "Webex",
|
|
102
|
+
logger: makeLogger(),
|
|
103
|
+
});
|
|
104
|
+
expect(observedHeaders?.get("content-type")).toBe("application/json");
|
|
105
|
+
expect(observedHeaders?.get("authorization")).toBe("Bearer token");
|
|
106
|
+
} finally {
|
|
107
|
+
globalThis.fetch = originalFetch;
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("truncates long error bodies in the log payload", async () => {
|
|
112
|
+
const longBody = "x".repeat(2_000);
|
|
113
|
+
const originalFetch = globalThis.fetch;
|
|
114
|
+
globalThis.fetch = mock(async () =>
|
|
115
|
+
new Response(longBody, { status: 500 }),
|
|
116
|
+
) as unknown as typeof fetch;
|
|
117
|
+
try {
|
|
118
|
+
const logger = makeLogger();
|
|
119
|
+
await postJson({
|
|
120
|
+
url: "https://example.test/hook",
|
|
121
|
+
body: {},
|
|
122
|
+
serviceName: "Gotify",
|
|
123
|
+
logger,
|
|
124
|
+
});
|
|
125
|
+
const errorCall = (logger.error as ReturnType<typeof mock>).mock
|
|
126
|
+
.calls[0];
|
|
127
|
+
const meta = errorCall?.[1] as { error: string } | undefined;
|
|
128
|
+
expect(meta?.error.length).toBe(500);
|
|
129
|
+
} finally {
|
|
130
|
+
globalThis.fetch = originalFetch;
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
});
|
package/src/post-json.ts
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import type { Logger } from "@checkstack/backend-api";
|
|
2
|
+
import { extractErrorMessage } from "@checkstack/common";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Outcome of a [[postJson]] call. Discriminated on `ok`:
|
|
6
|
+
* - `ok: true` carries the raw `Response` so callers can read service-specific
|
|
7
|
+
* success payloads (e.g. Pushover's `receipt`).
|
|
8
|
+
* - `ok: false` carries a human-readable error string already suitable for
|
|
9
|
+
* surfacing back to operators via `NotificationDeliveryResult.error`.
|
|
10
|
+
*/
|
|
11
|
+
export type PostJsonResult =
|
|
12
|
+
| { ok: true; response: Response }
|
|
13
|
+
| { ok: false; error: string };
|
|
14
|
+
|
|
15
|
+
export interface PostJsonOptions {
|
|
16
|
+
/** Absolute URL to POST to. */
|
|
17
|
+
url: string;
|
|
18
|
+
/** Body, JSON-stringified for you. */
|
|
19
|
+
body: unknown;
|
|
20
|
+
/**
|
|
21
|
+
* Headers merged on top of `Content-Type: application/json`. Provide auth
|
|
22
|
+
* tokens here (e.g. `Bearer ...`).
|
|
23
|
+
*/
|
|
24
|
+
headers?: Record<string, string>;
|
|
25
|
+
/** Request timeout. Defaults to 10s, matching the platform-wide default. */
|
|
26
|
+
timeoutMs?: number;
|
|
27
|
+
/**
|
|
28
|
+
* Short, human-readable service label used to build log messages and the
|
|
29
|
+
* returned error string. Example: `"Discord"`, `"Slack webhook"`.
|
|
30
|
+
*/
|
|
31
|
+
serviceName: string;
|
|
32
|
+
logger: Logger;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Shared POST helper for notification strategies. Centralises the
|
|
37
|
+
* timeout-bounded fetch, non-2xx logging, and error mapping that every
|
|
38
|
+
* webhook-style notification plugin (Discord, Slack, Teams, Webex, Telegram,
|
|
39
|
+
* Gotify, Pushover, ...) was previously re-implementing inline.
|
|
40
|
+
*
|
|
41
|
+
* Plugins remain responsible for building the request body and interpreting
|
|
42
|
+
* a successful `Response` (some services return JSON receipts; most return
|
|
43
|
+
* 204 No Content).
|
|
44
|
+
*/
|
|
45
|
+
export async function postJson(
|
|
46
|
+
options: PostJsonOptions,
|
|
47
|
+
): Promise<PostJsonResult> {
|
|
48
|
+
const {
|
|
49
|
+
url,
|
|
50
|
+
body,
|
|
51
|
+
headers = {},
|
|
52
|
+
timeoutMs = 10_000,
|
|
53
|
+
serviceName,
|
|
54
|
+
logger,
|
|
55
|
+
} = options;
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
const response = await fetch(url, {
|
|
59
|
+
method: "POST",
|
|
60
|
+
headers: { "Content-Type": "application/json", ...headers },
|
|
61
|
+
body: JSON.stringify(body),
|
|
62
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
if (!response.ok) {
|
|
66
|
+
const errorText = await response.text();
|
|
67
|
+
logger.error(`Failed to send ${serviceName} message`, {
|
|
68
|
+
status: response.status,
|
|
69
|
+
error: errorText.slice(0, 500),
|
|
70
|
+
});
|
|
71
|
+
return {
|
|
72
|
+
ok: false,
|
|
73
|
+
error: `Failed to send ${serviceName} message: ${response.status}`,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return { ok: true, response };
|
|
78
|
+
} catch (error) {
|
|
79
|
+
const message = extractErrorMessage(error, `Unknown ${serviceName} error`);
|
|
80
|
+
logger.error(`${serviceName} request error`, { error: message });
|
|
81
|
+
return {
|
|
82
|
+
ok: false,
|
|
83
|
+
error: `Failed to send ${serviceName} notification: ${message}`,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
}
|
package/src/render.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
Importance,
|
|
3
|
+
SubjectStatus,
|
|
4
|
+
} from "@checkstack/notification-common";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Emoji used to prefix an affected subject in a rendered notification, keyed
|
|
8
|
+
* by the subject's health status. Plugins that render notifications in
|
|
9
|
+
* plain-text channels (Slack, Discord, Teams, Telegram, Webex, etc.) should
|
|
10
|
+
* use this map instead of redefining their own so operators see a consistent
|
|
11
|
+
* visual language across destinations.
|
|
12
|
+
*/
|
|
13
|
+
export const SUBJECT_STATUS_EMOJI: Record<SubjectStatus, string> = {
|
|
14
|
+
healthy: "đĸ",
|
|
15
|
+
degraded: "đĄ",
|
|
16
|
+
unhealthy: "đ´",
|
|
17
|
+
unknown: "âĒ",
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Emoji used to prefix a notification's title in a rendered message, keyed
|
|
22
|
+
* by the notification's importance level. Plugins should use this map rather
|
|
23
|
+
* than redefining their own.
|
|
24
|
+
*/
|
|
25
|
+
export const IMPORTANCE_EMOJI: Record<Importance, string> = {
|
|
26
|
+
info: "âšī¸",
|
|
27
|
+
warning: "â ī¸",
|
|
28
|
+
critical: "đ¨",
|
|
29
|
+
};
|