@hasna/events 0.1.8 → 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 +31 -0
- package/dist/cli/index.js +153 -23
- package/dist/commander.js +127 -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 +50 -7
- 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
|
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
|
});
|
|
@@ -463,7 +478,7 @@ class EventsClient {
|
|
|
463
478
|
}
|
|
464
479
|
return deliveries;
|
|
465
480
|
}
|
|
466
|
-
async
|
|
481
|
+
async matchChannel(id, input = {}) {
|
|
467
482
|
const channel = await this.store.getChannel(id);
|
|
468
483
|
if (!channel)
|
|
469
484
|
throw new Error(`Channel not found: ${id}`);
|
|
@@ -480,6 +495,34 @@ class EventsClient {
|
|
|
480
495
|
time: input.time,
|
|
481
496
|
id: input.id
|
|
482
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
|
+
}
|
|
483
526
|
const eventForChannel = await this.applyRedaction(event, channel);
|
|
484
527
|
const result = await this.deliverWithRetry(eventForChannel, channel);
|
|
485
528
|
await this.store.appendDelivery(result);
|
|
@@ -591,6 +634,61 @@ function normalizeRetryPolicy(policy) {
|
|
|
591
634
|
};
|
|
592
635
|
}
|
|
593
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
|
+
|
|
594
692
|
// src/cli/index.ts
|
|
595
693
|
function version() {
|
|
596
694
|
try {
|
|
@@ -659,20 +757,16 @@ function parseJsonOption(value, fallback) {
|
|
|
659
757
|
return parsed;
|
|
660
758
|
}
|
|
661
759
|
function parseFilter(args) {
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
filter2.subject = subject;
|
|
673
|
-
if (severity)
|
|
674
|
-
filter2.severity = severity;
|
|
675
|
-
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
|
+
});
|
|
676
770
|
}
|
|
677
771
|
function parseHeaders(values) {
|
|
678
772
|
if (values.length === 0)
|
|
@@ -705,6 +799,8 @@ Usage:
|
|
|
705
799
|
${name} [--dir <path>] [--json] webhooks list
|
|
706
800
|
${name} [--dir <path>] [--json] webhooks remove <id>
|
|
707
801
|
${name} [--dir <path>] [--json] webhooks test <id>
|
|
802
|
+
${name} [--dir <path>] [--json] webhooks match <id>
|
|
803
|
+
${name} [--dir <path>] [--json] webhooks status
|
|
708
804
|
${name} [--dir <path>] [--json] status
|
|
709
805
|
${name} [--dir <path>] [--json] events emit <type>${options.source ? "" : " --source <source>"} [options]
|
|
710
806
|
${name} [--dir <path>] [--json] events list [--limit <n>]
|
|
@@ -722,6 +818,8 @@ Usage:
|
|
|
722
818
|
${name} [--dir <path>] [--json] webhooks list
|
|
723
819
|
${name} [--dir <path>] [--json] webhooks remove <id>
|
|
724
820
|
${name} [--dir <path>] [--json] webhooks test <id>
|
|
821
|
+
${name} [--dir <path>] [--json] webhooks match <id>
|
|
822
|
+
${name} [--dir <path>] [--json] webhooks status
|
|
725
823
|
|
|
726
824
|
Options:
|
|
727
825
|
--id <id> Channel id for add
|
|
@@ -729,6 +827,11 @@ Options:
|
|
|
729
827
|
--source <source> Event source filter
|
|
730
828
|
--subject <subject> Event subject filter
|
|
731
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
|
|
732
835
|
--transport <kind> webhook or command
|
|
733
836
|
--secret <secret> Webhook signing secret
|
|
734
837
|
--header <name=value> Webhook header, repeatable
|
|
@@ -857,6 +960,14 @@ async function handleWebhooks(client, command, tail, parsed, options) {
|
|
|
857
960
|
});
|
|
858
961
|
return;
|
|
859
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
|
+
}
|
|
860
971
|
if (command === "remove") {
|
|
861
972
|
const id = tail[0];
|
|
862
973
|
if (!id)
|
|
@@ -870,15 +981,34 @@ async function handleWebhooks(client, command, tail, parsed, options) {
|
|
|
870
981
|
const id = args.shift();
|
|
871
982
|
if (!id)
|
|
872
983
|
throw new Error("webhooks test requires a channel id");
|
|
984
|
+
const honorFilters = takeFlag(args, "--honor-filters");
|
|
873
985
|
const result = await client.testChannel(id, {
|
|
874
986
|
source: takeOption(args, "--source") ?? options.source ?? "hasna.events",
|
|
875
987
|
type: takeOption(args, "--type") ?? "events.test",
|
|
876
988
|
subject: takeOption(args, "--subject") ?? id,
|
|
877
|
-
|
|
878
|
-
|
|
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 });
|
|
879
993
|
output(parsed, result, () => console.log(`${result.status}: ${result.channelId}`));
|
|
880
994
|
return;
|
|
881
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
|
+
}
|
|
882
1012
|
throw new Error(`Unknown webhooks command: ${command ?? ""}`);
|
|
883
1013
|
}
|
|
884
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
|
});
|
|
@@ -474,7 +489,7 @@ class EventsClient {
|
|
|
474
489
|
}
|
|
475
490
|
return deliveries;
|
|
476
491
|
}
|
|
477
|
-
async
|
|
492
|
+
async matchChannel(id, input = {}) {
|
|
478
493
|
const channel = await this.store.getChannel(id);
|
|
479
494
|
if (!channel)
|
|
480
495
|
throw new Error(`Channel not found: ${id}`);
|
|
@@ -491,6 +506,34 @@ class EventsClient {
|
|
|
491
506
|
time: input.time,
|
|
492
507
|
id: input.id
|
|
493
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
|
+
}
|
|
494
537
|
const eventForChannel = await this.applyRedaction(event, channel);
|
|
495
538
|
const result = await this.deliverWithRetry(eventForChannel, channel);
|
|
496
539
|
await this.store.appendDelivery(result);
|
|
@@ -602,6 +645,61 @@ function normalizeRetryPolicy(policy) {
|
|
|
602
645
|
};
|
|
603
646
|
}
|
|
604
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
|
+
|
|
605
703
|
// src/commander.ts
|
|
606
704
|
function parseJsonObject(value, fallback) {
|
|
607
705
|
if (!value)
|
|
@@ -624,18 +722,6 @@ function parseHeaders(values) {
|
|
|
624
722
|
}
|
|
625
723
|
return headers;
|
|
626
724
|
}
|
|
627
|
-
function parseFilter(options) {
|
|
628
|
-
const filter2 = {};
|
|
629
|
-
if (options.source)
|
|
630
|
-
filter2.source = options.source;
|
|
631
|
-
if (options.type)
|
|
632
|
-
filter2.type = options.type;
|
|
633
|
-
if (options.subject)
|
|
634
|
-
filter2.subject = options.subject;
|
|
635
|
-
if (options.severity)
|
|
636
|
-
filter2.severity = options.severity;
|
|
637
|
-
return Object.keys(filter2).length > 0 ? [filter2] : undefined;
|
|
638
|
-
}
|
|
639
725
|
function createClient(options) {
|
|
640
726
|
if (options.createClient)
|
|
641
727
|
return options.createClient();
|
|
@@ -655,14 +741,14 @@ function wantsJson(actionOptions, command) {
|
|
|
655
741
|
}
|
|
656
742
|
function registerWebhookCommands(program, options) {
|
|
657
743
|
const webhooks = program.command(options.webhooksCommandName ?? "webhooks").description("Manage Hasna event webhook subscriptions");
|
|
658
|
-
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) => {
|
|
659
745
|
const timestamp = new Date().toISOString();
|
|
660
746
|
const channel = {
|
|
661
747
|
id: actionOptions.id,
|
|
662
748
|
name: actionOptions.name,
|
|
663
749
|
enabled: !actionOptions.disabled,
|
|
664
750
|
transport: actionOptions.transport,
|
|
665
|
-
filters:
|
|
751
|
+
filters: parseFilterOptions(actionOptions),
|
|
666
752
|
retry: actionOptions.retryAttempts || actionOptions.retryBackoffMs ? { maxAttempts: actionOptions.retryAttempts, backoffMs: actionOptions.retryBackoffMs } : undefined,
|
|
667
753
|
redact: actionOptions.redact?.length ? { paths: actionOptions.redact } : undefined,
|
|
668
754
|
createdAt: timestamp,
|
|
@@ -692,20 +778,36 @@ function registerWebhookCommands(program, options) {
|
|
|
692
778
|
console.log(`${channel.id} ${channel.enabled ? "enabled" : "disabled"} ${channel.transport} ${channel.webhook?.url ?? channel.command?.command ?? channel.transport}`);
|
|
693
779
|
}
|
|
694
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
|
+
});
|
|
695
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) => {
|
|
696
786
|
const removed = await createClient(options).removeChannel(id);
|
|
697
787
|
print({ removed }, wantsJson(actionOptions, command), removed ? `Removed ${id}` : `Channel not found: ${id}`);
|
|
698
788
|
});
|
|
699
|
-
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) => {
|
|
700
790
|
const result = await createClient(options).testChannel(id, {
|
|
701
|
-
source: options.source,
|
|
791
|
+
source: actionOptions.source ?? options.source,
|
|
702
792
|
type: actionOptions.type,
|
|
703
793
|
subject: actionOptions.subject ?? id,
|
|
704
794
|
message: actionOptions.message,
|
|
705
|
-
data: parseJsonObject(actionOptions.data, { test: true })
|
|
706
|
-
|
|
795
|
+
data: parseJsonObject(actionOptions.data, { test: true }),
|
|
796
|
+
metadata: parseJsonObject(actionOptions.metadata, {})
|
|
797
|
+
}, { honorFilters: actionOptions.honorFilters });
|
|
707
798
|
print(result, wantsJson(actionOptions, command), `${result.status}: ${result.channelId}`);
|
|
708
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
|
+
});
|
|
709
811
|
return webhooks;
|
|
710
812
|
}
|
|
711
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
|
});
|
|
@@ -474,7 +489,7 @@ class EventsClient {
|
|
|
474
489
|
}
|
|
475
490
|
return deliveries;
|
|
476
491
|
}
|
|
477
|
-
async
|
|
492
|
+
async matchChannel(id, input = {}) {
|
|
478
493
|
const channel = await this.store.getChannel(id);
|
|
479
494
|
if (!channel)
|
|
480
495
|
throw new Error(`Channel not found: ${id}`);
|
|
@@ -491,6 +506,34 @@ class EventsClient {
|
|
|
491
506
|
time: input.time,
|
|
492
507
|
id: input.id
|
|
493
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
|
+
}
|
|
494
537
|
const eventForChannel = await this.applyRedaction(event, channel);
|
|
495
538
|
const result = await this.deliverWithRetry(eventForChannel, channel);
|
|
496
539
|
await this.store.appendDelivery(result);
|