@smthrs/notifications 0.0.0-stage → 1.0.0-rc.3
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 +127 -0
- package/LICENSE +21 -0
- package/README.md +91 -2
- package/dist/cjs/AlertPolicy.d.ts +284 -0
- package/dist/cjs/AlertPolicy.d.ts.map +1 -0
- package/dist/cjs/AlertPolicy.js +124 -0
- package/dist/cjs/AlertPolicy.js.map +7 -0
- package/dist/cjs/AlertRuntime.d.ts +90 -0
- package/dist/cjs/AlertRuntime.d.ts.map +1 -0
- package/dist/cjs/AlertRuntime.js +166 -0
- package/dist/cjs/AlertRuntime.js.map +7 -0
- package/dist/cjs/AlertSink.d.ts +101 -0
- package/dist/cjs/AlertSink.d.ts.map +1 -0
- package/dist/cjs/AlertSink.js +44 -0
- package/dist/cjs/AlertSink.js.map +7 -0
- package/dist/cjs/Alerts.d.ts +57 -0
- package/dist/cjs/Alerts.d.ts.map +1 -0
- package/dist/cjs/Alerts.js +46 -0
- package/dist/cjs/Alerts.js.map +7 -0
- package/dist/cjs/Notification.d.ts +214 -0
- package/dist/cjs/Notification.d.ts.map +1 -0
- package/dist/cjs/Notification.js +62 -0
- package/dist/cjs/Notification.js.map +7 -0
- package/dist/cjs/NotificationEvent.d.ts +214 -0
- package/dist/cjs/NotificationEvent.d.ts.map +1 -0
- package/dist/cjs/NotificationEvent.js +84 -0
- package/dist/cjs/NotificationEvent.js.map +7 -0
- package/dist/cjs/NotificationQueue.d.ts +193 -0
- package/dist/cjs/NotificationQueue.d.ts.map +1 -0
- package/dist/cjs/NotificationQueue.js +478 -0
- package/dist/cjs/NotificationQueue.js.map +7 -0
- package/dist/cjs/NotificationState.d.ts +169 -0
- package/dist/cjs/NotificationState.d.ts.map +1 -0
- package/dist/cjs/NotificationState.js +110 -0
- package/dist/cjs/NotificationState.js.map +7 -0
- package/dist/cjs/Projection.d.ts +27 -0
- package/dist/cjs/Projection.d.ts.map +1 -0
- package/dist/cjs/Projection.js +54 -0
- package/dist/cjs/Projection.js.map +7 -0
- package/dist/cjs/SteerPayload.d.ts +177 -0
- package/dist/cjs/SteerPayload.d.ts.map +1 -0
- package/dist/cjs/SteerPayload.js +61 -0
- package/dist/cjs/SteerPayload.js.map +7 -0
- package/dist/cjs/index.d.ts +41 -0
- package/dist/cjs/index.d.ts.map +1 -0
- package/dist/cjs/index.js +47 -0
- package/dist/cjs/index.js.map +7 -0
- package/dist/cjs/internal/foldCache.d.ts +36 -0
- package/dist/cjs/internal/foldCache.d.ts.map +1 -0
- package/dist/cjs/internal/foldCache.js +54 -0
- package/dist/cjs/internal/foldCache.js.map +7 -0
- package/dist/cjs/layerWebhook.d.ts +53 -0
- package/dist/cjs/layerWebhook.d.ts.map +1 -0
- package/dist/cjs/layerWebhook.js +107 -0
- package/dist/cjs/layerWebhook.js.map +7 -0
- package/dist/cjs/package.json +1 -0
- package/dist/esm/AlertPolicy.d.ts +284 -0
- package/dist/esm/AlertPolicy.d.ts.map +1 -0
- package/dist/esm/AlertPolicy.js +284 -0
- package/dist/esm/AlertPolicy.js.map +1 -0
- package/dist/esm/AlertRuntime.d.ts +90 -0
- package/dist/esm/AlertRuntime.d.ts.map +1 -0
- package/dist/esm/AlertRuntime.js +191 -0
- package/dist/esm/AlertRuntime.js.map +1 -0
- package/dist/esm/AlertSink.d.ts +101 -0
- package/dist/esm/AlertSink.d.ts.map +1 -0
- package/dist/esm/AlertSink.js +63 -0
- package/dist/esm/AlertSink.js.map +1 -0
- package/dist/esm/Alerts.d.ts +57 -0
- package/dist/esm/Alerts.d.ts.map +1 -0
- package/dist/esm/Alerts.js +57 -0
- package/dist/esm/Alerts.js.map +1 -0
- package/dist/esm/Notification.d.ts +214 -0
- package/dist/esm/Notification.d.ts.map +1 -0
- package/dist/esm/Notification.js +121 -0
- package/dist/esm/Notification.js.map +1 -0
- package/dist/esm/NotificationEvent.d.ts +214 -0
- package/dist/esm/NotificationEvent.d.ts.map +1 -0
- package/dist/esm/NotificationEvent.js +158 -0
- package/dist/esm/NotificationEvent.js.map +1 -0
- package/dist/esm/NotificationQueue.d.ts +193 -0
- package/dist/esm/NotificationQueue.d.ts.map +1 -0
- package/dist/esm/NotificationQueue.js +626 -0
- package/dist/esm/NotificationQueue.js.map +1 -0
- package/dist/esm/NotificationState.d.ts +169 -0
- package/dist/esm/NotificationState.d.ts.map +1 -0
- package/dist/esm/NotificationState.js +191 -0
- package/dist/esm/NotificationState.js.map +1 -0
- package/dist/esm/Projection.d.ts +27 -0
- package/dist/esm/Projection.d.ts.map +1 -0
- package/dist/esm/Projection.js +43 -0
- package/dist/esm/Projection.js.map +1 -0
- package/dist/esm/SteerPayload.d.ts +177 -0
- package/dist/esm/SteerPayload.d.ts.map +1 -0
- package/dist/esm/SteerPayload.js +139 -0
- package/dist/esm/SteerPayload.js.map +1 -0
- package/dist/esm/index.d.ts +41 -0
- package/dist/esm/index.d.ts.map +1 -0
- package/dist/esm/index.js +41 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/internal/foldCache.d.ts +36 -0
- package/dist/esm/internal/foldCache.d.ts.map +1 -0
- package/dist/esm/internal/foldCache.js +58 -0
- package/dist/esm/internal/foldCache.js.map +1 -0
- package/dist/esm/layerWebhook.d.ts +53 -0
- package/dist/esm/layerWebhook.d.ts.map +1 -0
- package/dist/esm/layerWebhook.js +118 -0
- package/dist/esm/layerWebhook.js.map +1 -0
- package/docs/README.md +144 -0
- package/docs/api.md +439 -0
- package/docs/concepts/admission-and-promotion.md +144 -0
- package/docs/concepts/alerting.md +138 -0
- package/docs/concepts/journal-records.md +132 -0
- package/docs/guides/alert-on-a-stuck-run.md +140 -0
- package/docs/guides/drain-at-a-turn-boundary.md +95 -0
- package/docs/guides/handle-a-full-queue.md +102 -0
- package/docs/guides/report-pending-notifications.md +92 -0
- package/docs/guides/send-alerts-to-a-webhook.md +130 -0
- package/docs/guides/steer-a-run.md +127 -0
- package/docs/guides/testing.md +161 -0
- package/docs/installation.md +119 -0
- package/docs/quickstart.md +159 -0
- package/docs/troubleshooting.md +254 -0
- package/package.json +182 -3
- package/src/AlertPolicy.ts +377 -0
- package/src/AlertRuntime.ts +280 -0
- package/src/AlertSink.ts +105 -0
- package/src/Alerts.ts +71 -0
- package/src/Notification.ts +171 -0
- package/src/NotificationEvent.ts +213 -0
- package/src/NotificationQueue.ts +857 -0
- package/src/NotificationState.ts +295 -0
- package/src/Projection.ts +46 -0
- package/src/SteerPayload.ts +198 -0
- package/src/index.ts +47 -0
- package/src/internal/foldCache.ts +72 -0
- package/src/layerWebhook.ts +161 -0
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The webhook alert sink: one POST per alert, keyed so the receiver can
|
|
3
|
+
* dedupe on it.
|
|
4
|
+
*
|
|
5
|
+
* The transport rules live here rather than beside the policy, because which
|
|
6
|
+
* endpoint is paged, over which scheme, carrying which credential is a
|
|
7
|
+
* deployment's decision and the delays are not.
|
|
8
|
+
*
|
|
9
|
+
* @since 1.0.0
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { Duration, Effect, Layer, Option } from "effect"
|
|
13
|
+
import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient"
|
|
14
|
+
import * as HttpClient from "effect/unstable/http/HttpClient"
|
|
15
|
+
import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"
|
|
16
|
+
import { alertId } from "./AlertPolicy.ts"
|
|
17
|
+
import { AlertError, Sink } from "./AlertSink.ts"
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* How long the webhook sink waits for an answer before it calls the page
|
|
21
|
+
* refused.
|
|
22
|
+
*
|
|
23
|
+
* @category constants
|
|
24
|
+
* @since 1.0.0
|
|
25
|
+
*/
|
|
26
|
+
export const defaultWebhookTimeout: Duration.Duration = Duration.seconds(10)
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The one check the webhook endpoint gets before an alert is ever built on
|
|
30
|
+
* it.
|
|
31
|
+
*
|
|
32
|
+
* The url is operator config rather than runtime input, so there is no SSRF
|
|
33
|
+
* vector to close here; the scheme bound is what keeps a credential the
|
|
34
|
+
* deployment set in `headers` from being handed to a scheme that is not HTTP
|
|
35
|
+
* at all. A `file:` or `javascript:` url is a config bug, and it fails the
|
|
36
|
+
* composition the way an impossible policy rule does. The error names no
|
|
37
|
+
* url: the value can carry basic-auth credentials, and an error is logged.
|
|
38
|
+
*/
|
|
39
|
+
const webhookUrl = (raw: string): Effect.Effect<string, AlertError> =>
|
|
40
|
+
Effect.suspend(() => {
|
|
41
|
+
const misconfigured = new AlertError({
|
|
42
|
+
code: "sink_misconfigured",
|
|
43
|
+
message: "Alert webhook url must be an absolute http: or https: URL"
|
|
44
|
+
})
|
|
45
|
+
let parsed: URL
|
|
46
|
+
try {
|
|
47
|
+
parsed = new URL(raw)
|
|
48
|
+
} catch {
|
|
49
|
+
return Effect.fail(misconfigured)
|
|
50
|
+
}
|
|
51
|
+
return parsed.protocol === "http:" || parsed.protocol === "https:"
|
|
52
|
+
? Effect.succeed(raw)
|
|
53
|
+
: Effect.fail(misconfigured)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Runs a webhook request with redirect following switched off at the fetch
|
|
58
|
+
* transport.
|
|
59
|
+
*
|
|
60
|
+
* The request carries whatever credential the deployment put in `headers`,
|
|
61
|
+
* and a transport that follows a 3xx re-sends those headers to an origin the
|
|
62
|
+
* deployment never named. Every other fetch default the composition set is
|
|
63
|
+
* kept; only the redirect mode is forced. A 3xx therefore surfaces to the
|
|
64
|
+
* status rule, which calls it `sink_rejected`: a redirecting pager endpoint
|
|
65
|
+
* is a misconfiguration to fix, not an address to forward credentials to.
|
|
66
|
+
*/
|
|
67
|
+
const followingNoRedirects = <A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> =>
|
|
68
|
+
Effect.flatMap(
|
|
69
|
+
Effect.serviceOption(FetchHttpClient.RequestInit),
|
|
70
|
+
(init) =>
|
|
71
|
+
Effect.provideService(effect, FetchHttpClient.RequestInit, {
|
|
72
|
+
...(Option.isSome(init) ? init.value : {}),
|
|
73
|
+
redirect: "manual"
|
|
74
|
+
})
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* A sink that POSTs each alert to one webhook.
|
|
79
|
+
*
|
|
80
|
+
* The body is the alert plus its {@link alertId}, and the same id is sent as
|
|
81
|
+
* an `Idempotency-Key` header, because the package requires the receiver to
|
|
82
|
+
* dedupe on it and a body without it cannot. The header is set after the
|
|
83
|
+
* caller's headers, so it is the one this sink sends.
|
|
84
|
+
*
|
|
85
|
+
* The endpoint must be an absolute `http:` or `https:` URL, checked when the
|
|
86
|
+
* layer is built, and the request is made with redirect following disabled:
|
|
87
|
+
* the caller's headers can carry a credential, and a followed redirect would
|
|
88
|
+
* re-send them to an origin the deployment never named. A 3xx answer is a
|
|
89
|
+
* refusal like any other non-2xx, not a forwarding instruction. The sink owns
|
|
90
|
+
* its Fetch transport; an externally supplied `HttpClient` is not used.
|
|
91
|
+
* Each delivery has its own scope, which aborts the request and releases any
|
|
92
|
+
* unread response body on completion, failure, timeout, or interruption.
|
|
93
|
+
*
|
|
94
|
+
* A non-2xx response is a failure, not a delivery, and an endpoint that never
|
|
95
|
+
* answers is a failure after `timeout`. Paging is exactly the case where a
|
|
96
|
+
* silently dropped request is worse than a retry, and a hung request is worse
|
|
97
|
+
* than either: it is indistinguishable from silence, so it must not be
|
|
98
|
+
* possible to wait on one forever.
|
|
99
|
+
*
|
|
100
|
+
* @param options the endpoint, any headers it needs, and how long to wait
|
|
101
|
+
* @category layers
|
|
102
|
+
* @since 0.1.0
|
|
103
|
+
*/
|
|
104
|
+
export const layerWebhook = (
|
|
105
|
+
options: {
|
|
106
|
+
readonly url: string
|
|
107
|
+
readonly headers?: Readonly<Record<string, string>> | undefined
|
|
108
|
+
readonly timeout?: Duration.Duration | undefined
|
|
109
|
+
}
|
|
110
|
+
): Layer.Layer<Sink> =>
|
|
111
|
+
Layer.effect(
|
|
112
|
+
Sink,
|
|
113
|
+
Effect.gen(function*() {
|
|
114
|
+
const url = yield* Effect.orDie(webhookUrl(options.url))
|
|
115
|
+
const client = HttpClient.withScope(yield* HttpClient.HttpClient)
|
|
116
|
+
const timeout = options.timeout ?? defaultWebhookTimeout
|
|
117
|
+
return {
|
|
118
|
+
deliver: (alert) =>
|
|
119
|
+
client.execute(
|
|
120
|
+
HttpClientRequest.post(url).pipe(
|
|
121
|
+
(request) =>
|
|
122
|
+
options.headers === undefined ? request : HttpClientRequest.setHeaders(request, options.headers),
|
|
123
|
+
HttpClientRequest.setHeader("Idempotency-Key", alertId(alert)),
|
|
124
|
+
HttpClientRequest.bodyJsonUnsafe({ ...alert, alertId: alertId(alert) })
|
|
125
|
+
)
|
|
126
|
+
).pipe(
|
|
127
|
+
followingNoRedirects,
|
|
128
|
+
Effect.flatMap((response) =>
|
|
129
|
+
response.status >= 200 && response.status < 300
|
|
130
|
+
? Effect.void
|
|
131
|
+
: Effect.fail(
|
|
132
|
+
new AlertError({
|
|
133
|
+
code: "sink_rejected",
|
|
134
|
+
status: response.status,
|
|
135
|
+
message: `Alert webhook answered ${response.status}`
|
|
136
|
+
})
|
|
137
|
+
)
|
|
138
|
+
),
|
|
139
|
+
Effect.timeout(timeout),
|
|
140
|
+
Effect.scoped,
|
|
141
|
+
Effect.catchTag("TimeoutError", () =>
|
|
142
|
+
Effect.fail(
|
|
143
|
+
new AlertError({
|
|
144
|
+
code: "sink_timeout",
|
|
145
|
+
message: `Alert webhook did not answer within ${Duration.toMillis(timeout)} ms`
|
|
146
|
+
})
|
|
147
|
+
)),
|
|
148
|
+
// The transport error holds the request, and the request holds the
|
|
149
|
+
// caller's headers. Only its reason tag crosses into the failure.
|
|
150
|
+
Effect.catchTag("HttpClientError", (cause) =>
|
|
151
|
+
Effect.fail(
|
|
152
|
+
new AlertError({
|
|
153
|
+
code: "sink_unreachable",
|
|
154
|
+
reason: cause.reason._tag,
|
|
155
|
+
message: "Alert webhook could not be reached"
|
|
156
|
+
})
|
|
157
|
+
))
|
|
158
|
+
)
|
|
159
|
+
}
|
|
160
|
+
})
|
|
161
|
+
).pipe(Layer.provide(FetchHttpClient.layer))
|