@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.
@@ -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
+ });
@@ -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
+ };