@checkstack/notification-backend 1.0.5 → 1.1.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 +40 -0
- package/package.json +7 -7
- 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/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,45 @@
|
|
|
1
1
|
# @checkstack/notification-backend
|
|
2
2
|
|
|
3
|
+
## 1.1.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- a06b899: Dead-code audit cleanup and a small platform of shared notification helpers.
|
|
8
|
+
|
|
9
|
+
**Removed (dead code)**
|
|
10
|
+
|
|
11
|
+
- `core/backend/src/plugin-manager/deregistration-guard.ts` deleted. The exported `assertCanDeregister()` was never called and was a less-complete version of the dependents+isUninstallable checks already done inline by `previewUninstallOriginator` / `uninstallOriginator` in `plugin-manager-orchestrator.ts`.
|
|
12
|
+
- `createMockQueueFactory` deprecated alias removed from `@checkstack/test-utils-backend`. Use `createMockQueueManager` directly.
|
|
13
|
+
|
|
14
|
+
**New shared helpers**
|
|
15
|
+
|
|
16
|
+
- `@checkstack/backend-api` now exports `requestTimeoutMs()` — a Zod field builder for outbound HTTP request timeouts (1s..60s, default 10s). Replaces hand-rolled `configNumber({}).min(1000).max(60_000).default(10_000)` in `integration-webhook-backend`, `integration-script-backend`, and `healthcheck-script-backend`'s inline collector.
|
|
17
|
+
- `@checkstack/notification-common` now exports `SubjectStatusSchema` / `SubjectStatus`, mirroring the existing `ImportanceSchema`.
|
|
18
|
+
- `@checkstack/notification-backend` now exports:
|
|
19
|
+
- `SUBJECT_STATUS_EMOJI` / `IMPORTANCE_EMOJI` — the shared status / importance emoji maps that Discord, Slack, Teams, Webex and Telegram previously each redefined inline.
|
|
20
|
+
- `postJson(opts)` — a timeout-bounded `fetch` wrapper that handles non-2xx logging and error mapping for webhook-style POSTs. Returns `{ ok: true, response } | { ok: false, error }`.
|
|
21
|
+
|
|
22
|
+
**Migrated to shared helpers**
|
|
23
|
+
|
|
24
|
+
- Discord, Slack, Gotify, Pushover notification backends now use `postJson`. Outer try/catch + per-plugin error mapping deleted (~140 LOC).
|
|
25
|
+
- Discord, Slack, Teams, Telegram, Webex notification backends now use `IMPORTANCE_EMOJI`. Discord, Slack, Teams use `SUBJECT_STATUS_EMOJI`.
|
|
26
|
+
- Teams, Webex, Backstage, Telegram kept their inline fetch/Bot logic: their error strings surface server response bodies to operators, or the transport isn't raw `fetch` (Telegram uses `grammy`'s `Bot`).
|
|
27
|
+
|
|
28
|
+
**API surface tightening**
|
|
29
|
+
|
|
30
|
+
- Per-plugin test-only re-exports in 6 notification backends (Pushover, Gotify, Backstage, Slack, Discord, Teams) and the `CertificateInfo` interface in `healthcheck-tls-backend/strategy.ts` are now JSDoc-tagged `@internal`. No behaviour change; signals that downstream consumers must not depend on them.
|
|
31
|
+
|
|
32
|
+
### Patch Changes
|
|
33
|
+
|
|
34
|
+
- Updated dependencies [a06b899]
|
|
35
|
+
- Updated dependencies [a06b899]
|
|
36
|
+
- @checkstack/backend-api@0.16.0
|
|
37
|
+
- @checkstack/notification-common@1.1.1
|
|
38
|
+
- @checkstack/auth-backend@0.4.28
|
|
39
|
+
- @checkstack/cache-api@0.3.3
|
|
40
|
+
- @checkstack/queue-api@0.3.3
|
|
41
|
+
- @checkstack/cache-utils@0.2.8
|
|
42
|
+
|
|
3
43
|
## 1.0.5
|
|
4
44
|
|
|
5
45
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@checkstack/notification-backend",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"license": "Elastic-2.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -16,12 +16,12 @@
|
|
|
16
16
|
},
|
|
17
17
|
"dependencies": {
|
|
18
18
|
"@checkstack/notification-common": "1.1.0",
|
|
19
|
-
"@checkstack/backend-api": "0.15.
|
|
20
|
-
"@checkstack/cache-api": "0.3.
|
|
21
|
-
"@checkstack/cache-utils": "0.2.
|
|
19
|
+
"@checkstack/backend-api": "0.15.3",
|
|
20
|
+
"@checkstack/cache-api": "0.3.2",
|
|
21
|
+
"@checkstack/cache-utils": "0.2.7",
|
|
22
22
|
"@checkstack/signal-common": "0.2.3",
|
|
23
|
-
"@checkstack/queue-api": "0.3.
|
|
24
|
-
"@checkstack/auth-backend": "0.4.
|
|
23
|
+
"@checkstack/queue-api": "0.3.2",
|
|
24
|
+
"@checkstack/auth-backend": "0.4.27",
|
|
25
25
|
"@checkstack/auth-common": "0.7.0",
|
|
26
26
|
"drizzle-orm": "^0.45.0",
|
|
27
27
|
"zod": "^4.2.1",
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
"@checkstack/drizzle-helper": "0.0.5",
|
|
33
33
|
"@checkstack/scripts": "0.3.2",
|
|
34
34
|
"@checkstack/tsconfig": "0.0.7",
|
|
35
|
-
"@checkstack/test-utils-backend": "0.1.
|
|
35
|
+
"@checkstack/test-utils-backend": "0.1.27",
|
|
36
36
|
"@types/node": "^20.0.0",
|
|
37
37
|
"drizzle-kit": "^0.31.10",
|
|
38
38
|
"typescript": "^5.0.0"
|
package/src/index.ts
CHANGED
|
@@ -46,6 +46,14 @@ export const notificationStrategyExtensionPoint =
|
|
|
46
46
|
"notification.strategyExtensionPoint"
|
|
47
47
|
);
|
|
48
48
|
|
|
49
|
+
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
50
|
+
// Shared render helpers for notification strategies
|
|
51
|
+
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
52
|
+
|
|
53
|
+
export { SUBJECT_STATUS_EMOJI, IMPORTANCE_EMOJI } from "./render";
|
|
54
|
+
export { postJson } from "./post-json";
|
|
55
|
+
export type { PostJsonOptions, PostJsonResult } from "./post-json";
|
|
56
|
+
|
|
49
57
|
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
50
58
|
// Registry Implementation
|
|
51
59
|
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
@@ -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
|
+
};
|