@hasna/events 0.1.7 → 0.1.9
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/README.md +41 -0
- package/dist/cli/index.js +215 -23
- package/dist/commander.js +180 -25
- package/dist/filter-options.d.ts +15 -0
- package/dist/filter.d.ts +3 -1
- package/dist/filter.js +21 -6
- package/dist/index.d.ts +13 -2
- package/dist/index.js +105 -7
- package/dist/storage.d.ts +3 -1
- package/dist/storage.js +55 -0
- package/dist/types.d.ts +41 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -204,6 +204,37 @@ events webhooks test ops
|
|
|
204
204
|
events webhooks remove ops
|
|
205
205
|
```
|
|
206
206
|
|
|
207
|
+
Field filters can match nested `data` or `metadata` values. Plain
|
|
208
|
+
`--data`/`--metadata` values are strings, which keeps ids and slugs such as
|
|
209
|
+
`001` intact. Use `--data-json` or `--metadata-json` for typed JSON predicates.
|
|
210
|
+
Dot paths access nested object keys; dots inside key names are not escaped yet,
|
|
211
|
+
and array traversal is not special-cased. Wildcard behavior stays broad for
|
|
212
|
+
legacy source/type/subject filters. For field paths ending in `_path` or `.path`,
|
|
213
|
+
`*` matches one path segment and `**` matches recursively.
|
|
214
|
+
|
|
215
|
+
```bash
|
|
216
|
+
events webhooks add loops \
|
|
217
|
+
--id open-source-task-route \
|
|
218
|
+
--transport command \
|
|
219
|
+
--source todos \
|
|
220
|
+
--type task.created \
|
|
221
|
+
--metadata 'project_path=/home/hasna/workspace/hasna/opensource/*' \
|
|
222
|
+
--metadata-json 'route_enabled=true' \
|
|
223
|
+
--arg events \
|
|
224
|
+
--arg handle \
|
|
225
|
+
--arg todos-task
|
|
226
|
+
|
|
227
|
+
events webhooks match open-source-task-route \
|
|
228
|
+
--source todos \
|
|
229
|
+
--type task.created \
|
|
230
|
+
--metadata '{"project_path":"/home/hasna/workspace/hasna/opensource/open-events","route_enabled":true}'
|
|
231
|
+
|
|
232
|
+
events webhooks test open-source-task-route --honor-filters \
|
|
233
|
+
--source todos \
|
|
234
|
+
--type task.created \
|
|
235
|
+
--metadata '{"project_path":"/tmp/outside","route_enabled":true}'
|
|
236
|
+
```
|
|
237
|
+
|
|
207
238
|
Emit, list, and replay:
|
|
208
239
|
|
|
209
240
|
```bash
|
|
@@ -219,6 +250,16 @@ events events replay --type ticket.created
|
|
|
219
250
|
events events replay --dry-run
|
|
220
251
|
```
|
|
221
252
|
|
|
253
|
+
Machine-readable status:
|
|
254
|
+
|
|
255
|
+
```bash
|
|
256
|
+
events status --json
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
The status contract reports event, channel, delivery, file, and transport counts
|
|
260
|
+
only. It does not include event payloads, webhook signing secrets, command
|
|
261
|
+
environment values, or channel targets.
|
|
262
|
+
|
|
222
263
|
Use `--json` for script-friendly output and `--dir <path>` for isolated data.
|
|
223
264
|
|
|
224
265
|
## App Integration Pattern
|
package/dist/cli/index.js
CHANGED
|
@@ -18,17 +18,30 @@ function getPathValue(input, path) {
|
|
|
18
18
|
return;
|
|
19
19
|
}, input);
|
|
20
20
|
}
|
|
21
|
-
function wildcardToRegExp(pattern) {
|
|
22
|
-
|
|
23
|
-
|
|
21
|
+
function wildcardToRegExp(pattern, options = {}) {
|
|
22
|
+
let body = "";
|
|
23
|
+
for (let index = 0;index < pattern.length; index += 1) {
|
|
24
|
+
const char = pattern[index];
|
|
25
|
+
if (char === "*") {
|
|
26
|
+
if (pattern[index + 1] === "*") {
|
|
27
|
+
body += ".*";
|
|
28
|
+
index += 1;
|
|
29
|
+
} else {
|
|
30
|
+
body += options.segmentSafe ? "[^/]*" : ".*";
|
|
31
|
+
}
|
|
32
|
+
} else {
|
|
33
|
+
body += char.replace(/[|\\{}()[\]^$+?.]/g, "\\$&");
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return new RegExp(`^${body}$`);
|
|
24
37
|
}
|
|
25
|
-
function matchString(value, matcher) {
|
|
38
|
+
function matchString(value, matcher, options = {}) {
|
|
26
39
|
if (matcher === undefined)
|
|
27
40
|
return true;
|
|
28
41
|
if (value === undefined)
|
|
29
42
|
return false;
|
|
30
43
|
const matchers = Array.isArray(matcher) ? matcher : [matcher];
|
|
31
|
-
return matchers.some((item) => wildcardToRegExp(item).test(value));
|
|
44
|
+
return matchers.some((item) => wildcardToRegExp(item, options).test(value));
|
|
32
45
|
}
|
|
33
46
|
function matchRecord(input, matcher) {
|
|
34
47
|
if (!matcher)
|
|
@@ -36,7 +49,9 @@ function matchRecord(input, matcher) {
|
|
|
36
49
|
return Object.entries(matcher).every(([path, expected]) => {
|
|
37
50
|
const actual = getPathValue(input, path);
|
|
38
51
|
if (typeof expected === "string" || Array.isArray(expected)) {
|
|
39
|
-
return matchString(actual === undefined ? undefined : String(actual), expected
|
|
52
|
+
return matchString(actual === undefined ? undefined : String(actual), expected, {
|
|
53
|
+
segmentSafe: path.endsWith("_path") || path.endsWith(".path")
|
|
54
|
+
});
|
|
40
55
|
}
|
|
41
56
|
return actual === expected;
|
|
42
57
|
});
|
|
@@ -62,6 +77,13 @@ var HASNA_EVENTS_HOME_ENV = "HASNA_EVENTS_HOME";
|
|
|
62
77
|
function getEventsDataDir(override) {
|
|
63
78
|
return override || process.env[HASNA_EVENTS_DIR_ENV] || process.env[HASNA_EVENTS_HOME_ENV] || join(homedir(), ".hasna", "events");
|
|
64
79
|
}
|
|
80
|
+
function getActiveEventsDirEnv() {
|
|
81
|
+
if (process.env[HASNA_EVENTS_DIR_ENV])
|
|
82
|
+
return HASNA_EVENTS_DIR_ENV;
|
|
83
|
+
if (process.env[HASNA_EVENTS_HOME_ENV])
|
|
84
|
+
return HASNA_EVENTS_HOME_ENV;
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
65
87
|
|
|
66
88
|
class JsonEventsStore {
|
|
67
89
|
dataDir;
|
|
@@ -174,6 +196,52 @@ class JsonEventsStore {
|
|
|
174
196
|
});
|
|
175
197
|
}
|
|
176
198
|
}
|
|
199
|
+
async function getEventsStatus(dataDir) {
|
|
200
|
+
const store = new JsonEventsStore(dataDir);
|
|
201
|
+
await store.init();
|
|
202
|
+
const [channels, events, deliveries] = await Promise.all([
|
|
203
|
+
store.listChannels(),
|
|
204
|
+
store.listEvents(),
|
|
205
|
+
store.listDeliveries()
|
|
206
|
+
]);
|
|
207
|
+
const transports = channels.reduce((counts, channel) => {
|
|
208
|
+
counts[channel.transport] = (counts[channel.transport] ?? 0) + 1;
|
|
209
|
+
return counts;
|
|
210
|
+
}, {});
|
|
211
|
+
return {
|
|
212
|
+
service: "events",
|
|
213
|
+
schemaVersion: "1.0",
|
|
214
|
+
dataDir: store.dataDir,
|
|
215
|
+
env: {
|
|
216
|
+
primary: HASNA_EVENTS_DIR_ENV,
|
|
217
|
+
fallback: HASNA_EVENTS_HOME_ENV,
|
|
218
|
+
active: getActiveEventsDirEnv()
|
|
219
|
+
},
|
|
220
|
+
files: {
|
|
221
|
+
channels: statusFile(store.dataDir, "channels.json", channels.length),
|
|
222
|
+
events: statusFile(store.dataDir, "events.json", events.length),
|
|
223
|
+
deliveries: statusFile(store.dataDir, "deliveries.json", deliveries.length)
|
|
224
|
+
},
|
|
225
|
+
counts: {
|
|
226
|
+
channels: channels.length,
|
|
227
|
+
enabledChannels: channels.filter((channel) => channel.enabled).length,
|
|
228
|
+
disabledChannels: channels.filter((channel) => !channel.enabled).length,
|
|
229
|
+
events: events.length,
|
|
230
|
+
deliveries: deliveries.length
|
|
231
|
+
},
|
|
232
|
+
transports,
|
|
233
|
+
safety: {
|
|
234
|
+
includesEventPayloads: false,
|
|
235
|
+
includesWebhookSecrets: false,
|
|
236
|
+
listOutputsRedactSecrets: true,
|
|
237
|
+
statusOutputIsMetadataOnly: true
|
|
238
|
+
}
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
function statusFile(dataDir, fileName, records) {
|
|
242
|
+
const path = join(dataDir, fileName);
|
|
243
|
+
return { path, exists: existsSync(path), records };
|
|
244
|
+
}
|
|
177
245
|
|
|
178
246
|
// src/transports.ts
|
|
179
247
|
import { randomUUID } from "crypto";
|
|
@@ -410,7 +478,7 @@ class EventsClient {
|
|
|
410
478
|
}
|
|
411
479
|
return deliveries;
|
|
412
480
|
}
|
|
413
|
-
async
|
|
481
|
+
async matchChannel(id, input = {}) {
|
|
414
482
|
const channel = await this.store.getChannel(id);
|
|
415
483
|
if (!channel)
|
|
416
484
|
throw new Error(`Channel not found: ${id}`);
|
|
@@ -427,6 +495,34 @@ class EventsClient {
|
|
|
427
495
|
time: input.time,
|
|
428
496
|
id: input.id
|
|
429
497
|
});
|
|
498
|
+
const matched = channelMatchesEvent(channel, event);
|
|
499
|
+
return {
|
|
500
|
+
channelId: channel.id,
|
|
501
|
+
matched,
|
|
502
|
+
event,
|
|
503
|
+
filters: channel.filters,
|
|
504
|
+
reason: matched ? undefined : channel.enabled ? "event did not match channel filters" : "channel is disabled"
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
async testChannel(id, input = {}, options = {}) {
|
|
508
|
+
const channel = await this.store.getChannel(id);
|
|
509
|
+
if (!channel)
|
|
510
|
+
throw new Error(`Channel not found: ${id}`);
|
|
511
|
+
const match = await this.matchChannel(id, input);
|
|
512
|
+
const event = match.event;
|
|
513
|
+
if (options.honorFilters && !match.matched) {
|
|
514
|
+
const timestamp = new Date().toISOString();
|
|
515
|
+
const result2 = createDeliveryResult(event, channel, [{
|
|
516
|
+
attempt: 1,
|
|
517
|
+
status: "skipped",
|
|
518
|
+
startedAt: timestamp,
|
|
519
|
+
completedAt: timestamp,
|
|
520
|
+
error: match.reason
|
|
521
|
+
}]);
|
|
522
|
+
result2.metadata = { reason: "filter_mismatch" };
|
|
523
|
+
await this.store.appendDelivery(result2);
|
|
524
|
+
return result2;
|
|
525
|
+
}
|
|
430
526
|
const eventForChannel = await this.applyRedaction(event, channel);
|
|
431
527
|
const result = await this.deliverWithRetry(eventForChannel, channel);
|
|
432
528
|
await this.store.appendDelivery(result);
|
|
@@ -538,6 +634,61 @@ function normalizeRetryPolicy(policy) {
|
|
|
538
634
|
};
|
|
539
635
|
}
|
|
540
636
|
|
|
637
|
+
// src/filter-options.ts
|
|
638
|
+
function parseFieldMatchers(values, label, typed = false) {
|
|
639
|
+
if (!values?.length)
|
|
640
|
+
return;
|
|
641
|
+
const result = {};
|
|
642
|
+
for (const value of values) {
|
|
643
|
+
const separator = value.indexOf("=");
|
|
644
|
+
if (separator <= 0)
|
|
645
|
+
throw new Error(`Invalid ${label} filter, expected path=value: ${value}`);
|
|
646
|
+
const path = value.slice(0, separator);
|
|
647
|
+
if (path in result)
|
|
648
|
+
throw new Error(`Duplicate ${label} filter path: ${path}`);
|
|
649
|
+
result[path] = typed ? parseTypedMatcherValue(value.slice(separator + 1), label) : value.slice(separator + 1);
|
|
650
|
+
}
|
|
651
|
+
return result;
|
|
652
|
+
}
|
|
653
|
+
function parseFilterOptions(options) {
|
|
654
|
+
const filter2 = {};
|
|
655
|
+
if (options.source)
|
|
656
|
+
filter2.source = options.source;
|
|
657
|
+
if (options.type)
|
|
658
|
+
filter2.type = options.type;
|
|
659
|
+
if (options.subject)
|
|
660
|
+
filter2.subject = options.subject;
|
|
661
|
+
if (options.severity)
|
|
662
|
+
filter2.severity = options.severity;
|
|
663
|
+
const data = mergeMatchers(parseFieldMatchers(options.data, "data"), parseFieldMatchers(options.dataJson, "data-json", true));
|
|
664
|
+
const metadata = mergeMatchers(parseFieldMatchers(options.metadata, "metadata"), parseFieldMatchers(options.metadataJson, "metadata-json", true));
|
|
665
|
+
if (Object.keys(data).length > 0)
|
|
666
|
+
filter2.data = data;
|
|
667
|
+
if (Object.keys(metadata).length > 0)
|
|
668
|
+
filter2.metadata = metadata;
|
|
669
|
+
return Object.keys(filter2).length > 0 ? [filter2] : undefined;
|
|
670
|
+
}
|
|
671
|
+
function mergeMatchers(...records) {
|
|
672
|
+
const result = {};
|
|
673
|
+
for (const record of records) {
|
|
674
|
+
if (!record)
|
|
675
|
+
continue;
|
|
676
|
+
for (const [path, value] of Object.entries(record)) {
|
|
677
|
+
if (path in result)
|
|
678
|
+
throw new Error(`Duplicate filter path: ${path}`);
|
|
679
|
+
result[path] = value;
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
return result;
|
|
683
|
+
}
|
|
684
|
+
function parseTypedMatcherValue(value, label) {
|
|
685
|
+
const parsed = JSON.parse(value);
|
|
686
|
+
if (parsed === null || typeof parsed === "string" || typeof parsed === "number" || typeof parsed === "boolean" || Array.isArray(parsed) && parsed.every((item) => typeof item === "string")) {
|
|
687
|
+
return parsed;
|
|
688
|
+
}
|
|
689
|
+
throw new Error(`${label} filter JSON values must be string, string[], number, boolean, or null`);
|
|
690
|
+
}
|
|
691
|
+
|
|
541
692
|
// src/cli/index.ts
|
|
542
693
|
function version() {
|
|
543
694
|
try {
|
|
@@ -606,20 +757,16 @@ function parseJsonOption(value, fallback) {
|
|
|
606
757
|
return parsed;
|
|
607
758
|
}
|
|
608
759
|
function parseFilter(args) {
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
filter2.subject = subject;
|
|
620
|
-
if (severity)
|
|
621
|
-
filter2.severity = severity;
|
|
622
|
-
return Object.keys(filter2).length > 0 ? [filter2] : undefined;
|
|
760
|
+
return parseFilterOptions({
|
|
761
|
+
type: takeOption(args, "--type") ?? takeOption(args, "--event-type"),
|
|
762
|
+
source: takeOption(args, "--source"),
|
|
763
|
+
subject: takeOption(args, "--subject"),
|
|
764
|
+
severity: takeOption(args, "--severity"),
|
|
765
|
+
data: takeMany(args, "--data"),
|
|
766
|
+
metadata: takeMany(args, "--metadata"),
|
|
767
|
+
dataJson: takeMany(args, "--data-json"),
|
|
768
|
+
metadataJson: takeMany(args, "--metadata-json")
|
|
769
|
+
});
|
|
623
770
|
}
|
|
624
771
|
function parseHeaders(values) {
|
|
625
772
|
if (values.length === 0)
|
|
@@ -652,6 +799,9 @@ Usage:
|
|
|
652
799
|
${name} [--dir <path>] [--json] webhooks list
|
|
653
800
|
${name} [--dir <path>] [--json] webhooks remove <id>
|
|
654
801
|
${name} [--dir <path>] [--json] webhooks test <id>
|
|
802
|
+
${name} [--dir <path>] [--json] webhooks match <id>
|
|
803
|
+
${name} [--dir <path>] [--json] webhooks status
|
|
804
|
+
${name} [--dir <path>] [--json] status
|
|
655
805
|
${name} [--dir <path>] [--json] events emit <type>${options.source ? "" : " --source <source>"} [options]
|
|
656
806
|
${name} [--dir <path>] [--json] events list [--limit <n>]
|
|
657
807
|
${name} [--dir <path>] [--json] events replay [--id <event-id>] [--dry-run]
|
|
@@ -668,6 +818,8 @@ Usage:
|
|
|
668
818
|
${name} [--dir <path>] [--json] webhooks list
|
|
669
819
|
${name} [--dir <path>] [--json] webhooks remove <id>
|
|
670
820
|
${name} [--dir <path>] [--json] webhooks test <id>
|
|
821
|
+
${name} [--dir <path>] [--json] webhooks match <id>
|
|
822
|
+
${name} [--dir <path>] [--json] webhooks status
|
|
671
823
|
|
|
672
824
|
Options:
|
|
673
825
|
--id <id> Channel id for add
|
|
@@ -675,6 +827,11 @@ Options:
|
|
|
675
827
|
--source <source> Event source filter
|
|
676
828
|
--subject <subject> Event subject filter
|
|
677
829
|
--severity <severity> Event severity filter
|
|
830
|
+
--data <path=value> Event data field filter, repeatable; string values, dot paths, * segment wildcard, ** recursive wildcard
|
|
831
|
+
--metadata <path=value> Event metadata field filter, repeatable; string values, dot paths, * segment wildcard, ** recursive wildcard
|
|
832
|
+
--data-json <path=json> Event data field filter with typed JSON value
|
|
833
|
+
--metadata-json <path=json> Event metadata field filter with typed JSON value
|
|
834
|
+
--honor-filters On webhooks test, skip delivery when the sample event does not match filters
|
|
678
835
|
--transport <kind> webhook or command
|
|
679
836
|
--secret <secret> Webhook signing secret
|
|
680
837
|
--header <name=value> Webhook header, repeatable
|
|
@@ -712,6 +869,14 @@ async function runEventsCli(argv = process.argv.slice(2), options = {}) {
|
|
|
712
869
|
console.log(version());
|
|
713
870
|
return;
|
|
714
871
|
}
|
|
872
|
+
if (group === "status") {
|
|
873
|
+
const status = await getEventsStatus(parsed.dir);
|
|
874
|
+
output(parsed, status, () => {
|
|
875
|
+
console.log(`events ${status.counts.events} event(s), ${status.counts.channels} channel(s), ${status.counts.deliveries} delivery record(s)`);
|
|
876
|
+
console.log(`dataDir: ${status.dataDir}`);
|
|
877
|
+
});
|
|
878
|
+
return;
|
|
879
|
+
}
|
|
715
880
|
const store = new JsonEventsStore(parsed.dir);
|
|
716
881
|
const client = new EventsClient({ store });
|
|
717
882
|
if (group === "webhooks") {
|
|
@@ -795,6 +960,14 @@ async function handleWebhooks(client, command, tail, parsed, options) {
|
|
|
795
960
|
});
|
|
796
961
|
return;
|
|
797
962
|
}
|
|
963
|
+
if (command === "status") {
|
|
964
|
+
const status = await getEventsStatus(parsed.dir);
|
|
965
|
+
output(parsed, status, () => {
|
|
966
|
+
console.log(`events dataDir: ${status.dataDir}`);
|
|
967
|
+
console.log(`${status.counts.enabledChannels}/${status.counts.channels} channel(s) enabled`);
|
|
968
|
+
});
|
|
969
|
+
return;
|
|
970
|
+
}
|
|
798
971
|
if (command === "remove") {
|
|
799
972
|
const id = tail[0];
|
|
800
973
|
if (!id)
|
|
@@ -808,15 +981,34 @@ async function handleWebhooks(client, command, tail, parsed, options) {
|
|
|
808
981
|
const id = args.shift();
|
|
809
982
|
if (!id)
|
|
810
983
|
throw new Error("webhooks test requires a channel id");
|
|
984
|
+
const honorFilters = takeFlag(args, "--honor-filters");
|
|
811
985
|
const result = await client.testChannel(id, {
|
|
812
986
|
source: takeOption(args, "--source") ?? options.source ?? "hasna.events",
|
|
813
987
|
type: takeOption(args, "--type") ?? "events.test",
|
|
814
988
|
subject: takeOption(args, "--subject") ?? id,
|
|
815
|
-
|
|
816
|
-
|
|
989
|
+
message: takeOption(args, "--message") ?? "Hasna events test delivery",
|
|
990
|
+
data: parseJsonOption(takeOption(args, "--data"), { test: true }),
|
|
991
|
+
metadata: parseJsonOption(takeOption(args, "--metadata"), {})
|
|
992
|
+
}, { honorFilters });
|
|
817
993
|
output(parsed, result, () => console.log(`${result.status}: ${result.channelId}`));
|
|
818
994
|
return;
|
|
819
995
|
}
|
|
996
|
+
if (command === "match") {
|
|
997
|
+
const args = [...tail];
|
|
998
|
+
const id = args.shift();
|
|
999
|
+
if (!id)
|
|
1000
|
+
throw new Error("webhooks match requires a channel id");
|
|
1001
|
+
const result = await client.matchChannel(id, {
|
|
1002
|
+
source: takeOption(args, "--source") ?? options.source ?? "hasna.events",
|
|
1003
|
+
type: takeOption(args, "--type") ?? "events.test",
|
|
1004
|
+
subject: takeOption(args, "--subject") ?? id,
|
|
1005
|
+
message: takeOption(args, "--message") ?? "Hasna events match preview",
|
|
1006
|
+
data: parseJsonOption(takeOption(args, "--data"), { test: true }),
|
|
1007
|
+
metadata: parseJsonOption(takeOption(args, "--metadata"), {})
|
|
1008
|
+
});
|
|
1009
|
+
output(parsed, result, () => console.log(`${result.matched ? "matched" : "skipped"}: ${result.channelId}`));
|
|
1010
|
+
return;
|
|
1011
|
+
}
|
|
820
1012
|
throw new Error(`Unknown webhooks command: ${command ?? ""}`);
|
|
821
1013
|
}
|
|
822
1014
|
async function handleEvents(client, command, tail, parsed, options) {
|
package/dist/commander.js
CHANGED
|
@@ -8,17 +8,30 @@ function getPathValue(input, path) {
|
|
|
8
8
|
return;
|
|
9
9
|
}, input);
|
|
10
10
|
}
|
|
11
|
-
function wildcardToRegExp(pattern) {
|
|
12
|
-
|
|
13
|
-
|
|
11
|
+
function wildcardToRegExp(pattern, options = {}) {
|
|
12
|
+
let body = "";
|
|
13
|
+
for (let index = 0;index < pattern.length; index += 1) {
|
|
14
|
+
const char = pattern[index];
|
|
15
|
+
if (char === "*") {
|
|
16
|
+
if (pattern[index + 1] === "*") {
|
|
17
|
+
body += ".*";
|
|
18
|
+
index += 1;
|
|
19
|
+
} else {
|
|
20
|
+
body += options.segmentSafe ? "[^/]*" : ".*";
|
|
21
|
+
}
|
|
22
|
+
} else {
|
|
23
|
+
body += char.replace(/[|\\{}()[\]^$+?.]/g, "\\$&");
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return new RegExp(`^${body}$`);
|
|
14
27
|
}
|
|
15
|
-
function matchString(value, matcher) {
|
|
28
|
+
function matchString(value, matcher, options = {}) {
|
|
16
29
|
if (matcher === undefined)
|
|
17
30
|
return true;
|
|
18
31
|
if (value === undefined)
|
|
19
32
|
return false;
|
|
20
33
|
const matchers = Array.isArray(matcher) ? matcher : [matcher];
|
|
21
|
-
return matchers.some((item) => wildcardToRegExp(item).test(value));
|
|
34
|
+
return matchers.some((item) => wildcardToRegExp(item, options).test(value));
|
|
22
35
|
}
|
|
23
36
|
function matchRecord(input, matcher) {
|
|
24
37
|
if (!matcher)
|
|
@@ -26,7 +39,9 @@ function matchRecord(input, matcher) {
|
|
|
26
39
|
return Object.entries(matcher).every(([path, expected]) => {
|
|
27
40
|
const actual = getPathValue(input, path);
|
|
28
41
|
if (typeof expected === "string" || Array.isArray(expected)) {
|
|
29
|
-
return matchString(actual === undefined ? undefined : String(actual), expected
|
|
42
|
+
return matchString(actual === undefined ? undefined : String(actual), expected, {
|
|
43
|
+
segmentSafe: path.endsWith("_path") || path.endsWith(".path")
|
|
44
|
+
});
|
|
30
45
|
}
|
|
31
46
|
return actual === expected;
|
|
32
47
|
});
|
|
@@ -52,6 +67,13 @@ var HASNA_EVENTS_HOME_ENV = "HASNA_EVENTS_HOME";
|
|
|
52
67
|
function getEventsDataDir(override) {
|
|
53
68
|
return override || process.env[HASNA_EVENTS_DIR_ENV] || process.env[HASNA_EVENTS_HOME_ENV] || join(homedir(), ".hasna", "events");
|
|
54
69
|
}
|
|
70
|
+
function getActiveEventsDirEnv() {
|
|
71
|
+
if (process.env[HASNA_EVENTS_DIR_ENV])
|
|
72
|
+
return HASNA_EVENTS_DIR_ENV;
|
|
73
|
+
if (process.env[HASNA_EVENTS_HOME_ENV])
|
|
74
|
+
return HASNA_EVENTS_HOME_ENV;
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
55
77
|
|
|
56
78
|
class JsonEventsStore {
|
|
57
79
|
dataDir;
|
|
@@ -164,6 +186,52 @@ class JsonEventsStore {
|
|
|
164
186
|
});
|
|
165
187
|
}
|
|
166
188
|
}
|
|
189
|
+
async function getEventsStatus(dataDir) {
|
|
190
|
+
const store = new JsonEventsStore(dataDir);
|
|
191
|
+
await store.init();
|
|
192
|
+
const [channels, events, deliveries] = await Promise.all([
|
|
193
|
+
store.listChannels(),
|
|
194
|
+
store.listEvents(),
|
|
195
|
+
store.listDeliveries()
|
|
196
|
+
]);
|
|
197
|
+
const transports = channels.reduce((counts, channel) => {
|
|
198
|
+
counts[channel.transport] = (counts[channel.transport] ?? 0) + 1;
|
|
199
|
+
return counts;
|
|
200
|
+
}, {});
|
|
201
|
+
return {
|
|
202
|
+
service: "events",
|
|
203
|
+
schemaVersion: "1.0",
|
|
204
|
+
dataDir: store.dataDir,
|
|
205
|
+
env: {
|
|
206
|
+
primary: HASNA_EVENTS_DIR_ENV,
|
|
207
|
+
fallback: HASNA_EVENTS_HOME_ENV,
|
|
208
|
+
active: getActiveEventsDirEnv()
|
|
209
|
+
},
|
|
210
|
+
files: {
|
|
211
|
+
channels: statusFile(store.dataDir, "channels.json", channels.length),
|
|
212
|
+
events: statusFile(store.dataDir, "events.json", events.length),
|
|
213
|
+
deliveries: statusFile(store.dataDir, "deliveries.json", deliveries.length)
|
|
214
|
+
},
|
|
215
|
+
counts: {
|
|
216
|
+
channels: channels.length,
|
|
217
|
+
enabledChannels: channels.filter((channel) => channel.enabled).length,
|
|
218
|
+
disabledChannels: channels.filter((channel) => !channel.enabled).length,
|
|
219
|
+
events: events.length,
|
|
220
|
+
deliveries: deliveries.length
|
|
221
|
+
},
|
|
222
|
+
transports,
|
|
223
|
+
safety: {
|
|
224
|
+
includesEventPayloads: false,
|
|
225
|
+
includesWebhookSecrets: false,
|
|
226
|
+
listOutputsRedactSecrets: true,
|
|
227
|
+
statusOutputIsMetadataOnly: true
|
|
228
|
+
}
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
function statusFile(dataDir, fileName, records) {
|
|
232
|
+
const path = join(dataDir, fileName);
|
|
233
|
+
return { path, exists: existsSync(path), records };
|
|
234
|
+
}
|
|
167
235
|
|
|
168
236
|
// src/signing.ts
|
|
169
237
|
import { createHmac, timingSafeEqual } from "crypto";
|
|
@@ -421,7 +489,7 @@ class EventsClient {
|
|
|
421
489
|
}
|
|
422
490
|
return deliveries;
|
|
423
491
|
}
|
|
424
|
-
async
|
|
492
|
+
async matchChannel(id, input = {}) {
|
|
425
493
|
const channel = await this.store.getChannel(id);
|
|
426
494
|
if (!channel)
|
|
427
495
|
throw new Error(`Channel not found: ${id}`);
|
|
@@ -438,6 +506,34 @@ class EventsClient {
|
|
|
438
506
|
time: input.time,
|
|
439
507
|
id: input.id
|
|
440
508
|
});
|
|
509
|
+
const matched = channelMatchesEvent(channel, event);
|
|
510
|
+
return {
|
|
511
|
+
channelId: channel.id,
|
|
512
|
+
matched,
|
|
513
|
+
event,
|
|
514
|
+
filters: channel.filters,
|
|
515
|
+
reason: matched ? undefined : channel.enabled ? "event did not match channel filters" : "channel is disabled"
|
|
516
|
+
};
|
|
517
|
+
}
|
|
518
|
+
async testChannel(id, input = {}, options = {}) {
|
|
519
|
+
const channel = await this.store.getChannel(id);
|
|
520
|
+
if (!channel)
|
|
521
|
+
throw new Error(`Channel not found: ${id}`);
|
|
522
|
+
const match = await this.matchChannel(id, input);
|
|
523
|
+
const event = match.event;
|
|
524
|
+
if (options.honorFilters && !match.matched) {
|
|
525
|
+
const timestamp = new Date().toISOString();
|
|
526
|
+
const result2 = createDeliveryResult(event, channel, [{
|
|
527
|
+
attempt: 1,
|
|
528
|
+
status: "skipped",
|
|
529
|
+
startedAt: timestamp,
|
|
530
|
+
completedAt: timestamp,
|
|
531
|
+
error: match.reason
|
|
532
|
+
}]);
|
|
533
|
+
result2.metadata = { reason: "filter_mismatch" };
|
|
534
|
+
await this.store.appendDelivery(result2);
|
|
535
|
+
return result2;
|
|
536
|
+
}
|
|
441
537
|
const eventForChannel = await this.applyRedaction(event, channel);
|
|
442
538
|
const result = await this.deliverWithRetry(eventForChannel, channel);
|
|
443
539
|
await this.store.appendDelivery(result);
|
|
@@ -549,6 +645,61 @@ function normalizeRetryPolicy(policy) {
|
|
|
549
645
|
};
|
|
550
646
|
}
|
|
551
647
|
|
|
648
|
+
// src/filter-options.ts
|
|
649
|
+
function parseFieldMatchers(values, label, typed = false) {
|
|
650
|
+
if (!values?.length)
|
|
651
|
+
return;
|
|
652
|
+
const result = {};
|
|
653
|
+
for (const value of values) {
|
|
654
|
+
const separator = value.indexOf("=");
|
|
655
|
+
if (separator <= 0)
|
|
656
|
+
throw new Error(`Invalid ${label} filter, expected path=value: ${value}`);
|
|
657
|
+
const path = value.slice(0, separator);
|
|
658
|
+
if (path in result)
|
|
659
|
+
throw new Error(`Duplicate ${label} filter path: ${path}`);
|
|
660
|
+
result[path] = typed ? parseTypedMatcherValue(value.slice(separator + 1), label) : value.slice(separator + 1);
|
|
661
|
+
}
|
|
662
|
+
return result;
|
|
663
|
+
}
|
|
664
|
+
function parseFilterOptions(options) {
|
|
665
|
+
const filter2 = {};
|
|
666
|
+
if (options.source)
|
|
667
|
+
filter2.source = options.source;
|
|
668
|
+
if (options.type)
|
|
669
|
+
filter2.type = options.type;
|
|
670
|
+
if (options.subject)
|
|
671
|
+
filter2.subject = options.subject;
|
|
672
|
+
if (options.severity)
|
|
673
|
+
filter2.severity = options.severity;
|
|
674
|
+
const data = mergeMatchers(parseFieldMatchers(options.data, "data"), parseFieldMatchers(options.dataJson, "data-json", true));
|
|
675
|
+
const metadata = mergeMatchers(parseFieldMatchers(options.metadata, "metadata"), parseFieldMatchers(options.metadataJson, "metadata-json", true));
|
|
676
|
+
if (Object.keys(data).length > 0)
|
|
677
|
+
filter2.data = data;
|
|
678
|
+
if (Object.keys(metadata).length > 0)
|
|
679
|
+
filter2.metadata = metadata;
|
|
680
|
+
return Object.keys(filter2).length > 0 ? [filter2] : undefined;
|
|
681
|
+
}
|
|
682
|
+
function mergeMatchers(...records) {
|
|
683
|
+
const result = {};
|
|
684
|
+
for (const record of records) {
|
|
685
|
+
if (!record)
|
|
686
|
+
continue;
|
|
687
|
+
for (const [path, value] of Object.entries(record)) {
|
|
688
|
+
if (path in result)
|
|
689
|
+
throw new Error(`Duplicate filter path: ${path}`);
|
|
690
|
+
result[path] = value;
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
return result;
|
|
694
|
+
}
|
|
695
|
+
function parseTypedMatcherValue(value, label) {
|
|
696
|
+
const parsed = JSON.parse(value);
|
|
697
|
+
if (parsed === null || typeof parsed === "string" || typeof parsed === "number" || typeof parsed === "boolean" || Array.isArray(parsed) && parsed.every((item) => typeof item === "string")) {
|
|
698
|
+
return parsed;
|
|
699
|
+
}
|
|
700
|
+
throw new Error(`${label} filter JSON values must be string, string[], number, boolean, or null`);
|
|
701
|
+
}
|
|
702
|
+
|
|
552
703
|
// src/commander.ts
|
|
553
704
|
function parseJsonObject(value, fallback) {
|
|
554
705
|
if (!value)
|
|
@@ -571,18 +722,6 @@ function parseHeaders(values) {
|
|
|
571
722
|
}
|
|
572
723
|
return headers;
|
|
573
724
|
}
|
|
574
|
-
function parseFilter(options) {
|
|
575
|
-
const filter2 = {};
|
|
576
|
-
if (options.source)
|
|
577
|
-
filter2.source = options.source;
|
|
578
|
-
if (options.type)
|
|
579
|
-
filter2.type = options.type;
|
|
580
|
-
if (options.subject)
|
|
581
|
-
filter2.subject = options.subject;
|
|
582
|
-
if (options.severity)
|
|
583
|
-
filter2.severity = options.severity;
|
|
584
|
-
return Object.keys(filter2).length > 0 ? [filter2] : undefined;
|
|
585
|
-
}
|
|
586
725
|
function createClient(options) {
|
|
587
726
|
if (options.createClient)
|
|
588
727
|
return options.createClient();
|
|
@@ -602,14 +741,14 @@ function wantsJson(actionOptions, command) {
|
|
|
602
741
|
}
|
|
603
742
|
function registerWebhookCommands(program, options) {
|
|
604
743
|
const webhooks = program.command(options.webhooksCommandName ?? "webhooks").description("Manage Hasna event webhook subscriptions");
|
|
605
|
-
webhooks.command("add").description("Add or replace a webhook or command subscription").argument("<target>", "Webhook URL or command binary").requiredOption("--id <id>", "Subscription/channel identifier").option("--transport <kind>", "Transport kind: webhook or command", "webhook").option("--name <name>", "Display name").option("--type <pattern>", "Event type filter, e.g. todos.task.*").option("--source <pattern>", "Event source filter").option("--subject <pattern>", "Event subject filter").option("--severity <pattern>", "Event severity filter").option("--secret <secret>", "Webhook HMAC secret").option("--header <name=value...>", "Webhook header", collectValues, []).option("--arg <arg...>", "Command argument", collectValues, []).option("--timeout-ms <ms>", "Transport timeout in milliseconds", parseNumber).option("--retry-attempts <n>", "Maximum delivery attempts", parseNumber).option("--retry-backoff-ms <ms>", "Initial retry backoff in milliseconds", parseNumber).option("--redact <path...>", "Event field path to redact before delivery", collectValues, []).option("--disabled", "Create channel disabled", false).option("-j, --json", "Print JSON output", false).action(async (target, actionOptions, command) => {
|
|
744
|
+
webhooks.command("add").description("Add or replace a webhook or command subscription").argument("<target>", "Webhook URL or command binary").requiredOption("--id <id>", "Subscription/channel identifier").option("--transport <kind>", "Transport kind: webhook or command", "webhook").option("--name <name>", "Display name").option("--type <pattern>", "Event type filter, e.g. todos.task.*").option("--source <pattern>", "Event source filter").option("--subject <pattern>", "Event subject filter").option("--severity <pattern>", "Event severity filter").option("--data <path=value...>", "Event data field filter; string values, dot paths, * segment wildcard, ** recursive wildcard", collectValues, []).option("--metadata <path=value...>", "Event metadata field filter; string values, dot paths, * segment wildcard, ** recursive wildcard", collectValues, []).option("--data-json <path=json...>", "Event data field filter with typed JSON value", collectValues, []).option("--metadata-json <path=json...>", "Event metadata field filter with typed JSON value", collectValues, []).option("--secret <secret>", "Webhook HMAC secret").option("--header <name=value...>", "Webhook header", collectValues, []).option("--arg <arg...>", "Command argument", collectValues, []).option("--timeout-ms <ms>", "Transport timeout in milliseconds", parseNumber).option("--retry-attempts <n>", "Maximum delivery attempts", parseNumber).option("--retry-backoff-ms <ms>", "Initial retry backoff in milliseconds", parseNumber).option("--redact <path...>", "Event field path to redact before delivery", collectValues, []).option("--disabled", "Create channel disabled", false).option("-j, --json", "Print JSON output", false).action(async (target, actionOptions, command) => {
|
|
606
745
|
const timestamp = new Date().toISOString();
|
|
607
746
|
const channel = {
|
|
608
747
|
id: actionOptions.id,
|
|
609
748
|
name: actionOptions.name,
|
|
610
749
|
enabled: !actionOptions.disabled,
|
|
611
750
|
transport: actionOptions.transport,
|
|
612
|
-
filters:
|
|
751
|
+
filters: parseFilterOptions(actionOptions),
|
|
613
752
|
retry: actionOptions.retryAttempts || actionOptions.retryBackoffMs ? { maxAttempts: actionOptions.retryAttempts, backoffMs: actionOptions.retryBackoffMs } : undefined,
|
|
614
753
|
redact: actionOptions.redact?.length ? { paths: actionOptions.redact } : undefined,
|
|
615
754
|
createdAt: timestamp,
|
|
@@ -639,20 +778,36 @@ function registerWebhookCommands(program, options) {
|
|
|
639
778
|
console.log(`${channel.id} ${channel.enabled ? "enabled" : "disabled"} ${channel.transport} ${channel.webhook?.url ?? channel.command?.command ?? channel.transport}`);
|
|
640
779
|
}
|
|
641
780
|
});
|
|
781
|
+
webhooks.command("status").description("Show events webhook storage status").option("-j, --json", "Print JSON output", false).action(async (actionOptions, command) => {
|
|
782
|
+
const status = await getEventsStatus(options.dataDir);
|
|
783
|
+
print(status, wantsJson(actionOptions, command), `events dataDir: ${status.dataDir}`);
|
|
784
|
+
});
|
|
642
785
|
webhooks.command("remove").description("Remove a subscription").argument("<id>", "Subscription/channel identifier").option("-j, --json", "Print JSON output", false).action(async (id, actionOptions, command) => {
|
|
643
786
|
const removed = await createClient(options).removeChannel(id);
|
|
644
787
|
print({ removed }, wantsJson(actionOptions, command), removed ? `Removed ${id}` : `Channel not found: ${id}`);
|
|
645
788
|
});
|
|
646
|
-
webhooks.command("test").description("Send a test event to one subscription").argument("<id>", "Subscription/channel identifier").option("--type <type>", "Event type", "events.test").option("--subject <subject>", "Event subject").option("--message <message>", "Event message", "Hasna events test delivery").option("--data <json>", "Event data JSON object").option("-j, --json", "Print JSON output", false).action(async (id, actionOptions, command) => {
|
|
789
|
+
webhooks.command("test").description("Send a test event to one subscription").argument("<id>", "Subscription/channel identifier").option("--source <source>", "Event source override").option("--type <type>", "Event type", "events.test").option("--subject <subject>", "Event subject").option("--message <message>", "Event message", "Hasna events test delivery").option("--data <json>", "Event data JSON object").option("--metadata <json>", "Event metadata JSON object").option("--honor-filters", "Skip delivery when the sample event does not match channel filters", false).option("-j, --json", "Print JSON output", false).action(async (id, actionOptions, command) => {
|
|
647
790
|
const result = await createClient(options).testChannel(id, {
|
|
648
|
-
source: options.source,
|
|
791
|
+
source: actionOptions.source ?? options.source,
|
|
649
792
|
type: actionOptions.type,
|
|
650
793
|
subject: actionOptions.subject ?? id,
|
|
651
794
|
message: actionOptions.message,
|
|
652
|
-
data: parseJsonObject(actionOptions.data, { test: true })
|
|
653
|
-
|
|
795
|
+
data: parseJsonObject(actionOptions.data, { test: true }),
|
|
796
|
+
metadata: parseJsonObject(actionOptions.metadata, {})
|
|
797
|
+
}, { honorFilters: actionOptions.honorFilters });
|
|
654
798
|
print(result, wantsJson(actionOptions, command), `${result.status}: ${result.channelId}`);
|
|
655
799
|
});
|
|
800
|
+
webhooks.command("match").description("Check whether a sample event matches one subscription without delivering").argument("<id>", "Subscription/channel identifier").option("--source <source>", "Event source override").option("--type <type>", "Event type", "events.test").option("--subject <subject>", "Event subject").option("--message <message>", "Event message", "Hasna events match preview").option("--data <json>", "Event data JSON object").option("--metadata <json>", "Event metadata JSON object").option("-j, --json", "Print JSON output", false).action(async (id, actionOptions, command) => {
|
|
801
|
+
const result = await createClient(options).matchChannel(id, {
|
|
802
|
+
source: actionOptions.source ?? options.source,
|
|
803
|
+
type: actionOptions.type,
|
|
804
|
+
subject: actionOptions.subject ?? id,
|
|
805
|
+
message: actionOptions.message,
|
|
806
|
+
data: parseJsonObject(actionOptions.data, { test: true }),
|
|
807
|
+
metadata: parseJsonObject(actionOptions.metadata, {})
|
|
808
|
+
});
|
|
809
|
+
print(result, wantsJson(actionOptions, command), `${result.matched ? "matched" : "skipped"}: ${result.channelId}`);
|
|
810
|
+
});
|
|
656
811
|
return webhooks;
|
|
657
812
|
}
|
|
658
813
|
function registerEventCommands(program, options) {
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { EventFilter, StringMatcher } from "./types.js";
|
|
2
|
+
type MatcherValue = StringMatcher | number | boolean | null;
|
|
3
|
+
export interface FilterOptionInput {
|
|
4
|
+
source?: string;
|
|
5
|
+
type?: string;
|
|
6
|
+
subject?: string;
|
|
7
|
+
severity?: string;
|
|
8
|
+
data?: string[];
|
|
9
|
+
metadata?: string[];
|
|
10
|
+
dataJson?: string[];
|
|
11
|
+
metadataJson?: string[];
|
|
12
|
+
}
|
|
13
|
+
export declare function parseFieldMatchers(values: string[] | undefined, label: string, typed?: boolean): Record<string, MatcherValue> | undefined;
|
|
14
|
+
export declare function parseFilterOptions(options: FilterOptionInput): EventFilter[] | undefined;
|
|
15
|
+
export {};
|
package/dist/filter.d.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import type { ChannelConfig, EventEnvelope, EventFilter, StringMatcher } from "./types.js";
|
|
2
|
-
export declare function matchString(value: string | undefined, matcher: StringMatcher | undefined
|
|
2
|
+
export declare function matchString(value: string | undefined, matcher: StringMatcher | undefined, options?: {
|
|
3
|
+
segmentSafe?: boolean;
|
|
4
|
+
}): boolean;
|
|
3
5
|
export declare function eventMatchesFilter(event: EventEnvelope, filter: EventFilter): boolean;
|
|
4
6
|
export declare function channelMatchesEvent(channel: ChannelConfig, event: EventEnvelope): boolean;
|
package/dist/filter.js
CHANGED
|
@@ -8,17 +8,30 @@ function getPathValue(input, path) {
|
|
|
8
8
|
return;
|
|
9
9
|
}, input);
|
|
10
10
|
}
|
|
11
|
-
function wildcardToRegExp(pattern) {
|
|
12
|
-
|
|
13
|
-
|
|
11
|
+
function wildcardToRegExp(pattern, options = {}) {
|
|
12
|
+
let body = "";
|
|
13
|
+
for (let index = 0;index < pattern.length; index += 1) {
|
|
14
|
+
const char = pattern[index];
|
|
15
|
+
if (char === "*") {
|
|
16
|
+
if (pattern[index + 1] === "*") {
|
|
17
|
+
body += ".*";
|
|
18
|
+
index += 1;
|
|
19
|
+
} else {
|
|
20
|
+
body += options.segmentSafe ? "[^/]*" : ".*";
|
|
21
|
+
}
|
|
22
|
+
} else {
|
|
23
|
+
body += char.replace(/[|\\{}()[\]^$+?.]/g, "\\$&");
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return new RegExp(`^${body}$`);
|
|
14
27
|
}
|
|
15
|
-
function matchString(value, matcher) {
|
|
28
|
+
function matchString(value, matcher, options = {}) {
|
|
16
29
|
if (matcher === undefined)
|
|
17
30
|
return true;
|
|
18
31
|
if (value === undefined)
|
|
19
32
|
return false;
|
|
20
33
|
const matchers = Array.isArray(matcher) ? matcher : [matcher];
|
|
21
|
-
return matchers.some((item) => wildcardToRegExp(item).test(value));
|
|
34
|
+
return matchers.some((item) => wildcardToRegExp(item, options).test(value));
|
|
22
35
|
}
|
|
23
36
|
function matchRecord(input, matcher) {
|
|
24
37
|
if (!matcher)
|
|
@@ -26,7 +39,9 @@ function matchRecord(input, matcher) {
|
|
|
26
39
|
return Object.entries(matcher).every(([path, expected]) => {
|
|
27
40
|
const actual = getPathValue(input, path);
|
|
28
41
|
if (typeof expected === "string" || Array.isArray(expected)) {
|
|
29
|
-
return matchString(actual === undefined ? undefined : String(actual), expected
|
|
42
|
+
return matchString(actual === undefined ? undefined : String(actual), expected, {
|
|
43
|
+
segmentSafe: path.endsWith("_path") || path.endsWith(".path")
|
|
44
|
+
});
|
|
30
45
|
}
|
|
31
46
|
return actual === expected;
|
|
32
47
|
});
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ChannelConfig, DeliveryResult, EmitOptions, EmitResult, EventEnvelope, EventInput, EventRedactor, ReplayOptions } from "./types.js";
|
|
1
|
+
import type { ChannelConfig, DeliveryResult, EmitOptions, EmitResult, EventEnvelope, EventFilter, EventInput, EventRedactor, ReplayOptions } from "./types.js";
|
|
2
2
|
import { type EventsStore } from "./storage.js";
|
|
3
3
|
import { type TransportDispatchOptions } from "./transports.js";
|
|
4
4
|
export * from "./types.js";
|
|
@@ -11,6 +11,16 @@ export interface EventsClientOptions extends TransportDispatchOptions {
|
|
|
11
11
|
dataDir?: string;
|
|
12
12
|
redactors?: EventRedactor[];
|
|
13
13
|
}
|
|
14
|
+
export interface ChannelMatchResult {
|
|
15
|
+
channelId: string;
|
|
16
|
+
matched: boolean;
|
|
17
|
+
event: EventEnvelope;
|
|
18
|
+
filters?: EventFilter[];
|
|
19
|
+
reason?: string;
|
|
20
|
+
}
|
|
21
|
+
export interface TestChannelOptions {
|
|
22
|
+
honorFilters?: boolean;
|
|
23
|
+
}
|
|
14
24
|
export declare function createEvent<TData extends Record<string, unknown>>(input: EventInput<TData>): EventEnvelope<TData>;
|
|
15
25
|
export declare class EventsClient {
|
|
16
26
|
private store;
|
|
@@ -24,7 +34,8 @@ export declare class EventsClient {
|
|
|
24
34
|
listEvents(): Promise<EventEnvelope[]>;
|
|
25
35
|
listDeliveries(): Promise<DeliveryResult[]>;
|
|
26
36
|
deliver(event: EventEnvelope): Promise<DeliveryResult[]>;
|
|
27
|
-
|
|
37
|
+
matchChannel(id: string, input?: Partial<EventInput>): Promise<ChannelMatchResult>;
|
|
38
|
+
testChannel(id: string, input?: Partial<EventInput>, options?: TestChannelOptions): Promise<DeliveryResult>;
|
|
28
39
|
replay(options?: ReplayOptions): Promise<{
|
|
29
40
|
events: EventEnvelope[];
|
|
30
41
|
deliveries: DeliveryResult[];
|
package/dist/index.js
CHANGED
|
@@ -8,17 +8,30 @@ function getPathValue(input, path) {
|
|
|
8
8
|
return;
|
|
9
9
|
}, input);
|
|
10
10
|
}
|
|
11
|
-
function wildcardToRegExp(pattern) {
|
|
12
|
-
|
|
13
|
-
|
|
11
|
+
function wildcardToRegExp(pattern, options = {}) {
|
|
12
|
+
let body = "";
|
|
13
|
+
for (let index = 0;index < pattern.length; index += 1) {
|
|
14
|
+
const char = pattern[index];
|
|
15
|
+
if (char === "*") {
|
|
16
|
+
if (pattern[index + 1] === "*") {
|
|
17
|
+
body += ".*";
|
|
18
|
+
index += 1;
|
|
19
|
+
} else {
|
|
20
|
+
body += options.segmentSafe ? "[^/]*" : ".*";
|
|
21
|
+
}
|
|
22
|
+
} else {
|
|
23
|
+
body += char.replace(/[|\\{}()[\]^$+?.]/g, "\\$&");
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return new RegExp(`^${body}$`);
|
|
14
27
|
}
|
|
15
|
-
function matchString(value, matcher) {
|
|
28
|
+
function matchString(value, matcher, options = {}) {
|
|
16
29
|
if (matcher === undefined)
|
|
17
30
|
return true;
|
|
18
31
|
if (value === undefined)
|
|
19
32
|
return false;
|
|
20
33
|
const matchers = Array.isArray(matcher) ? matcher : [matcher];
|
|
21
|
-
return matchers.some((item) => wildcardToRegExp(item).test(value));
|
|
34
|
+
return matchers.some((item) => wildcardToRegExp(item, options).test(value));
|
|
22
35
|
}
|
|
23
36
|
function matchRecord(input, matcher) {
|
|
24
37
|
if (!matcher)
|
|
@@ -26,7 +39,9 @@ function matchRecord(input, matcher) {
|
|
|
26
39
|
return Object.entries(matcher).every(([path, expected]) => {
|
|
27
40
|
const actual = getPathValue(input, path);
|
|
28
41
|
if (typeof expected === "string" || Array.isArray(expected)) {
|
|
29
|
-
return matchString(actual === undefined ? undefined : String(actual), expected
|
|
42
|
+
return matchString(actual === undefined ? undefined : String(actual), expected, {
|
|
43
|
+
segmentSafe: path.endsWith("_path") || path.endsWith(".path")
|
|
44
|
+
});
|
|
30
45
|
}
|
|
31
46
|
return actual === expected;
|
|
32
47
|
});
|
|
@@ -52,6 +67,13 @@ var HASNA_EVENTS_HOME_ENV = "HASNA_EVENTS_HOME";
|
|
|
52
67
|
function getEventsDataDir(override) {
|
|
53
68
|
return override || process.env[HASNA_EVENTS_DIR_ENV] || process.env[HASNA_EVENTS_HOME_ENV] || join(homedir(), ".hasna", "events");
|
|
54
69
|
}
|
|
70
|
+
function getActiveEventsDirEnv() {
|
|
71
|
+
if (process.env[HASNA_EVENTS_DIR_ENV])
|
|
72
|
+
return HASNA_EVENTS_DIR_ENV;
|
|
73
|
+
if (process.env[HASNA_EVENTS_HOME_ENV])
|
|
74
|
+
return HASNA_EVENTS_HOME_ENV;
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
55
77
|
|
|
56
78
|
class JsonEventsStore {
|
|
57
79
|
dataDir;
|
|
@@ -164,6 +186,52 @@ class JsonEventsStore {
|
|
|
164
186
|
});
|
|
165
187
|
}
|
|
166
188
|
}
|
|
189
|
+
async function getEventsStatus(dataDir) {
|
|
190
|
+
const store = new JsonEventsStore(dataDir);
|
|
191
|
+
await store.init();
|
|
192
|
+
const [channels, events, deliveries] = await Promise.all([
|
|
193
|
+
store.listChannels(),
|
|
194
|
+
store.listEvents(),
|
|
195
|
+
store.listDeliveries()
|
|
196
|
+
]);
|
|
197
|
+
const transports = channels.reduce((counts, channel) => {
|
|
198
|
+
counts[channel.transport] = (counts[channel.transport] ?? 0) + 1;
|
|
199
|
+
return counts;
|
|
200
|
+
}, {});
|
|
201
|
+
return {
|
|
202
|
+
service: "events",
|
|
203
|
+
schemaVersion: "1.0",
|
|
204
|
+
dataDir: store.dataDir,
|
|
205
|
+
env: {
|
|
206
|
+
primary: HASNA_EVENTS_DIR_ENV,
|
|
207
|
+
fallback: HASNA_EVENTS_HOME_ENV,
|
|
208
|
+
active: getActiveEventsDirEnv()
|
|
209
|
+
},
|
|
210
|
+
files: {
|
|
211
|
+
channels: statusFile(store.dataDir, "channels.json", channels.length),
|
|
212
|
+
events: statusFile(store.dataDir, "events.json", events.length),
|
|
213
|
+
deliveries: statusFile(store.dataDir, "deliveries.json", deliveries.length)
|
|
214
|
+
},
|
|
215
|
+
counts: {
|
|
216
|
+
channels: channels.length,
|
|
217
|
+
enabledChannels: channels.filter((channel) => channel.enabled).length,
|
|
218
|
+
disabledChannels: channels.filter((channel) => !channel.enabled).length,
|
|
219
|
+
events: events.length,
|
|
220
|
+
deliveries: deliveries.length
|
|
221
|
+
},
|
|
222
|
+
transports,
|
|
223
|
+
safety: {
|
|
224
|
+
includesEventPayloads: false,
|
|
225
|
+
includesWebhookSecrets: false,
|
|
226
|
+
listOutputsRedactSecrets: true,
|
|
227
|
+
statusOutputIsMetadataOnly: true
|
|
228
|
+
}
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
function statusFile(dataDir, fileName, records) {
|
|
232
|
+
const path = join(dataDir, fileName);
|
|
233
|
+
return { path, exists: existsSync(path), records };
|
|
234
|
+
}
|
|
167
235
|
|
|
168
236
|
// src/signing.ts
|
|
169
237
|
import { createHmac, timingSafeEqual } from "crypto";
|
|
@@ -421,7 +489,7 @@ class EventsClient {
|
|
|
421
489
|
}
|
|
422
490
|
return deliveries;
|
|
423
491
|
}
|
|
424
|
-
async
|
|
492
|
+
async matchChannel(id, input = {}) {
|
|
425
493
|
const channel = await this.store.getChannel(id);
|
|
426
494
|
if (!channel)
|
|
427
495
|
throw new Error(`Channel not found: ${id}`);
|
|
@@ -438,6 +506,34 @@ class EventsClient {
|
|
|
438
506
|
time: input.time,
|
|
439
507
|
id: input.id
|
|
440
508
|
});
|
|
509
|
+
const matched = channelMatchesEvent(channel, event);
|
|
510
|
+
return {
|
|
511
|
+
channelId: channel.id,
|
|
512
|
+
matched,
|
|
513
|
+
event,
|
|
514
|
+
filters: channel.filters,
|
|
515
|
+
reason: matched ? undefined : channel.enabled ? "event did not match channel filters" : "channel is disabled"
|
|
516
|
+
};
|
|
517
|
+
}
|
|
518
|
+
async testChannel(id, input = {}, options = {}) {
|
|
519
|
+
const channel = await this.store.getChannel(id);
|
|
520
|
+
if (!channel)
|
|
521
|
+
throw new Error(`Channel not found: ${id}`);
|
|
522
|
+
const match = await this.matchChannel(id, input);
|
|
523
|
+
const event = match.event;
|
|
524
|
+
if (options.honorFilters && !match.matched) {
|
|
525
|
+
const timestamp = new Date().toISOString();
|
|
526
|
+
const result2 = createDeliveryResult(event, channel, [{
|
|
527
|
+
attempt: 1,
|
|
528
|
+
status: "skipped",
|
|
529
|
+
startedAt: timestamp,
|
|
530
|
+
completedAt: timestamp,
|
|
531
|
+
error: match.reason
|
|
532
|
+
}]);
|
|
533
|
+
result2.metadata = { reason: "filter_mismatch" };
|
|
534
|
+
await this.store.appendDelivery(result2);
|
|
535
|
+
return result2;
|
|
536
|
+
}
|
|
441
537
|
const eventForChannel = await this.applyRedaction(event, channel);
|
|
442
538
|
const result = await this.deliverWithRetry(eventForChannel, channel);
|
|
443
539
|
await this.store.appendDelivery(result);
|
|
@@ -558,7 +654,9 @@ export {
|
|
|
558
654
|
redactPaths,
|
|
559
655
|
matchString,
|
|
560
656
|
isTimestampWithinTolerance,
|
|
657
|
+
getEventsStatus,
|
|
561
658
|
getEventsDataDir,
|
|
659
|
+
getActiveEventsDirEnv,
|
|
562
660
|
eventMatchesFilter,
|
|
563
661
|
dispatchWebhook,
|
|
564
662
|
dispatchCommand,
|
package/dist/storage.d.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import type { ChannelConfig, DeliveryResult, EventEnvelope, StoredEventsData } from "./types.js";
|
|
1
|
+
import type { ChannelConfig, DeliveryResult, EventEnvelope, EventsStatus, StoredEventsData } from "./types.js";
|
|
2
2
|
export declare const HASNA_EVENTS_DIR_ENV = "HASNA_EVENTS_DIR";
|
|
3
3
|
export declare const HASNA_EVENTS_HOME_ENV = "HASNA_EVENTS_HOME";
|
|
4
4
|
export declare function getEventsDataDir(override?: string): string;
|
|
5
|
+
export declare function getActiveEventsDirEnv(): EventsStatus["env"]["active"];
|
|
5
6
|
export interface EventsStore {
|
|
6
7
|
dataDir: string;
|
|
7
8
|
init(): Promise<void>;
|
|
@@ -42,3 +43,4 @@ export declare class JsonEventsStore implements EventsStore {
|
|
|
42
43
|
private readJson;
|
|
43
44
|
private writeJson;
|
|
44
45
|
}
|
|
46
|
+
export declare function getEventsStatus(dataDir?: string): Promise<EventsStatus>;
|
package/dist/storage.js
CHANGED
|
@@ -9,6 +9,13 @@ var HASNA_EVENTS_HOME_ENV = "HASNA_EVENTS_HOME";
|
|
|
9
9
|
function getEventsDataDir(override) {
|
|
10
10
|
return override || process.env[HASNA_EVENTS_DIR_ENV] || process.env[HASNA_EVENTS_HOME_ENV] || join(homedir(), ".hasna", "events");
|
|
11
11
|
}
|
|
12
|
+
function getActiveEventsDirEnv() {
|
|
13
|
+
if (process.env[HASNA_EVENTS_DIR_ENV])
|
|
14
|
+
return HASNA_EVENTS_DIR_ENV;
|
|
15
|
+
if (process.env[HASNA_EVENTS_HOME_ENV])
|
|
16
|
+
return HASNA_EVENTS_HOME_ENV;
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
12
19
|
|
|
13
20
|
class JsonEventsStore {
|
|
14
21
|
dataDir;
|
|
@@ -121,8 +128,56 @@ class JsonEventsStore {
|
|
|
121
128
|
});
|
|
122
129
|
}
|
|
123
130
|
}
|
|
131
|
+
async function getEventsStatus(dataDir) {
|
|
132
|
+
const store = new JsonEventsStore(dataDir);
|
|
133
|
+
await store.init();
|
|
134
|
+
const [channels, events, deliveries] = await Promise.all([
|
|
135
|
+
store.listChannels(),
|
|
136
|
+
store.listEvents(),
|
|
137
|
+
store.listDeliveries()
|
|
138
|
+
]);
|
|
139
|
+
const transports = channels.reduce((counts, channel) => {
|
|
140
|
+
counts[channel.transport] = (counts[channel.transport] ?? 0) + 1;
|
|
141
|
+
return counts;
|
|
142
|
+
}, {});
|
|
143
|
+
return {
|
|
144
|
+
service: "events",
|
|
145
|
+
schemaVersion: "1.0",
|
|
146
|
+
dataDir: store.dataDir,
|
|
147
|
+
env: {
|
|
148
|
+
primary: HASNA_EVENTS_DIR_ENV,
|
|
149
|
+
fallback: HASNA_EVENTS_HOME_ENV,
|
|
150
|
+
active: getActiveEventsDirEnv()
|
|
151
|
+
},
|
|
152
|
+
files: {
|
|
153
|
+
channels: statusFile(store.dataDir, "channels.json", channels.length),
|
|
154
|
+
events: statusFile(store.dataDir, "events.json", events.length),
|
|
155
|
+
deliveries: statusFile(store.dataDir, "deliveries.json", deliveries.length)
|
|
156
|
+
},
|
|
157
|
+
counts: {
|
|
158
|
+
channels: channels.length,
|
|
159
|
+
enabledChannels: channels.filter((channel) => channel.enabled).length,
|
|
160
|
+
disabledChannels: channels.filter((channel) => !channel.enabled).length,
|
|
161
|
+
events: events.length,
|
|
162
|
+
deliveries: deliveries.length
|
|
163
|
+
},
|
|
164
|
+
transports,
|
|
165
|
+
safety: {
|
|
166
|
+
includesEventPayloads: false,
|
|
167
|
+
includesWebhookSecrets: false,
|
|
168
|
+
listOutputsRedactSecrets: true,
|
|
169
|
+
statusOutputIsMetadataOnly: true
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
function statusFile(dataDir, fileName, records) {
|
|
174
|
+
const path = join(dataDir, fileName);
|
|
175
|
+
return { path, exists: existsSync(path), records };
|
|
176
|
+
}
|
|
124
177
|
export {
|
|
178
|
+
getEventsStatus,
|
|
125
179
|
getEventsDataDir,
|
|
180
|
+
getActiveEventsDirEnv,
|
|
126
181
|
JsonEventsStore,
|
|
127
182
|
HASNA_EVENTS_HOME_ENV,
|
|
128
183
|
HASNA_EVENTS_DIR_ENV
|
package/dist/types.d.ts
CHANGED
|
@@ -117,3 +117,44 @@ export interface EmitResult<TData extends EventData = EventData> {
|
|
|
117
117
|
deliveries: DeliveryResult[];
|
|
118
118
|
deduped: boolean;
|
|
119
119
|
}
|
|
120
|
+
export interface EventsStatus {
|
|
121
|
+
service: "events";
|
|
122
|
+
schemaVersion: "1.0";
|
|
123
|
+
dataDir: string;
|
|
124
|
+
env: {
|
|
125
|
+
primary: "HASNA_EVENTS_DIR";
|
|
126
|
+
fallback: "HASNA_EVENTS_HOME";
|
|
127
|
+
active: "HASNA_EVENTS_DIR" | "HASNA_EVENTS_HOME" | null;
|
|
128
|
+
};
|
|
129
|
+
files: {
|
|
130
|
+
channels: {
|
|
131
|
+
path: string;
|
|
132
|
+
exists: boolean;
|
|
133
|
+
records: number;
|
|
134
|
+
};
|
|
135
|
+
events: {
|
|
136
|
+
path: string;
|
|
137
|
+
exists: boolean;
|
|
138
|
+
records: number;
|
|
139
|
+
};
|
|
140
|
+
deliveries: {
|
|
141
|
+
path: string;
|
|
142
|
+
exists: boolean;
|
|
143
|
+
records: number;
|
|
144
|
+
};
|
|
145
|
+
};
|
|
146
|
+
counts: {
|
|
147
|
+
channels: number;
|
|
148
|
+
enabledChannels: number;
|
|
149
|
+
disabledChannels: number;
|
|
150
|
+
events: number;
|
|
151
|
+
deliveries: number;
|
|
152
|
+
};
|
|
153
|
+
transports: Record<string, number>;
|
|
154
|
+
safety: {
|
|
155
|
+
includesEventPayloads: false;
|
|
156
|
+
includesWebhookSecrets: false;
|
|
157
|
+
listOutputsRedactSecrets: true;
|
|
158
|
+
statusOutputIsMetadataOnly: true;
|
|
159
|
+
};
|
|
160
|
+
}
|