@end-close/relay 0.12.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/COMPATIBILITY.md +39 -0
- package/LICENSE +21 -0
- package/README.md +205 -0
- package/dist/config/schema.d.ts +993 -0
- package/dist/config/schema.d.ts.map +1 -0
- package/dist/config/schema.js +156 -0
- package/dist/config/schema.js.map +1 -0
- package/dist/crypto/at-rest.d.ts +6 -0
- package/dist/crypto/at-rest.d.ts.map +1 -0
- package/dist/crypto/at-rest.js +19 -0
- package/dist/crypto/at-rest.js.map +1 -0
- package/dist/crypto/keys.d.ts +2 -0
- package/dist/crypto/keys.d.ts.map +1 -0
- package/dist/crypto/keys.js +12 -0
- package/dist/crypto/keys.js.map +1 -0
- package/dist/engine/codec.d.ts +12 -0
- package/dist/engine/codec.d.ts.map +1 -0
- package/dist/engine/codec.js +21 -0
- package/dist/engine/codec.js.map +1 -0
- package/dist/engine/hooks.d.ts +84 -0
- package/dist/engine/hooks.d.ts.map +1 -0
- package/dist/engine/hooks.js +34 -0
- package/dist/engine/hooks.js.map +1 -0
- package/dist/engine/ingest.d.ts +39 -0
- package/dist/engine/ingest.d.ts.map +1 -0
- package/dist/engine/ingest.js +131 -0
- package/dist/engine/ingest.js.map +1 -0
- package/dist/engine/memory-store.d.ts +35 -0
- package/dist/engine/memory-store.d.ts.map +1 -0
- package/dist/engine/memory-store.js +205 -0
- package/dist/engine/memory-store.js.map +1 -0
- package/dist/engine/relay.d.ts +116 -0
- package/dist/engine/relay.d.ts.map +1 -0
- package/dist/engine/relay.js +180 -0
- package/dist/engine/relay.js.map +1 -0
- package/dist/engine/secrets.d.ts +18 -0
- package/dist/engine/secrets.d.ts.map +1 -0
- package/dist/engine/secrets.js +42 -0
- package/dist/engine/secrets.js.map +1 -0
- package/dist/engine/settings.d.ts +29 -0
- package/dist/engine/settings.d.ts.map +1 -0
- package/dist/engine/settings.js +13 -0
- package/dist/engine/settings.js.map +1 -0
- package/dist/engine/store.d.ts +141 -0
- package/dist/engine/store.d.ts.map +1 -0
- package/dist/engine/store.js +52 -0
- package/dist/engine/store.js.map +1 -0
- package/dist/forward/backoff.d.ts +3 -0
- package/dist/forward/backoff.d.ts.map +1 -0
- package/dist/forward/backoff.js +10 -0
- package/dist/forward/backoff.js.map +1 -0
- package/dist/forward/dispatcher.d.ts +98 -0
- package/dist/forward/dispatcher.d.ts.map +1 -0
- package/dist/forward/dispatcher.js +444 -0
- package/dist/forward/dispatcher.js.map +1 -0
- package/dist/forward/endclose-client.d.ts +42 -0
- package/dist/forward/endclose-client.d.ts.map +1 -0
- package/dist/forward/endclose-client.js +76 -0
- package/dist/forward/endclose-client.js.map +1 -0
- package/dist/forward/enrich.d.ts +39 -0
- package/dist/forward/enrich.d.ts.map +1 -0
- package/dist/forward/enrich.js +77 -0
- package/dist/forward/enrich.js.map +1 -0
- package/dist/forward/mapper.d.ts +50 -0
- package/dist/forward/mapper.d.ts.map +1 -0
- package/dist/forward/mapper.js +141 -0
- package/dist/forward/mapper.js.map +1 -0
- package/dist/index.d.ts +36 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +24 -0
- package/dist/index.js.map +1 -0
- package/dist/ingest/adapters/generic-hmac.d.ts +3 -0
- package/dist/ingest/adapters/generic-hmac.d.ts.map +1 -0
- package/dist/ingest/adapters/generic-hmac.js +60 -0
- package/dist/ingest/adapters/generic-hmac.js.map +1 -0
- package/dist/ingest/adapters/payabli.d.ts +3 -0
- package/dist/ingest/adapters/payabli.d.ts.map +1 -0
- package/dist/ingest/adapters/payabli.js +57 -0
- package/dist/ingest/adapters/payabli.js.map +1 -0
- package/dist/ingest/adapters/registry.d.ts +4 -0
- package/dist/ingest/adapters/registry.d.ts.map +1 -0
- package/dist/ingest/adapters/registry.js +16 -0
- package/dist/ingest/adapters/registry.js.map +1 -0
- package/dist/ingest/adapters/types.d.ts +28 -0
- package/dist/ingest/adapters/types.d.ts.map +1 -0
- package/dist/ingest/adapters/types.js +5 -0
- package/dist/ingest/adapters/types.js.map +1 -0
- package/dist/logger.d.ts +12 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +14 -0
- package/dist/logger.js.map +1 -0
- package/dist/mask/defaults.d.ts +7 -0
- package/dist/mask/defaults.d.ts.map +1 -0
- package/dist/mask/defaults.js +51 -0
- package/dist/mask/defaults.js.map +1 -0
- package/dist/mask/paths.d.ts +7 -0
- package/dist/mask/paths.d.ts.map +1 -0
- package/dist/mask/paths.js +36 -0
- package/dist/mask/paths.js.map +1 -0
- package/dist/util/payload-shape.d.ts +6 -0
- package/dist/util/payload-shape.d.ts.map +1 -0
- package/dist/util/payload-shape.js +19 -0
- package/dist/util/payload-shape.js.map +1 -0
- package/dist/util/strings.d.ts +3 -0
- package/dist/util/strings.d.ts.map +1 -0
- package/dist/util/strings.js +7 -0
- package/dist/util/strings.js.map +1 -0
- package/package.json +57 -0
- package/src/config/schema.ts +177 -0
- package/src/crypto/at-rest.ts +21 -0
- package/src/crypto/keys.ts +13 -0
- package/src/engine/codec.ts +30 -0
- package/src/engine/hooks.ts +76 -0
- package/src/engine/ingest.ts +194 -0
- package/src/engine/memory-store.ts +232 -0
- package/src/engine/relay.ts +294 -0
- package/src/engine/secrets.ts +51 -0
- package/src/engine/settings.ts +42 -0
- package/src/engine/store.ts +188 -0
- package/src/forward/backoff.ts +10 -0
- package/src/forward/dispatcher.ts +504 -0
- package/src/forward/endclose-client.ts +102 -0
- package/src/forward/enrich.ts +106 -0
- package/src/forward/mapper.ts +191 -0
- package/src/index.ts +47 -0
- package/src/ingest/adapters/generic-hmac.ts +70 -0
- package/src/ingest/adapters/payabli.ts +67 -0
- package/src/ingest/adapters/registry.ts +21 -0
- package/src/ingest/adapters/types.ts +33 -0
- package/src/logger.ts +26 -0
- package/src/mask/defaults.ts +56 -0
- package/src/mask/paths.ts +36 -0
- package/src/util/payload-shape.ts +19 -0
- package/src/util/strings.ts +7 -0
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
import { EventEmitter } from 'node:events'
|
|
2
|
+
import { randomUUID } from 'node:crypto'
|
|
3
|
+
import { refEnrichment, relayConfigSchema, type RelayConfig, type RouteConfig } from '../config/schema.js'
|
|
4
|
+
import { deriveKey } from '../crypto/keys.js'
|
|
5
|
+
import { EndCloseClient } from '../forward/endclose-client.js'
|
|
6
|
+
import { Dispatcher, type DispatchCounts } from '../forward/dispatcher.js'
|
|
7
|
+
import type { Enrichment } from '../forward/enrich.js'
|
|
8
|
+
import { mapEvent, type MappedEvent } from '../forward/mapper.js'
|
|
9
|
+
import { hasAdapter } from '../ingest/adapters/registry.js'
|
|
10
|
+
import type { ProcessorAdapter, RawRequest } from '../ingest/adapters/types.js'
|
|
11
|
+
import type { Json } from '../mask/paths.js'
|
|
12
|
+
import { noopLogger, type Logger } from '../logger.js'
|
|
13
|
+
import { sleep } from '../util/strings.js'
|
|
14
|
+
import { aesGcmCodec, plainCodec } from './codec.js'
|
|
15
|
+
import { RelayHooks, type RelayEventName, type RelayHandler } from './hooks.js'
|
|
16
|
+
import { ingestWebhook, type IngestResult } from './ingest.js'
|
|
17
|
+
import { toSecretResolver, type SecretResolver } from './secrets.js'
|
|
18
|
+
import {
|
|
19
|
+
DEFAULT_DISPATCH,
|
|
20
|
+
DEFAULT_RETENTION,
|
|
21
|
+
type DispatchSettings,
|
|
22
|
+
type RetentionSettings,
|
|
23
|
+
} from './settings.js'
|
|
24
|
+
import {
|
|
25
|
+
hasAdmin,
|
|
26
|
+
MemoryControlStore,
|
|
27
|
+
staticRoutes,
|
|
28
|
+
type ControlStore,
|
|
29
|
+
type EventStore,
|
|
30
|
+
type RouteProvider,
|
|
31
|
+
} from './store.js'
|
|
32
|
+
|
|
33
|
+
// The embeddable engine: everything the application does between "webhook arrives" and
|
|
34
|
+
// "record accepted by End Close", with storage, secrets, logging and observability
|
|
35
|
+
// supplied by the host.
|
|
36
|
+
|
|
37
|
+
export interface RelayOptions {
|
|
38
|
+
/** Route definitions: the same shape as the `routes` block of relay.yaml. */
|
|
39
|
+
routes: RouteConfig[] | RouteProvider
|
|
40
|
+
store: EventStore
|
|
41
|
+
/** Killswitch and per-route pause state. Default: in-memory, nothing paused. */
|
|
42
|
+
control?: ControlStore
|
|
43
|
+
/** Where `auth.secret_env` references resolve. */
|
|
44
|
+
secrets: SecretResolver | Record<string, string>
|
|
45
|
+
endclose: { apiKey: string; baseUrl?: string; fetch?: typeof fetch }
|
|
46
|
+
/** Explicit: encrypt buffered payloads at rest under this key, or store them as-is. */
|
|
47
|
+
encryption: { dataKey: string | Buffer } | 'none'
|
|
48
|
+
/** Keys the deterministic `hash` transform. Never leaves the host. */
|
|
49
|
+
maskingKey: string | Buffer
|
|
50
|
+
dispatch?: Partial<DispatchSettings>
|
|
51
|
+
/** `false` disables retention pruning entirely. */
|
|
52
|
+
retention?: Partial<RetentionSettings> | false
|
|
53
|
+
/** Default: silent. */
|
|
54
|
+
logger?: Logger | null
|
|
55
|
+
/** Additional processor adapters keyed by route `source`. */
|
|
56
|
+
adapters?: Record<string, ProcessorAdapter>
|
|
57
|
+
/**
|
|
58
|
+
* Host functions a route's map may name with `enrich: <name>` on `description` or a
|
|
59
|
+
* `metadata` entry. Each runs in-process per event after mapping, receives the value at
|
|
60
|
+
* that field's `source`, and returns what is forwarded (validated like any mapped value).
|
|
61
|
+
* Throw to retry the event with backoff; throw `EnrichmentError` to park it; return
|
|
62
|
+
* `undefined` to omit the field. Bounded by `dispatch.enrichTimeoutMs` per call.
|
|
63
|
+
*/
|
|
64
|
+
enrichments?: Record<string, Enrichment>
|
|
65
|
+
/** Lease owner for claimed batches. Give each long-lived replica a stable id. */
|
|
66
|
+
instanceId?: string
|
|
67
|
+
hooks?: RelayHooks
|
|
68
|
+
/** Supply a pre-built client (the application shares one with telemetry). */
|
|
69
|
+
client?: EndCloseClient
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export type DispatchOnceResult = DispatchCounts
|
|
73
|
+
|
|
74
|
+
export interface FlushResult extends DispatchOnceResult {
|
|
75
|
+
/** True when nothing deliverable remained when flush returned. */
|
|
76
|
+
drained: boolean
|
|
77
|
+
/**
|
|
78
|
+
* Why flush stopped early: the deadline passed, forwarding is paused (killswitch or
|
|
79
|
+
* every due route), or due events belong to routes the provider no longer knows.
|
|
80
|
+
*/
|
|
81
|
+
reason?: 'timeout' | 'paused' | 'unroutable'
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface Relay {
|
|
85
|
+
/** Framework-agnostic webhook entrypoint. */
|
|
86
|
+
ingest(routeId: string, req: RawRequest): Promise<IngestResult>
|
|
87
|
+
/** Start the background dispatch loop (long-lived processes). */
|
|
88
|
+
start(): void
|
|
89
|
+
/** Stop the loop and drain in-flight work. */
|
|
90
|
+
stop(): Promise<void>
|
|
91
|
+
/** Run one dispatch cycle (cron / serverless / tests). */
|
|
92
|
+
dispatchOnce(opts?: { prune?: boolean }): Promise<DispatchOnceResult>
|
|
93
|
+
/**
|
|
94
|
+
* Run dispatch cycles until nothing deliverable remains or `timeoutMs` (default 30 s)
|
|
95
|
+
* passes, retrying as backoff timers expire. Returns immediately if forwarding is
|
|
96
|
+
* paused. Events still `retried` when it returns need a later cycle or a durable store.
|
|
97
|
+
*/
|
|
98
|
+
flush(opts?: { timeoutMs?: number }): Promise<FlushResult>
|
|
99
|
+
/** Run retention pruning to completion. */
|
|
100
|
+
prune(): Promise<{ wiped: number; deleted: number }>
|
|
101
|
+
/**
|
|
102
|
+
* Map a sample payload through a route without storing or sending anything. Enrichments
|
|
103
|
+
* are not run: their fields are listed in `report.enriched` and `pending`.
|
|
104
|
+
*/
|
|
105
|
+
preview(route: RouteConfig, sample: Json, receivedAt?: string): MappedEvent
|
|
106
|
+
/** Decode a buffered payload. Sensitive: the caller is responsible for auditing. */
|
|
107
|
+
readPayload(id: string): Promise<Buffer | undefined>
|
|
108
|
+
on<E extends RelayEventName>(name: E, handler: RelayHandler<E>): () => void
|
|
109
|
+
readonly store: EventStore
|
|
110
|
+
readonly control: ControlStore
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function toKey(name: string, v: string | Buffer): Buffer {
|
|
114
|
+
if (typeof v === 'string') return deriveKey(name, v)
|
|
115
|
+
if (v.length !== 32) throw new Error(`${name} must be a 32-byte Buffer or a string of 16+ chars`)
|
|
116
|
+
return v
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Reject routes whose `source` has no adapter (built-in or host-registered). */
|
|
120
|
+
export function assertKnownSources(
|
|
121
|
+
routes: RouteConfig[],
|
|
122
|
+
adapters?: Record<string, ProcessorAdapter>,
|
|
123
|
+
): void {
|
|
124
|
+
for (const r of routes) {
|
|
125
|
+
if (!hasAdapter(r.source, adapters)) {
|
|
126
|
+
throw new Error(`route ${r.id}: no adapter for source "${r.source}"`)
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Every `enrich:` reference in a route's map, as [field, enrichment name]. */
|
|
132
|
+
export function routeEnrichments(route: RouteConfig): [field: string, enrichment: string][] {
|
|
133
|
+
const out: [string, string][] = []
|
|
134
|
+
const desc = route.map.description === undefined ? undefined : refEnrichment(route.map.description)
|
|
135
|
+
if (desc !== undefined) out.push(['description', desc])
|
|
136
|
+
for (const [key, ref] of Object.entries(route.map.metadata)) {
|
|
137
|
+
const name = refEnrichment(ref)
|
|
138
|
+
if (name !== undefined) out.push([`metadata.${key}`, name])
|
|
139
|
+
}
|
|
140
|
+
return out
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Reject routes whose map names an enrichment the host has not registered. */
|
|
144
|
+
export function assertKnownEnrichments(
|
|
145
|
+
routes: RouteConfig[],
|
|
146
|
+
enrichments?: Record<string, unknown>,
|
|
147
|
+
): void {
|
|
148
|
+
for (const r of routes) {
|
|
149
|
+
for (const [field, name] of routeEnrichments(r)) {
|
|
150
|
+
if (!enrichments || !Object.hasOwn(enrichments, name)) {
|
|
151
|
+
throw new Error(`route ${r.id}: ${field} references unknown enrichment "${name}"`)
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Validate a routes document (parsed YAML or a plain object) into RouteConfig[]. Applies
|
|
159
|
+
* defaults, the hard-denylist check on metadata names, duplicate-id, unknown-source and
|
|
160
|
+
* unknown-enrichment checks. Pass the host's extra adapters and enrichments so routes
|
|
161
|
+
* that use them validate too; with none registered, any `enrich:` reference is rejected.
|
|
162
|
+
*/
|
|
163
|
+
export function parseRoutes(
|
|
164
|
+
doc: unknown,
|
|
165
|
+
opts: { adapters?: Record<string, ProcessorAdapter>; enrichments?: Record<string, unknown> } = {},
|
|
166
|
+
): RouteConfig[] {
|
|
167
|
+
const config: RelayConfig = relayConfigSchema.parse(doc)
|
|
168
|
+
const seen = new Set<string>()
|
|
169
|
+
for (const route of config.routes) {
|
|
170
|
+
if (seen.has(route.id)) throw new Error(`duplicate route id: ${route.id}`)
|
|
171
|
+
seen.add(route.id)
|
|
172
|
+
}
|
|
173
|
+
assertKnownSources(config.routes, opts.adapters)
|
|
174
|
+
assertKnownEnrichments(config.routes, opts.enrichments)
|
|
175
|
+
return config.routes
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const FLUSH_POLL_MIN_MS = 50
|
|
179
|
+
const FLUSH_POLL_MAX_MS = 1000
|
|
180
|
+
// Asking for "due" events at this time returns every route holding pending/retry rows.
|
|
181
|
+
const FAR_FUTURE = '9999-12-31T23:59:59.999Z'
|
|
182
|
+
|
|
183
|
+
export function createRelay(opts: RelayOptions): Relay {
|
|
184
|
+
if (Array.isArray(opts.routes)) {
|
|
185
|
+
assertKnownSources(opts.routes, opts.adapters)
|
|
186
|
+
assertKnownEnrichments(opts.routes, opts.enrichments)
|
|
187
|
+
}
|
|
188
|
+
const routes = Array.isArray(opts.routes) ? staticRoutes(opts.routes) : opts.routes
|
|
189
|
+
const control = opts.control ?? new MemoryControlStore()
|
|
190
|
+
const secrets = toSecretResolver(opts.secrets)
|
|
191
|
+
const logger = opts.logger ?? noopLogger
|
|
192
|
+
const hooks = opts.hooks ?? new RelayHooks()
|
|
193
|
+
const codec =
|
|
194
|
+
opts.encryption === 'none' ? plainCodec : aesGcmCodec(toKey('dataKey', opts.encryption.dataKey))
|
|
195
|
+
const maskingKey = toKey('maskingKey', opts.maskingKey)
|
|
196
|
+
const client =
|
|
197
|
+
opts.client ??
|
|
198
|
+
new EndCloseClient(
|
|
199
|
+
opts.endclose.baseUrl ?? 'https://api.endclose.com/v1',
|
|
200
|
+
opts.endclose.apiKey,
|
|
201
|
+
opts.endclose.fetch ?? fetch,
|
|
202
|
+
)
|
|
203
|
+
const dispatch: DispatchSettings = { ...DEFAULT_DISPATCH, ...opts.dispatch }
|
|
204
|
+
const retention = opts.retention === false ? null : { ...DEFAULT_RETENTION, ...opts.retention }
|
|
205
|
+
const signal = new EventEmitter()
|
|
206
|
+
const { store } = opts
|
|
207
|
+
|
|
208
|
+
const ingestDeps = {
|
|
209
|
+
store,
|
|
210
|
+
control,
|
|
211
|
+
routes,
|
|
212
|
+
secrets,
|
|
213
|
+
codec,
|
|
214
|
+
signal,
|
|
215
|
+
hooks,
|
|
216
|
+
logger,
|
|
217
|
+
...(opts.adapters ? { adapters: opts.adapters } : {}),
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const dispatcher = new Dispatcher({
|
|
221
|
+
store,
|
|
222
|
+
control,
|
|
223
|
+
routes,
|
|
224
|
+
dispatch,
|
|
225
|
+
retention,
|
|
226
|
+
client,
|
|
227
|
+
codec,
|
|
228
|
+
maskingKey,
|
|
229
|
+
instanceId: opts.instanceId ?? randomUUID(),
|
|
230
|
+
signal,
|
|
231
|
+
hooks,
|
|
232
|
+
logger,
|
|
233
|
+
...(opts.enrichments ? { enrichments: opts.enrichments } : {}),
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
const dispatchOnce = async (o: { prune?: boolean } = {}): Promise<DispatchOnceResult> => {
|
|
237
|
+
const { delivered, retried, parked } = await dispatcher.runOnce()
|
|
238
|
+
if (o.prune) await dispatcher.pruneNow()
|
|
239
|
+
return { delivered, retried, parked }
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const flush = async ({ timeoutMs = 30_000 } = {}): Promise<FlushResult> => {
|
|
243
|
+
const deadline = Date.now() + timeoutMs
|
|
244
|
+
const totals: FlushResult = { delivered: 0, retried: 0, parked: 0, drained: false }
|
|
245
|
+
let pollMs = FLUSH_POLL_MIN_MS
|
|
246
|
+
for (;;) {
|
|
247
|
+
const c = await dispatcher.runOnce()
|
|
248
|
+
totals.delivered += c.delivered
|
|
249
|
+
totals.retried += c.retried
|
|
250
|
+
totals.parked += c.parked
|
|
251
|
+
if (c.halted) return { ...totals, reason: 'paused' }
|
|
252
|
+
|
|
253
|
+
const remaining = deadline - Date.now()
|
|
254
|
+
if (remaining <= 0) return { ...totals, reason: 'timeout' }
|
|
255
|
+
const touched = c.delivered + c.retried + c.parked
|
|
256
|
+
if (touched > 0) continue // there may be more than one batch's worth; go straight back
|
|
257
|
+
|
|
258
|
+
const backlog = await store.routesWithDueEvents(FAR_FUTURE)
|
|
259
|
+
if (backlog.length === 0) return { ...totals, drained: true }
|
|
260
|
+
// Backlog confined to routes the cycle deliberately skipped will not clear on its
|
|
261
|
+
// own. Anything else is waiting on a backoff timer.
|
|
262
|
+
const skipped = new Map(c.skipped.map((s) => [s.routeId, s.reason]))
|
|
263
|
+
if (backlog.every((r) => skipped.has(r))) {
|
|
264
|
+
const allPaused = backlog.every((r) => skipped.get(r) === 'paused')
|
|
265
|
+
return { ...totals, reason: allPaused ? 'paused' : 'unroutable' }
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
await sleep(Math.min(pollMs, remaining))
|
|
269
|
+
pollMs = Math.min(pollMs * 2, FLUSH_POLL_MAX_MS)
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const readPayload = async (id: string): Promise<Buffer | undefined> => {
|
|
274
|
+
if (!hasAdmin(store)) throw new Error('store does not support inspection (EventStoreAdmin)')
|
|
275
|
+
const row = await store.getById(id)
|
|
276
|
+
if (!row || row.payload.length === 0) return undefined
|
|
277
|
+
return codec.decode(row.payload, row.payload_iv)
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
return {
|
|
281
|
+
ingest: (routeId, req) => ingestWebhook(ingestDeps, routeId, req),
|
|
282
|
+
start: () => dispatcher.start(),
|
|
283
|
+
stop: () => dispatcher.stop(),
|
|
284
|
+
dispatchOnce,
|
|
285
|
+
flush,
|
|
286
|
+
prune: () => dispatcher.pruneNow(),
|
|
287
|
+
preview: (route, sample, receivedAt = new Date().toISOString()) =>
|
|
288
|
+
mapEvent(route, sample, receivedAt, maskingKey),
|
|
289
|
+
readPayload,
|
|
290
|
+
on: (name, handler) => hooks.on(name, handler),
|
|
291
|
+
store,
|
|
292
|
+
control,
|
|
293
|
+
}
|
|
294
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// Secret resolution for the engine. Route configs reference secrets by NAME only (the
|
|
2
|
+
// `secret_env` field); the host decides where the value comes from. The application uses the
|
|
3
|
+
// process environment; an embedding application may use a static map loaded from its
|
|
4
|
+
// secret manager at startup.
|
|
5
|
+
//
|
|
6
|
+
// Resolution is synchronous by design: it sits on the webhook verification hot path, and
|
|
7
|
+
// secrets should be materialised once at boot rather than fetched per request.
|
|
8
|
+
|
|
9
|
+
export interface SecretResolver {
|
|
10
|
+
/** The secret value for `ref`, or undefined when it is not available. */
|
|
11
|
+
resolve(ref: string): string | undefined
|
|
12
|
+
/** Whether `ref` is currently available (defaults to `resolve(ref) !== undefined`). */
|
|
13
|
+
has?(ref: string): boolean
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export class SecretUnavailableError extends Error {
|
|
17
|
+
constructor(readonly ref: string) {
|
|
18
|
+
super(`missing required secret env var: ${ref}`)
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Resolve from an environment-shaped record; empty strings count as unset. */
|
|
23
|
+
export function envSecrets(env: Record<string, string | undefined> = process.env): SecretResolver {
|
|
24
|
+
return {
|
|
25
|
+
resolve: (ref) => {
|
|
26
|
+
const v = env[ref]
|
|
27
|
+
return v ? v : undefined
|
|
28
|
+
},
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function staticSecrets(map: Record<string, string>): SecretResolver {
|
|
33
|
+
return { resolve: (ref) => map[ref] }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function toSecretResolver(s: SecretResolver | Record<string, string>): SecretResolver {
|
|
37
|
+
return typeof (s as SecretResolver).resolve === 'function'
|
|
38
|
+
? (s as SecretResolver)
|
|
39
|
+
: staticSecrets(s as Record<string, string>)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function hasSecret(resolver: SecretResolver, ref: string): boolean {
|
|
43
|
+
return resolver.has ? resolver.has(ref) : resolver.resolve(ref) !== undefined
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Resolve or throw `SecretUnavailableError`. */
|
|
47
|
+
export function requireSecret(resolver: SecretResolver, ref: string): string {
|
|
48
|
+
const v = resolver.resolve(ref)
|
|
49
|
+
if (v === undefined) throw new SecretUnavailableError(ref)
|
|
50
|
+
return v
|
|
51
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Tuning knobs shared by createRelay() and the Dispatcher. One shape, camelCase.
|
|
2
|
+
|
|
3
|
+
export interface DispatchSettings {
|
|
4
|
+
/** Records per bulk POST. */
|
|
5
|
+
batchMax: number
|
|
6
|
+
/** How often the loop wakes when idle (it also wakes immediately on ingest). */
|
|
7
|
+
pollIntervalMs: number
|
|
8
|
+
backoffBaseMs: number
|
|
9
|
+
backoffCapMs: number
|
|
10
|
+
/** Retrying events park (never dropped) after this long. */
|
|
11
|
+
parkAfterMs: number
|
|
12
|
+
/** How long a claimed batch is protected from recovery by other instances. */
|
|
13
|
+
leaseMs: number
|
|
14
|
+
/** How often a running instance sweeps for expired leases left by crashed peers. */
|
|
15
|
+
recoverIntervalMs: number
|
|
16
|
+
/**
|
|
17
|
+
* Upper bound on one `enrich` call. A slower call counts as a transient failure and the
|
|
18
|
+
* event retries with backoff. Enrichments run one at a time per event, so keep
|
|
19
|
+
* batchMax × (enrichments per event) × enrichTimeoutMs well under leaseMs.
|
|
20
|
+
*/
|
|
21
|
+
enrichTimeoutMs: number
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface RetentionSettings {
|
|
25
|
+
/** Payloads of delivered/filtered events are wiped after this many days. */
|
|
26
|
+
deliveredDays: number
|
|
27
|
+
/** Their rows (the idempotency ledger) are deleted after this many days. */
|
|
28
|
+
ledgerDays: number
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const DEFAULT_DISPATCH: DispatchSettings = {
|
|
32
|
+
batchMax: 100,
|
|
33
|
+
pollIntervalMs: 250,
|
|
34
|
+
backoffBaseMs: 1000,
|
|
35
|
+
backoffCapMs: 600_000,
|
|
36
|
+
parkAfterMs: 7 * 24 * 3600 * 1000,
|
|
37
|
+
leaseMs: 600_000,
|
|
38
|
+
recoverIntervalMs: 60_000,
|
|
39
|
+
enrichTimeoutMs: 5_000,
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export const DEFAULT_RETENTION: RetentionSettings = { deliveredDays: 7, ledgerDays: 30 }
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import type { RouteConfig } from '../config/schema.js'
|
|
2
|
+
|
|
3
|
+
// The persistence contracts the engine depends on. A host supplies implementations: the
|
|
4
|
+
// application uses SQLite; an embedding application may use its own database. Every
|
|
5
|
+
// method is async so implementations can be backed by a network database.
|
|
6
|
+
|
|
7
|
+
export type EventStatus =
|
|
8
|
+
| 'pending'
|
|
9
|
+
| 'retry'
|
|
10
|
+
| 'delivering'
|
|
11
|
+
| 'delivered'
|
|
12
|
+
| 'parked'
|
|
13
|
+
| 'dropped_by_filter'
|
|
14
|
+
|
|
15
|
+
export interface EventRecord {
|
|
16
|
+
/** Opaque store-assigned id. */
|
|
17
|
+
id: string
|
|
18
|
+
route_id: string
|
|
19
|
+
source: string
|
|
20
|
+
event_id: string
|
|
21
|
+
event_type: string | null
|
|
22
|
+
/** Whatever the PayloadCodec produced; opaque to the store. */
|
|
23
|
+
payload: Buffer
|
|
24
|
+
payload_iv: Buffer | null
|
|
25
|
+
headers_json: string
|
|
26
|
+
received_at: string
|
|
27
|
+
status: EventStatus
|
|
28
|
+
attempts: number
|
|
29
|
+
next_attempt_at: string | null
|
|
30
|
+
delivered_at: string | null
|
|
31
|
+
bulk_request_id: string | null
|
|
32
|
+
last_error: string | null
|
|
33
|
+
idempotency_key: string
|
|
34
|
+
claimed_by: string | null
|
|
35
|
+
lease_until: string | null
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface NewEvent {
|
|
39
|
+
route_id: string
|
|
40
|
+
source: string
|
|
41
|
+
event_id: string
|
|
42
|
+
event_type: string | null
|
|
43
|
+
payload: Buffer
|
|
44
|
+
payload_iv: Buffer | null
|
|
45
|
+
headers_json: string
|
|
46
|
+
received_at: string
|
|
47
|
+
status: EventStatus
|
|
48
|
+
idempotency_key: string
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface RouteStats {
|
|
52
|
+
route_id: string
|
|
53
|
+
counts: Partial<Record<EventStatus, number>>
|
|
54
|
+
last_delivered_at: string | null
|
|
55
|
+
oldest_pending_at: string | null
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export type EventSummary = Omit<
|
|
59
|
+
EventRecord,
|
|
60
|
+
'payload' | 'payload_iv' | 'headers_json' | 'idempotency_key' | 'claimed_by' | 'lease_until'
|
|
61
|
+
>
|
|
62
|
+
|
|
63
|
+
export type InsertResult = { duplicate: false; id: string } | { duplicate: true }
|
|
64
|
+
|
|
65
|
+
/** A store operation failed. `op` names the operation for logs and hooks. */
|
|
66
|
+
export class StoreError extends Error {
|
|
67
|
+
constructor(
|
|
68
|
+
message: string,
|
|
69
|
+
readonly op: string,
|
|
70
|
+
options?: { cause?: unknown },
|
|
71
|
+
) {
|
|
72
|
+
super(message, options)
|
|
73
|
+
this.name = 'StoreError'
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The store is temporarily unable to serve a request (lock contention, connection
|
|
79
|
+
* loss). Ingest answers 503 so the processor retries; any other StoreError is 500.
|
|
80
|
+
*/
|
|
81
|
+
export class StoreUnavailableError extends StoreError {
|
|
82
|
+
constructor(message: string, op: string, options?: { cause?: unknown }) {
|
|
83
|
+
super(message, op, options)
|
|
84
|
+
this.name = 'StoreUnavailableError'
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function storeOp(err: unknown): string | undefined {
|
|
89
|
+
return err instanceof StoreError ? err.op : undefined
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** What the engine needs from event storage. */
|
|
93
|
+
export interface EventStore {
|
|
94
|
+
/** Persist a new event; `duplicate: true` when the idempotency key already exists. */
|
|
95
|
+
insert(e: NewEvent): Promise<InsertResult>
|
|
96
|
+
/**
|
|
97
|
+
* Routes with `pending`/`retry` events whose next attempt is at or before `now`.
|
|
98
|
+
* Called with a far-future `now` it answers "which routes hold any backlog at all".
|
|
99
|
+
*/
|
|
100
|
+
routesWithDueEvents(now: string): Promise<string[]>
|
|
101
|
+
/**
|
|
102
|
+
* Atomically select up to `limit` due events for a route (oldest first), mark them
|
|
103
|
+
* `delivering` and record the lease. Concurrent callers must never receive overlapping
|
|
104
|
+
* rows (a SQL store would use SELECT ... FOR UPDATE SKIP LOCKED).
|
|
105
|
+
*/
|
|
106
|
+
claimDue(routeId: string, now: string, limit: number, lease: Lease): Promise<EventRecord[]>
|
|
107
|
+
markDelivered(ids: string[], deliveredAt: string, bulkRequestId: string | null): Promise<void>
|
|
108
|
+
markFailed(ids: string[], nextAttemptAt: string, error: string): Promise<void>
|
|
109
|
+
markParked(ids: string[], error: string): Promise<void>
|
|
110
|
+
/** Return the given rows to `retry` if (and only if) they are still `delivering`. */
|
|
111
|
+
releaseDelivering(ids: string[], nextAttemptAt: string, error: string): Promise<number>
|
|
112
|
+
/**
|
|
113
|
+
* Return `delivering` rows whose lease has expired — or that were claimed by `owner`,
|
|
114
|
+
* so a restarted instance reclaims its own work immediately — to `retry`.
|
|
115
|
+
*/
|
|
116
|
+
recoverDelivering(now: string, owner?: string): Promise<number>
|
|
117
|
+
/** Park events that have been retrying longer than `maxAgeMs`. */
|
|
118
|
+
parkExpired(now: string, maxAgeMs: number): Promise<number>
|
|
119
|
+
/** One bounded retention step; callers loop until it returns zeros. */
|
|
120
|
+
pruneBatch(
|
|
121
|
+
now: string,
|
|
122
|
+
deliveredDays: number,
|
|
123
|
+
ledgerDays: number,
|
|
124
|
+
limit: number,
|
|
125
|
+
): Promise<{ wiped: number; deleted: number }>
|
|
126
|
+
close?(): Promise<void>
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export interface Lease {
|
|
130
|
+
owner: string
|
|
131
|
+
until: string
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Optional inspection/replay capability used by an operator surface. */
|
|
135
|
+
export interface EventStoreAdmin {
|
|
136
|
+
getById(id: string): Promise<EventRecord | undefined>
|
|
137
|
+
list(filter: { status?: EventStatus; route?: string; limit?: number }): Promise<EventSummary[]>
|
|
138
|
+
countByStatus(): Promise<Record<string, number>>
|
|
139
|
+
perRouteStats(): Promise<RouteStats[]>
|
|
140
|
+
replay(id: string): Promise<boolean>
|
|
141
|
+
replayAllParked(): Promise<number>
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function hasAdmin(store: EventStore): store is EventStore & EventStoreAdmin {
|
|
145
|
+
return typeof (store as Partial<EventStoreAdmin>).list === 'function'
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export type Killswitch = 'none' | 'pause' | 'panic'
|
|
149
|
+
|
|
150
|
+
/** Runtime-mutable operator state: the global killswitch and per-route pauses. */
|
|
151
|
+
export interface ControlStore {
|
|
152
|
+
getKillswitch(): Promise<Killswitch>
|
|
153
|
+
setKillswitch(state: Killswitch): Promise<void>
|
|
154
|
+
isRoutePaused(routeId: string): Promise<boolean>
|
|
155
|
+
setRoutePaused(routeId: string, paused: boolean): Promise<void>
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Where the engine reads route definitions. Static for an embedding app; live for the application. */
|
|
159
|
+
export interface RouteProvider {
|
|
160
|
+
get(id: string): Promise<RouteConfig | undefined>
|
|
161
|
+
all(): Promise<RouteConfig[]>
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function staticRoutes(routes: RouteConfig[]): RouteProvider {
|
|
165
|
+
const byId = new Map(routes.map((r) => [r.id, r]))
|
|
166
|
+
return {
|
|
167
|
+
get: async (id) => byId.get(id),
|
|
168
|
+
all: async () => [...byId.values()],
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export class MemoryControlStore implements ControlStore {
|
|
173
|
+
private killswitch: Killswitch = 'none'
|
|
174
|
+
private paused = new Set<string>()
|
|
175
|
+
async getKillswitch(): Promise<Killswitch> {
|
|
176
|
+
return this.killswitch
|
|
177
|
+
}
|
|
178
|
+
async setKillswitch(state: Killswitch): Promise<void> {
|
|
179
|
+
this.killswitch = state
|
|
180
|
+
}
|
|
181
|
+
async isRoutePaused(routeId: string): Promise<boolean> {
|
|
182
|
+
return this.paused.has(routeId)
|
|
183
|
+
}
|
|
184
|
+
async setRoutePaused(routeId: string, paused: boolean): Promise<void> {
|
|
185
|
+
if (paused) this.paused.add(routeId)
|
|
186
|
+
else this.paused.delete(routeId)
|
|
187
|
+
}
|
|
188
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// Exponential backoff with ±20% jitter, Sequin-style: base * 2^attempts, capped.
|
|
2
|
+
export function backoffMs(attempts: number, baseMs = 1000, capMs = 600_000): number {
|
|
3
|
+
const raw = Math.min(baseMs * 2 ** Math.min(attempts, 30), capMs)
|
|
4
|
+
const jitter = 0.8 + Math.random() * 0.4
|
|
5
|
+
return Math.round(raw * jitter)
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function nextAttemptAt(attempts: number, baseMs?: number, capMs?: number): string {
|
|
9
|
+
return new Date(Date.now() + backoffMs(attempts, baseMs, capMs)).toISOString()
|
|
10
|
+
}
|