@remit/doctor 0.0.1
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/package.json +29 -0
- package/src/attempt.ts +34 -0
- package/src/check.test.ts +81 -0
- package/src/check.ts +36 -0
- package/src/cli.ts +43 -0
- package/src/config.test.ts +115 -0
- package/src/config.ts +241 -0
- package/src/deadman.test.ts +31 -0
- package/src/deadman.ts +34 -0
- package/src/dwell.test.ts +120 -0
- package/src/dwell.ts +53 -0
- package/src/heartbeats.test.ts +68 -0
- package/src/heartbeats.ts +68 -0
- package/src/index.ts +18 -0
- package/src/log.ts +71 -0
- package/src/loop.test.ts +301 -0
- package/src/loop.ts +154 -0
- package/src/main.ts +69 -0
- package/src/prometheus.test.ts +105 -0
- package/src/prometheus.ts +136 -0
- package/src/report.test.ts +165 -0
- package/src/report.ts +106 -0
- package/src/scrape.test.ts +71 -0
- package/src/scrape.ts +63 -0
- package/src/state.test.ts +111 -0
- package/src/state.ts +127 -0
- package/src/verdict.test.ts +554 -0
- package/src/verdict.ts +386 -0
- package/src/webhook.test.ts +241 -0
- package/src/webhook.ts +155 -0
- package/tsconfig.json +8 -0
package/src/webhook.ts
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { attempt } from "./attempt.js";
|
|
2
|
+
import type { Fetcher } from "./scrape.js";
|
|
3
|
+
import type { CheckResult, Verdict } from "./verdict.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* D12. One URL, one template, one content type — not an integration registry.
|
|
7
|
+
*
|
|
8
|
+
* The default template is Slack-shaped JSON, which Mattermost and Discord also
|
|
9
|
+
* accept. A plain-text content type covers ntfy and anything else that takes a
|
|
10
|
+
* raw body. A named integration per provider — a Slack block builder, a Discord
|
|
11
|
+
* embed, a Teams card — makes every provider our maintenance; a template covers
|
|
12
|
+
* providers we have never heard of, and the operator owns it.
|
|
13
|
+
*
|
|
14
|
+
* Substitutions are `{{verdict}}`, `{{summary}}` and `{{reasons}}`. Anything
|
|
15
|
+
* else in braces is left alone, so a template can carry the literal braces some
|
|
16
|
+
* targets use.
|
|
17
|
+
*/
|
|
18
|
+
export const PLACEHOLDERS = ["verdict", "summary", "reasons"] as const;
|
|
19
|
+
|
|
20
|
+
export type Placeholder = (typeof PLACEHOLDERS)[number];
|
|
21
|
+
|
|
22
|
+
const DEFAULT_JSON_TEMPLATE = '{"text":"{{summary}}\\n{{reasons}}"}';
|
|
23
|
+
const DEFAULT_TEXT_TEMPLATE = "{{summary}}\n{{reasons}}";
|
|
24
|
+
|
|
25
|
+
export const isJsonContentType = (contentType: string): boolean =>
|
|
26
|
+
contentType.toLowerCase().includes("json");
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* JSON string escaping for a value that lands between quotes the template
|
|
30
|
+
* supplies. `JSON.stringify` handles the quote, the backslash, the newline and
|
|
31
|
+
* every control character in one pass, and stripping its own quotes is what
|
|
32
|
+
* makes it composable into a template rather than a whole document.
|
|
33
|
+
*
|
|
34
|
+
* A malformed payload fails silently under D8 — the transition is spent and the
|
|
35
|
+
* next thing the operator hears is the recovery — so this is the one place in
|
|
36
|
+
* the alert path where getting it wrong costs an outage nobody is told about.
|
|
37
|
+
*/
|
|
38
|
+
export const escapeFor =
|
|
39
|
+
(contentType: string): ((value: string) => string) =>
|
|
40
|
+
(value) =>
|
|
41
|
+
isJsonContentType(contentType) ? JSON.stringify(value).slice(1, -1) : value;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* `\n` and `\t` in a plain-text template become real characters. A `.env` file
|
|
45
|
+
* has no escape sequences and compose passes the value through verbatim, so
|
|
46
|
+
* without this a multi-line plain-text template cannot be configured at all. A
|
|
47
|
+
* JSON template is left exactly as written — JSON has its own escapes and
|
|
48
|
+
* rewriting them here would corrupt the document.
|
|
49
|
+
*/
|
|
50
|
+
export const expandTemplate = (
|
|
51
|
+
template: string,
|
|
52
|
+
contentType: string,
|
|
53
|
+
): string =>
|
|
54
|
+
isJsonContentType(contentType)
|
|
55
|
+
? template
|
|
56
|
+
: template.replace(/\\n/g, "\n").replace(/\\t/g, "\t");
|
|
57
|
+
|
|
58
|
+
export const defaultTemplate = (contentType: string): string =>
|
|
59
|
+
isJsonContentType(contentType)
|
|
60
|
+
? DEFAULT_JSON_TEMPLATE
|
|
61
|
+
: DEFAULT_TEXT_TEMPLATE;
|
|
62
|
+
|
|
63
|
+
export const render = (
|
|
64
|
+
template: string,
|
|
65
|
+
values: Readonly<Record<Placeholder, string>>,
|
|
66
|
+
escapeValue: (value: string) => string,
|
|
67
|
+
): string =>
|
|
68
|
+
template.replace(
|
|
69
|
+
/\{\{(verdict|summary|reasons)\}\}/g,
|
|
70
|
+
(_match, name: Placeholder) => escapeValue(values[name]),
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The payload's whole vocabulary: a verdict, a headline, and the reason
|
|
75
|
+
* summaries. D10 — no address, no subject, no sender, no message id, no folder
|
|
76
|
+
* name, and no account id. `Reason.detail`, which holds the account ids, is
|
|
77
|
+
* structurally out of reach here: this function never receives it.
|
|
78
|
+
*/
|
|
79
|
+
export const payloadValues = (
|
|
80
|
+
result: CheckResult,
|
|
81
|
+
): Record<Placeholder, string> => ({
|
|
82
|
+
verdict: result.verdict,
|
|
83
|
+
summary: result.summary,
|
|
84
|
+
reasons:
|
|
85
|
+
result.reasons.length === 0
|
|
86
|
+
? "no problems found"
|
|
87
|
+
: result.reasons.map((reason) => `• ${reason.summary}`).join("\n"),
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
export interface WebhookRequest {
|
|
91
|
+
readonly url: string;
|
|
92
|
+
readonly template: string | undefined;
|
|
93
|
+
readonly contentType: string;
|
|
94
|
+
readonly timeoutMs: number;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export const buildBody = (
|
|
98
|
+
result: CheckResult,
|
|
99
|
+
template: string | undefined,
|
|
100
|
+
contentType: string,
|
|
101
|
+
): string =>
|
|
102
|
+
render(
|
|
103
|
+
expandTemplate(template ?? defaultTemplate(contentType), contentType),
|
|
104
|
+
payloadValues(result),
|
|
105
|
+
escapeFor(contentType),
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* What happened to one delivery attempt, split by whether attempting again
|
|
110
|
+
* could plausibly work.
|
|
111
|
+
*
|
|
112
|
+
* `rejected` is a decision the endpoint made about this payload — a template
|
|
113
|
+
* the operator wrote wrong, a revoked URL. Repeating it produces the same
|
|
114
|
+
* answer forever, so the transition is spent and the operator gets one error
|
|
115
|
+
* line naming the status.
|
|
116
|
+
*
|
|
117
|
+
* `unreachable` is not a decision. A timeout, a refused connection, a 5xx or a
|
|
118
|
+
* 429 says nothing about the payload, and a transition dropped for one of them
|
|
119
|
+
* is an outage nobody is ever told about: the dead-man's switch cannot catch it,
|
|
120
|
+
* because it is a different URL at a different provider and it keeps answering
|
|
121
|
+
* 200 while the webhook is down.
|
|
122
|
+
*/
|
|
123
|
+
export type Delivery =
|
|
124
|
+
| { readonly kind: "sent" }
|
|
125
|
+
| { readonly kind: "rejected"; readonly detail: string }
|
|
126
|
+
| { readonly kind: "unreachable"; readonly detail: string };
|
|
127
|
+
|
|
128
|
+
// 429 is the endpoint asking for later, not refusing the content.
|
|
129
|
+
const isRetryable = (status: number): boolean =>
|
|
130
|
+
status >= 500 || status === 429;
|
|
131
|
+
|
|
132
|
+
export const postWebhook = async (
|
|
133
|
+
request: WebhookRequest,
|
|
134
|
+
result: CheckResult,
|
|
135
|
+
fetcher: Fetcher = fetch,
|
|
136
|
+
): Promise<Delivery> => {
|
|
137
|
+
const attempted = await attempt(
|
|
138
|
+
fetcher(request.url, {
|
|
139
|
+
method: "POST",
|
|
140
|
+
headers: { "content-type": request.contentType },
|
|
141
|
+
body: buildBody(result, request.template, request.contentType),
|
|
142
|
+
signal: AbortSignal.timeout(request.timeoutMs),
|
|
143
|
+
}),
|
|
144
|
+
);
|
|
145
|
+
if (!attempted.ok) {
|
|
146
|
+
return { kind: "unreachable", detail: attempted.error };
|
|
147
|
+
}
|
|
148
|
+
const { status } = attempted.value;
|
|
149
|
+
if (attempted.value.ok) return { kind: "sent" };
|
|
150
|
+
return isRetryable(status)
|
|
151
|
+
? { kind: "unreachable", detail: `HTTP ${status}` }
|
|
152
|
+
: { kind: "rejected", detail: `HTTP ${status}` };
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
export type { Verdict };
|