@hasna/events 0.1.8 → 0.1.10
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 +46 -0
- package/dist/cli/index.js +261 -29
- package/dist/commander.js +173 -28
- package/dist/filter-options.d.ts +17 -0
- package/dist/filter.d.ts +3 -1
- package/dist/filter.js +50 -9
- package/dist/index.d.ts +13 -2
- package/dist/index.js +79 -10
- package/dist/types.d.ts +7 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -204,6 +204,52 @@ 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
|
+
When the actual event value is an array, string filters match any primitive
|
|
212
|
+
array member, which is useful for tag routing such as `data.tags=auto:route`.
|
|
213
|
+
Use `path!=value` or `path!=json` for negative predicates such as
|
|
214
|
+
`metadata-json 'automation.no_auto!=true'`. Wildcard behavior stays broad for
|
|
215
|
+
legacy source/type/subject filters. For field paths ending in `_path` or `.path`,
|
|
216
|
+
`*` matches one path segment and `**` matches recursively.
|
|
217
|
+
|
|
218
|
+
```bash
|
|
219
|
+
events webhooks add loops \
|
|
220
|
+
--id open-source-task-route \
|
|
221
|
+
--transport command \
|
|
222
|
+
--source todos \
|
|
223
|
+
--type task.created \
|
|
224
|
+
--timeout-ms 15000 \
|
|
225
|
+
--retry-attempts 3 \
|
|
226
|
+
--retry-backoff-ms 500 \
|
|
227
|
+
--metadata 'project_path=/home/hasna/workspace/hasna/opensource/*' \
|
|
228
|
+
--metadata-json 'route_enabled=true' \
|
|
229
|
+
--metadata-json 'automation.no_auto!=true' \
|
|
230
|
+
--data 'tags=auto:route' \
|
|
231
|
+
--arg events \
|
|
232
|
+
--arg handle \
|
|
233
|
+
--arg todos-task
|
|
234
|
+
|
|
235
|
+
# Command args that begin with dashes can be passed either form:
|
|
236
|
+
events webhooks add events --id json-route --transport command --arg --json
|
|
237
|
+
events webhooks add events --id json-route --transport command --arg=--json
|
|
238
|
+
|
|
239
|
+
# For nested CLIs, put child positional args and flags after an explicit delimiter.
|
|
240
|
+
events webhooks add events --id nested-route --transport command -- handle todos-task --json
|
|
241
|
+
|
|
242
|
+
events webhooks match open-source-task-route \
|
|
243
|
+
--source todos \
|
|
244
|
+
--type task.created \
|
|
245
|
+
--metadata '{"project_path":"/home/hasna/workspace/hasna/opensource/open-events","route_enabled":true}'
|
|
246
|
+
|
|
247
|
+
events webhooks test open-source-task-route --honor-filters \
|
|
248
|
+
--source todos \
|
|
249
|
+
--type task.created \
|
|
250
|
+
--metadata '{"project_path":"/tmp/outside","route_enabled":true}'
|
|
251
|
+
```
|
|
252
|
+
|
|
207
253
|
Emit, list, and replay:
|
|
208
254
|
|
|
209
255
|
```bash
|
package/dist/cli/index.js
CHANGED
|
@@ -18,29 +18,70 @@ 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)
|
|
35
48
|
return true;
|
|
36
49
|
return Object.entries(matcher).every(([path, expected]) => {
|
|
37
50
|
const actual = getPathValue(input, path);
|
|
38
|
-
|
|
39
|
-
return matchString(actual === undefined ? undefined : String(actual), expected);
|
|
40
|
-
}
|
|
41
|
-
return actual === expected;
|
|
51
|
+
return matchField(actual, expected, path);
|
|
42
52
|
});
|
|
43
53
|
}
|
|
54
|
+
function matchField(actual, expected, path) {
|
|
55
|
+
if (isNegativeMatcher(expected)) {
|
|
56
|
+
return !matchPositiveField(actual, expected.not, path);
|
|
57
|
+
}
|
|
58
|
+
return matchPositiveField(actual, expected, path);
|
|
59
|
+
}
|
|
60
|
+
function matchPositiveField(actual, expected, path) {
|
|
61
|
+
if (typeof expected === "string" || Array.isArray(expected)) {
|
|
62
|
+
return stringCandidates(actual).some((candidate) => matchString(candidate, expected, {
|
|
63
|
+
segmentSafe: path.endsWith("_path") || path.endsWith(".path")
|
|
64
|
+
}));
|
|
65
|
+
}
|
|
66
|
+
if (Array.isArray(actual)) {
|
|
67
|
+
return actual.some((item) => item === expected);
|
|
68
|
+
}
|
|
69
|
+
return actual === expected;
|
|
70
|
+
}
|
|
71
|
+
function stringCandidates(actual) {
|
|
72
|
+
if (actual === undefined)
|
|
73
|
+
return [];
|
|
74
|
+
if (Array.isArray(actual)) {
|
|
75
|
+
return actual.flatMap((item) => isPrimitiveFieldValue(item) ? [String(item)] : []);
|
|
76
|
+
}
|
|
77
|
+
return [String(actual)];
|
|
78
|
+
}
|
|
79
|
+
function isPrimitiveFieldValue(value) {
|
|
80
|
+
return value === null || typeof value === "string" || typeof value === "number" || typeof value === "boolean";
|
|
81
|
+
}
|
|
82
|
+
function isNegativeMatcher(value) {
|
|
83
|
+
return Boolean(value && typeof value === "object" && !Array.isArray(value) && "not" in value);
|
|
84
|
+
}
|
|
44
85
|
function eventMatchesFilter(event, filter) {
|
|
45
86
|
return matchString(event.source, filter.source) && matchString(event.type, filter.type) && matchString(event.subject, filter.subject) && matchString(event.severity, filter.severity) && matchRecord(event.data, filter.data) && matchRecord(event.metadata, filter.metadata);
|
|
46
87
|
}
|
|
@@ -463,7 +504,7 @@ class EventsClient {
|
|
|
463
504
|
}
|
|
464
505
|
return deliveries;
|
|
465
506
|
}
|
|
466
|
-
async
|
|
507
|
+
async matchChannel(id, input = {}) {
|
|
467
508
|
const channel = await this.store.getChannel(id);
|
|
468
509
|
if (!channel)
|
|
469
510
|
throw new Error(`Channel not found: ${id}`);
|
|
@@ -480,6 +521,34 @@ class EventsClient {
|
|
|
480
521
|
time: input.time,
|
|
481
522
|
id: input.id
|
|
482
523
|
});
|
|
524
|
+
const matched = channelMatchesEvent(channel, event);
|
|
525
|
+
return {
|
|
526
|
+
channelId: channel.id,
|
|
527
|
+
matched,
|
|
528
|
+
event,
|
|
529
|
+
filters: channel.filters,
|
|
530
|
+
reason: matched ? undefined : channel.enabled ? "event did not match channel filters" : "channel is disabled"
|
|
531
|
+
};
|
|
532
|
+
}
|
|
533
|
+
async testChannel(id, input = {}, options = {}) {
|
|
534
|
+
const channel = await this.store.getChannel(id);
|
|
535
|
+
if (!channel)
|
|
536
|
+
throw new Error(`Channel not found: ${id}`);
|
|
537
|
+
const match = await this.matchChannel(id, input);
|
|
538
|
+
const event = match.event;
|
|
539
|
+
if (options.honorFilters && !match.matched) {
|
|
540
|
+
const timestamp = new Date().toISOString();
|
|
541
|
+
const result2 = createDeliveryResult(event, channel, [{
|
|
542
|
+
attempt: 1,
|
|
543
|
+
status: "skipped",
|
|
544
|
+
startedAt: timestamp,
|
|
545
|
+
completedAt: timestamp,
|
|
546
|
+
error: match.reason
|
|
547
|
+
}]);
|
|
548
|
+
result2.metadata = { reason: "filter_mismatch" };
|
|
549
|
+
await this.store.appendDelivery(result2);
|
|
550
|
+
return result2;
|
|
551
|
+
}
|
|
483
552
|
const eventForChannel = await this.applyRedaction(event, channel);
|
|
484
553
|
const result = await this.deliverWithRetry(eventForChannel, channel);
|
|
485
554
|
await this.store.appendDelivery(result);
|
|
@@ -591,6 +660,78 @@ function normalizeRetryPolicy(policy) {
|
|
|
591
660
|
};
|
|
592
661
|
}
|
|
593
662
|
|
|
663
|
+
// src/filter-options.ts
|
|
664
|
+
function parseFieldMatchers(values, label, typed = false) {
|
|
665
|
+
if (!values?.length)
|
|
666
|
+
return;
|
|
667
|
+
const result = {};
|
|
668
|
+
for (const value of values) {
|
|
669
|
+
const parsed = parseMatcherExpression(value, label);
|
|
670
|
+
const path = parsed.path;
|
|
671
|
+
if (path in result)
|
|
672
|
+
throw new Error(`Duplicate ${label} filter path: ${path}`);
|
|
673
|
+
const matcherValue = typed ? parseTypedMatcherValue(parsed.rawValue, label) : parsed.rawValue;
|
|
674
|
+
result[path] = parsed.negated ? { not: matcherValue } : matcherValue;
|
|
675
|
+
}
|
|
676
|
+
return result;
|
|
677
|
+
}
|
|
678
|
+
function parseFilterOptions(options) {
|
|
679
|
+
const filter2 = {};
|
|
680
|
+
if (options.source)
|
|
681
|
+
filter2.source = options.source;
|
|
682
|
+
if (options.type)
|
|
683
|
+
filter2.type = options.type;
|
|
684
|
+
if (options.subject)
|
|
685
|
+
filter2.subject = options.subject;
|
|
686
|
+
if (options.severity)
|
|
687
|
+
filter2.severity = options.severity;
|
|
688
|
+
const data = mergeMatchers(parseFieldMatchers(options.data, "data"), parseFieldMatchers(options.dataJson, "data-json", true));
|
|
689
|
+
const metadata = mergeMatchers(parseFieldMatchers(options.metadata, "metadata"), parseFieldMatchers(options.metadataJson, "metadata-json", true));
|
|
690
|
+
if (Object.keys(data).length > 0)
|
|
691
|
+
filter2.data = data;
|
|
692
|
+
if (Object.keys(metadata).length > 0)
|
|
693
|
+
filter2.metadata = metadata;
|
|
694
|
+
return Object.keys(filter2).length > 0 ? [filter2] : undefined;
|
|
695
|
+
}
|
|
696
|
+
function mergeMatchers(...records) {
|
|
697
|
+
const result = {};
|
|
698
|
+
for (const record of records) {
|
|
699
|
+
if (!record)
|
|
700
|
+
continue;
|
|
701
|
+
for (const [path, value] of Object.entries(record)) {
|
|
702
|
+
if (path in result)
|
|
703
|
+
throw new Error(`Duplicate filter path: ${path}`);
|
|
704
|
+
result[path] = value;
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
return result;
|
|
708
|
+
}
|
|
709
|
+
function parseTypedMatcherValue(value, label) {
|
|
710
|
+
const parsed = JSON.parse(value);
|
|
711
|
+
if (parsed === null || typeof parsed === "string" || typeof parsed === "number" || typeof parsed === "boolean" || Array.isArray(parsed) && parsed.every((item) => typeof item === "string")) {
|
|
712
|
+
return parsed;
|
|
713
|
+
}
|
|
714
|
+
throw new Error(`${label} filter JSON values must be string, string[], number, boolean, or null`);
|
|
715
|
+
}
|
|
716
|
+
function parseMatcherExpression(value, label) {
|
|
717
|
+
const negativeSeparator = value.indexOf("!=");
|
|
718
|
+
if (negativeSeparator > 0) {
|
|
719
|
+
return {
|
|
720
|
+
path: value.slice(0, negativeSeparator),
|
|
721
|
+
rawValue: value.slice(negativeSeparator + 2),
|
|
722
|
+
negated: true
|
|
723
|
+
};
|
|
724
|
+
}
|
|
725
|
+
const separator = value.indexOf("=");
|
|
726
|
+
if (separator <= 0)
|
|
727
|
+
throw new Error(`Invalid ${label} filter, expected path=value or path!=value: ${value}`);
|
|
728
|
+
return {
|
|
729
|
+
path: value.slice(0, separator),
|
|
730
|
+
rawValue: value.slice(separator + 1),
|
|
731
|
+
negated: false
|
|
732
|
+
};
|
|
733
|
+
}
|
|
734
|
+
|
|
594
735
|
// src/cli/index.ts
|
|
595
736
|
function version() {
|
|
596
737
|
try {
|
|
@@ -606,12 +747,23 @@ function parseGlobalArgs(argv) {
|
|
|
606
747
|
let dir;
|
|
607
748
|
for (let index = 0;index < argv.length; index += 1) {
|
|
608
749
|
const arg = argv[index];
|
|
750
|
+
if (arg === "--") {
|
|
751
|
+
rest.push(...argv.slice(index + 1));
|
|
752
|
+
break;
|
|
753
|
+
}
|
|
754
|
+
if (!arg.startsWith("-")) {
|
|
755
|
+
rest.push(...argv.slice(index));
|
|
756
|
+
break;
|
|
757
|
+
}
|
|
609
758
|
if (arg === "--json" || arg === "-j") {
|
|
610
759
|
json = true;
|
|
760
|
+
} else if (arg.startsWith("--dir=")) {
|
|
761
|
+
dir = arg.slice("--dir=".length);
|
|
611
762
|
} else if (arg === "--dir") {
|
|
612
763
|
dir = argv[++index];
|
|
613
764
|
} else {
|
|
614
|
-
rest.push(
|
|
765
|
+
rest.push(...argv.slice(index));
|
|
766
|
+
break;
|
|
615
767
|
}
|
|
616
768
|
}
|
|
617
769
|
return { json, dir, rest };
|
|
@@ -659,20 +811,16 @@ function parseJsonOption(value, fallback) {
|
|
|
659
811
|
return parsed;
|
|
660
812
|
}
|
|
661
813
|
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;
|
|
814
|
+
return parseFilterOptions({
|
|
815
|
+
type: takeOption(args, "--type") ?? takeOption(args, "--event-type"),
|
|
816
|
+
source: takeOption(args, "--source"),
|
|
817
|
+
subject: takeOption(args, "--subject"),
|
|
818
|
+
severity: takeOption(args, "--severity"),
|
|
819
|
+
data: takeMany(args, "--data"),
|
|
820
|
+
metadata: takeMany(args, "--metadata"),
|
|
821
|
+
dataJson: takeMany(args, "--data-json"),
|
|
822
|
+
metadataJson: takeMany(args, "--metadata-json")
|
|
823
|
+
});
|
|
676
824
|
}
|
|
677
825
|
function parseHeaders(values) {
|
|
678
826
|
if (values.length === 0)
|
|
@@ -705,6 +853,8 @@ Usage:
|
|
|
705
853
|
${name} [--dir <path>] [--json] webhooks list
|
|
706
854
|
${name} [--dir <path>] [--json] webhooks remove <id>
|
|
707
855
|
${name} [--dir <path>] [--json] webhooks test <id>
|
|
856
|
+
${name} [--dir <path>] [--json] webhooks match <id>
|
|
857
|
+
${name} [--dir <path>] [--json] webhooks status
|
|
708
858
|
${name} [--dir <path>] [--json] status
|
|
709
859
|
${name} [--dir <path>] [--json] events emit <type>${options.source ? "" : " --source <source>"} [options]
|
|
710
860
|
${name} [--dir <path>] [--json] events list [--limit <n>]
|
|
@@ -722,6 +872,8 @@ Usage:
|
|
|
722
872
|
${name} [--dir <path>] [--json] webhooks list
|
|
723
873
|
${name} [--dir <path>] [--json] webhooks remove <id>
|
|
724
874
|
${name} [--dir <path>] [--json] webhooks test <id>
|
|
875
|
+
${name} [--dir <path>] [--json] webhooks match <id>
|
|
876
|
+
${name} [--dir <path>] [--json] webhooks status
|
|
725
877
|
|
|
726
878
|
Options:
|
|
727
879
|
--id <id> Channel id for add
|
|
@@ -729,12 +881,52 @@ Options:
|
|
|
729
881
|
--source <source> Event source filter
|
|
730
882
|
--subject <subject> Event subject filter
|
|
731
883
|
--severity <severity> Event severity filter
|
|
884
|
+
--data <path=value|path!=value> Event data field filter, repeatable; strings, dot paths, array-member matching, * segment wildcard, ** recursive wildcard
|
|
885
|
+
--metadata <path=value|path!=value> Event metadata field filter, repeatable; strings, dot paths, array-member matching, * segment wildcard, ** recursive wildcard
|
|
886
|
+
--data-json <path=json|path!=json> Event data field filter with typed JSON value
|
|
887
|
+
--metadata-json <path=json|path!=json> Event metadata field filter with typed JSON value
|
|
888
|
+
--honor-filters On webhooks test, skip delivery when the sample event does not match filters
|
|
732
889
|
--transport <kind> webhook or command
|
|
733
890
|
--secret <secret> Webhook signing secret
|
|
734
891
|
--header <name=value> Webhook header, repeatable
|
|
735
892
|
--redact <path> Redaction path, repeatable
|
|
736
893
|
--no-deliver Available on events emit`);
|
|
737
894
|
}
|
|
895
|
+
function printWebhookAddHelp(options = {}) {
|
|
896
|
+
const name = commandName(options);
|
|
897
|
+
console.log(`${name} webhooks add
|
|
898
|
+
|
|
899
|
+
Usage:
|
|
900
|
+
${name} [--dir <path>] [--json] webhooks add <url|command> [options]
|
|
901
|
+
${name} [--dir <path>] [--json] webhooks add <command> --transport command [options] -- [command-args...]
|
|
902
|
+
|
|
903
|
+
Options:
|
|
904
|
+
--id <id> Channel id
|
|
905
|
+
--name <name> Display name
|
|
906
|
+
--transport <kind> webhook or command
|
|
907
|
+
--type <pattern> Event type filter, supports wildcards
|
|
908
|
+
--source <source> Event source filter
|
|
909
|
+
--subject <subject> Event subject filter
|
|
910
|
+
--severity <severity> Event severity filter
|
|
911
|
+
--data <path=value|path!=value> Event data field filter, repeatable
|
|
912
|
+
--metadata <path=value|path!=value> Event metadata field filter, repeatable
|
|
913
|
+
--data-json <path=json|path!=json> Event data field filter with typed JSON value
|
|
914
|
+
--metadata-json <path=json|path!=json> Event metadata field filter with typed JSON value
|
|
915
|
+
--secret <secret> Webhook signing secret
|
|
916
|
+
--header <name=value> Webhook header, repeatable
|
|
917
|
+
--arg <arg> Command argument, repeatable; values may begin with dashes
|
|
918
|
+
--timeout-ms <ms> Transport timeout in milliseconds
|
|
919
|
+
--retry-attempts <n> Maximum delivery attempts
|
|
920
|
+
--retry-backoff-ms <ms> Initial retry backoff in milliseconds
|
|
921
|
+
--redact <path> Redaction path, repeatable
|
|
922
|
+
--disabled Create channel disabled
|
|
923
|
+
|
|
924
|
+
Examples:
|
|
925
|
+
${name} webhooks add https://example.com/webhooks/hasna --id ops --retry-attempts 3 --retry-backoff-ms 500
|
|
926
|
+
${name} webhooks add bun --id command-hook --transport command --arg run --arg ./handler.ts --arg --json
|
|
927
|
+
${name} webhooks add bun --id command-hook --transport command --arg=--json
|
|
928
|
+
${name} webhooks add bun --id command-hook --transport command -- run ./handler.ts --json`);
|
|
929
|
+
}
|
|
738
930
|
function printEventsHelp(options = {}) {
|
|
739
931
|
const name = commandName(options);
|
|
740
932
|
console.log(`${name} events
|
|
@@ -781,6 +973,10 @@ async function runEventsCli(argv = process.argv.slice(2), options = {}) {
|
|
|
781
973
|
printWebhooksHelp(options);
|
|
782
974
|
return;
|
|
783
975
|
}
|
|
976
|
+
if (command === "add" && (tail[0] === "--help" || tail[0] === "-h")) {
|
|
977
|
+
printWebhookAddHelp(options);
|
|
978
|
+
return;
|
|
979
|
+
}
|
|
784
980
|
if (tail.includes("--help") || tail.includes("-h")) {
|
|
785
981
|
printWebhooksHelp(options);
|
|
786
982
|
return;
|
|
@@ -804,7 +1000,7 @@ async function runEventsCli(argv = process.argv.slice(2), options = {}) {
|
|
|
804
1000
|
}
|
|
805
1001
|
async function handleWebhooks(client, command, tail, parsed, options) {
|
|
806
1002
|
if (command === "add") {
|
|
807
|
-
const args =
|
|
1003
|
+
const { args, delimiterArgs } = splitDelimiter(tail);
|
|
808
1004
|
const transport = takeOption(args, "--transport") ?? "webhook";
|
|
809
1005
|
const id = takeOption(args, "--id") ?? crypto.randomUUID();
|
|
810
1006
|
const name = takeOption(args, "--name");
|
|
@@ -835,7 +1031,7 @@ async function handleWebhooks(client, command, tail, parsed, options) {
|
|
|
835
1031
|
if (transport === "webhook") {
|
|
836
1032
|
channel.webhook = { url: target, secret, headers: parseHeaders(headerValues), timeoutMs };
|
|
837
1033
|
} else if (transport === "command") {
|
|
838
|
-
channel.command = { command: target, args: [...args.slice(1), ...commandArgs], timeoutMs };
|
|
1034
|
+
channel.command = { command: target, args: [...args.slice(1), ...commandArgs, ...delimiterArgs], timeoutMs };
|
|
839
1035
|
} else {
|
|
840
1036
|
throw new Error(`Transport ${transport} is reserved for future use and cannot be added yet`);
|
|
841
1037
|
}
|
|
@@ -857,6 +1053,14 @@ async function handleWebhooks(client, command, tail, parsed, options) {
|
|
|
857
1053
|
});
|
|
858
1054
|
return;
|
|
859
1055
|
}
|
|
1056
|
+
if (command === "status") {
|
|
1057
|
+
const status = await getEventsStatus(parsed.dir);
|
|
1058
|
+
output(parsed, status, () => {
|
|
1059
|
+
console.log(`events dataDir: ${status.dataDir}`);
|
|
1060
|
+
console.log(`${status.counts.enabledChannels}/${status.counts.channels} channel(s) enabled`);
|
|
1061
|
+
});
|
|
1062
|
+
return;
|
|
1063
|
+
}
|
|
860
1064
|
if (command === "remove") {
|
|
861
1065
|
const id = tail[0];
|
|
862
1066
|
if (!id)
|
|
@@ -870,17 +1074,45 @@ async function handleWebhooks(client, command, tail, parsed, options) {
|
|
|
870
1074
|
const id = args.shift();
|
|
871
1075
|
if (!id)
|
|
872
1076
|
throw new Error("webhooks test requires a channel id");
|
|
1077
|
+
const honorFilters = takeFlag(args, "--honor-filters");
|
|
873
1078
|
const result = await client.testChannel(id, {
|
|
874
1079
|
source: takeOption(args, "--source") ?? options.source ?? "hasna.events",
|
|
875
1080
|
type: takeOption(args, "--type") ?? "events.test",
|
|
876
1081
|
subject: takeOption(args, "--subject") ?? id,
|
|
877
|
-
|
|
878
|
-
|
|
1082
|
+
message: takeOption(args, "--message") ?? "Hasna events test delivery",
|
|
1083
|
+
data: parseJsonOption(takeOption(args, "--data"), { test: true }),
|
|
1084
|
+
metadata: parseJsonOption(takeOption(args, "--metadata"), {})
|
|
1085
|
+
}, { honorFilters });
|
|
879
1086
|
output(parsed, result, () => console.log(`${result.status}: ${result.channelId}`));
|
|
880
1087
|
return;
|
|
881
1088
|
}
|
|
1089
|
+
if (command === "match") {
|
|
1090
|
+
const args = [...tail];
|
|
1091
|
+
const id = args.shift();
|
|
1092
|
+
if (!id)
|
|
1093
|
+
throw new Error("webhooks match requires a channel id");
|
|
1094
|
+
const result = await client.matchChannel(id, {
|
|
1095
|
+
source: takeOption(args, "--source") ?? options.source ?? "hasna.events",
|
|
1096
|
+
type: takeOption(args, "--type") ?? "events.test",
|
|
1097
|
+
subject: takeOption(args, "--subject") ?? id,
|
|
1098
|
+
message: takeOption(args, "--message") ?? "Hasna events match preview",
|
|
1099
|
+
data: parseJsonOption(takeOption(args, "--data"), { test: true }),
|
|
1100
|
+
metadata: parseJsonOption(takeOption(args, "--metadata"), {})
|
|
1101
|
+
});
|
|
1102
|
+
output(parsed, result, () => console.log(`${result.matched ? "matched" : "skipped"}: ${result.channelId}`));
|
|
1103
|
+
return;
|
|
1104
|
+
}
|
|
882
1105
|
throw new Error(`Unknown webhooks command: ${command ?? ""}`);
|
|
883
1106
|
}
|
|
1107
|
+
function splitDelimiter(values) {
|
|
1108
|
+
const delimiterIndex = values.indexOf("--");
|
|
1109
|
+
if (delimiterIndex === -1)
|
|
1110
|
+
return { args: [...values], delimiterArgs: [] };
|
|
1111
|
+
return {
|
|
1112
|
+
args: values.slice(0, delimiterIndex),
|
|
1113
|
+
delimiterArgs: values.slice(delimiterIndex + 1)
|
|
1114
|
+
};
|
|
1115
|
+
}
|
|
884
1116
|
async function handleEvents(client, command, tail, parsed, options) {
|
|
885
1117
|
if (command === "emit") {
|
|
886
1118
|
const args = [...tail];
|
package/dist/commander.js
CHANGED
|
@@ -8,29 +8,70 @@ 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)
|
|
25
38
|
return true;
|
|
26
39
|
return Object.entries(matcher).every(([path, expected]) => {
|
|
27
40
|
const actual = getPathValue(input, path);
|
|
28
|
-
|
|
29
|
-
return matchString(actual === undefined ? undefined : String(actual), expected);
|
|
30
|
-
}
|
|
31
|
-
return actual === expected;
|
|
41
|
+
return matchField(actual, expected, path);
|
|
32
42
|
});
|
|
33
43
|
}
|
|
44
|
+
function matchField(actual, expected, path) {
|
|
45
|
+
if (isNegativeMatcher(expected)) {
|
|
46
|
+
return !matchPositiveField(actual, expected.not, path);
|
|
47
|
+
}
|
|
48
|
+
return matchPositiveField(actual, expected, path);
|
|
49
|
+
}
|
|
50
|
+
function matchPositiveField(actual, expected, path) {
|
|
51
|
+
if (typeof expected === "string" || Array.isArray(expected)) {
|
|
52
|
+
return stringCandidates(actual).some((candidate) => matchString(candidate, expected, {
|
|
53
|
+
segmentSafe: path.endsWith("_path") || path.endsWith(".path")
|
|
54
|
+
}));
|
|
55
|
+
}
|
|
56
|
+
if (Array.isArray(actual)) {
|
|
57
|
+
return actual.some((item) => item === expected);
|
|
58
|
+
}
|
|
59
|
+
return actual === expected;
|
|
60
|
+
}
|
|
61
|
+
function stringCandidates(actual) {
|
|
62
|
+
if (actual === undefined)
|
|
63
|
+
return [];
|
|
64
|
+
if (Array.isArray(actual)) {
|
|
65
|
+
return actual.flatMap((item) => isPrimitiveFieldValue(item) ? [String(item)] : []);
|
|
66
|
+
}
|
|
67
|
+
return [String(actual)];
|
|
68
|
+
}
|
|
69
|
+
function isPrimitiveFieldValue(value) {
|
|
70
|
+
return value === null || typeof value === "string" || typeof value === "number" || typeof value === "boolean";
|
|
71
|
+
}
|
|
72
|
+
function isNegativeMatcher(value) {
|
|
73
|
+
return Boolean(value && typeof value === "object" && !Array.isArray(value) && "not" in value);
|
|
74
|
+
}
|
|
34
75
|
function eventMatchesFilter(event, filter) {
|
|
35
76
|
return matchString(event.source, filter.source) && matchString(event.type, filter.type) && matchString(event.subject, filter.subject) && matchString(event.severity, filter.severity) && matchRecord(event.data, filter.data) && matchRecord(event.metadata, filter.metadata);
|
|
36
77
|
}
|
|
@@ -474,7 +515,7 @@ class EventsClient {
|
|
|
474
515
|
}
|
|
475
516
|
return deliveries;
|
|
476
517
|
}
|
|
477
|
-
async
|
|
518
|
+
async matchChannel(id, input = {}) {
|
|
478
519
|
const channel = await this.store.getChannel(id);
|
|
479
520
|
if (!channel)
|
|
480
521
|
throw new Error(`Channel not found: ${id}`);
|
|
@@ -491,6 +532,34 @@ class EventsClient {
|
|
|
491
532
|
time: input.time,
|
|
492
533
|
id: input.id
|
|
493
534
|
});
|
|
535
|
+
const matched = channelMatchesEvent(channel, event);
|
|
536
|
+
return {
|
|
537
|
+
channelId: channel.id,
|
|
538
|
+
matched,
|
|
539
|
+
event,
|
|
540
|
+
filters: channel.filters,
|
|
541
|
+
reason: matched ? undefined : channel.enabled ? "event did not match channel filters" : "channel is disabled"
|
|
542
|
+
};
|
|
543
|
+
}
|
|
544
|
+
async testChannel(id, input = {}, options = {}) {
|
|
545
|
+
const channel = await this.store.getChannel(id);
|
|
546
|
+
if (!channel)
|
|
547
|
+
throw new Error(`Channel not found: ${id}`);
|
|
548
|
+
const match = await this.matchChannel(id, input);
|
|
549
|
+
const event = match.event;
|
|
550
|
+
if (options.honorFilters && !match.matched) {
|
|
551
|
+
const timestamp = new Date().toISOString();
|
|
552
|
+
const result2 = createDeliveryResult(event, channel, [{
|
|
553
|
+
attempt: 1,
|
|
554
|
+
status: "skipped",
|
|
555
|
+
startedAt: timestamp,
|
|
556
|
+
completedAt: timestamp,
|
|
557
|
+
error: match.reason
|
|
558
|
+
}]);
|
|
559
|
+
result2.metadata = { reason: "filter_mismatch" };
|
|
560
|
+
await this.store.appendDelivery(result2);
|
|
561
|
+
return result2;
|
|
562
|
+
}
|
|
494
563
|
const eventForChannel = await this.applyRedaction(event, channel);
|
|
495
564
|
const result = await this.deliverWithRetry(eventForChannel, channel);
|
|
496
565
|
await this.store.appendDelivery(result);
|
|
@@ -602,6 +671,78 @@ function normalizeRetryPolicy(policy) {
|
|
|
602
671
|
};
|
|
603
672
|
}
|
|
604
673
|
|
|
674
|
+
// src/filter-options.ts
|
|
675
|
+
function parseFieldMatchers(values, label, typed = false) {
|
|
676
|
+
if (!values?.length)
|
|
677
|
+
return;
|
|
678
|
+
const result = {};
|
|
679
|
+
for (const value of values) {
|
|
680
|
+
const parsed = parseMatcherExpression(value, label);
|
|
681
|
+
const path = parsed.path;
|
|
682
|
+
if (path in result)
|
|
683
|
+
throw new Error(`Duplicate ${label} filter path: ${path}`);
|
|
684
|
+
const matcherValue = typed ? parseTypedMatcherValue(parsed.rawValue, label) : parsed.rawValue;
|
|
685
|
+
result[path] = parsed.negated ? { not: matcherValue } : matcherValue;
|
|
686
|
+
}
|
|
687
|
+
return result;
|
|
688
|
+
}
|
|
689
|
+
function parseFilterOptions(options) {
|
|
690
|
+
const filter2 = {};
|
|
691
|
+
if (options.source)
|
|
692
|
+
filter2.source = options.source;
|
|
693
|
+
if (options.type)
|
|
694
|
+
filter2.type = options.type;
|
|
695
|
+
if (options.subject)
|
|
696
|
+
filter2.subject = options.subject;
|
|
697
|
+
if (options.severity)
|
|
698
|
+
filter2.severity = options.severity;
|
|
699
|
+
const data = mergeMatchers(parseFieldMatchers(options.data, "data"), parseFieldMatchers(options.dataJson, "data-json", true));
|
|
700
|
+
const metadata = mergeMatchers(parseFieldMatchers(options.metadata, "metadata"), parseFieldMatchers(options.metadataJson, "metadata-json", true));
|
|
701
|
+
if (Object.keys(data).length > 0)
|
|
702
|
+
filter2.data = data;
|
|
703
|
+
if (Object.keys(metadata).length > 0)
|
|
704
|
+
filter2.metadata = metadata;
|
|
705
|
+
return Object.keys(filter2).length > 0 ? [filter2] : undefined;
|
|
706
|
+
}
|
|
707
|
+
function mergeMatchers(...records) {
|
|
708
|
+
const result = {};
|
|
709
|
+
for (const record of records) {
|
|
710
|
+
if (!record)
|
|
711
|
+
continue;
|
|
712
|
+
for (const [path, value] of Object.entries(record)) {
|
|
713
|
+
if (path in result)
|
|
714
|
+
throw new Error(`Duplicate filter path: ${path}`);
|
|
715
|
+
result[path] = value;
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
return result;
|
|
719
|
+
}
|
|
720
|
+
function parseTypedMatcherValue(value, label) {
|
|
721
|
+
const parsed = JSON.parse(value);
|
|
722
|
+
if (parsed === null || typeof parsed === "string" || typeof parsed === "number" || typeof parsed === "boolean" || Array.isArray(parsed) && parsed.every((item) => typeof item === "string")) {
|
|
723
|
+
return parsed;
|
|
724
|
+
}
|
|
725
|
+
throw new Error(`${label} filter JSON values must be string, string[], number, boolean, or null`);
|
|
726
|
+
}
|
|
727
|
+
function parseMatcherExpression(value, label) {
|
|
728
|
+
const negativeSeparator = value.indexOf("!=");
|
|
729
|
+
if (negativeSeparator > 0) {
|
|
730
|
+
return {
|
|
731
|
+
path: value.slice(0, negativeSeparator),
|
|
732
|
+
rawValue: value.slice(negativeSeparator + 2),
|
|
733
|
+
negated: true
|
|
734
|
+
};
|
|
735
|
+
}
|
|
736
|
+
const separator = value.indexOf("=");
|
|
737
|
+
if (separator <= 0)
|
|
738
|
+
throw new Error(`Invalid ${label} filter, expected path=value or path!=value: ${value}`);
|
|
739
|
+
return {
|
|
740
|
+
path: value.slice(0, separator),
|
|
741
|
+
rawValue: value.slice(separator + 1),
|
|
742
|
+
negated: false
|
|
743
|
+
};
|
|
744
|
+
}
|
|
745
|
+
|
|
605
746
|
// src/commander.ts
|
|
606
747
|
function parseJsonObject(value, fallback) {
|
|
607
748
|
if (!value)
|
|
@@ -624,18 +765,6 @@ function parseHeaders(values) {
|
|
|
624
765
|
}
|
|
625
766
|
return headers;
|
|
626
767
|
}
|
|
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
768
|
function createClient(options) {
|
|
640
769
|
if (options.createClient)
|
|
641
770
|
return options.createClient();
|
|
@@ -655,14 +784,14 @@ function wantsJson(actionOptions, command) {
|
|
|
655
784
|
}
|
|
656
785
|
function registerWebhookCommands(program, options) {
|
|
657
786
|
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) => {
|
|
787
|
+
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, path!=value negatives, array-member matching, dot paths, * segment wildcard, ** recursive wildcard", collectValues, []).option("--metadata <path=value...>", "Event metadata field filter; string values, path!=value negatives, array-member matching, dot paths, * segment wildcard, ** recursive wildcard", collectValues, []).option("--data-json <path=json...>", "Event data field filter with typed JSON value; path!=json negatives supported", collectValues, []).option("--metadata-json <path=json...>", "Event metadata field filter with typed JSON value; path!=json negatives supported", 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
788
|
const timestamp = new Date().toISOString();
|
|
660
789
|
const channel = {
|
|
661
790
|
id: actionOptions.id,
|
|
662
791
|
name: actionOptions.name,
|
|
663
792
|
enabled: !actionOptions.disabled,
|
|
664
793
|
transport: actionOptions.transport,
|
|
665
|
-
filters:
|
|
794
|
+
filters: parseFilterOptions(actionOptions),
|
|
666
795
|
retry: actionOptions.retryAttempts || actionOptions.retryBackoffMs ? { maxAttempts: actionOptions.retryAttempts, backoffMs: actionOptions.retryBackoffMs } : undefined,
|
|
667
796
|
redact: actionOptions.redact?.length ? { paths: actionOptions.redact } : undefined,
|
|
668
797
|
createdAt: timestamp,
|
|
@@ -692,20 +821,36 @@ function registerWebhookCommands(program, options) {
|
|
|
692
821
|
console.log(`${channel.id} ${channel.enabled ? "enabled" : "disabled"} ${channel.transport} ${channel.webhook?.url ?? channel.command?.command ?? channel.transport}`);
|
|
693
822
|
}
|
|
694
823
|
});
|
|
824
|
+
webhooks.command("status").description("Show events webhook storage status").option("-j, --json", "Print JSON output", false).action(async (actionOptions, command) => {
|
|
825
|
+
const status = await getEventsStatus(options.dataDir);
|
|
826
|
+
print(status, wantsJson(actionOptions, command), `events dataDir: ${status.dataDir}`);
|
|
827
|
+
});
|
|
695
828
|
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
829
|
const removed = await createClient(options).removeChannel(id);
|
|
697
830
|
print({ removed }, wantsJson(actionOptions, command), removed ? `Removed ${id}` : `Channel not found: ${id}`);
|
|
698
831
|
});
|
|
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) => {
|
|
832
|
+
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
833
|
const result = await createClient(options).testChannel(id, {
|
|
701
|
-
source: options.source,
|
|
834
|
+
source: actionOptions.source ?? options.source,
|
|
702
835
|
type: actionOptions.type,
|
|
703
836
|
subject: actionOptions.subject ?? id,
|
|
704
837
|
message: actionOptions.message,
|
|
705
|
-
data: parseJsonObject(actionOptions.data, { test: true })
|
|
706
|
-
|
|
838
|
+
data: parseJsonObject(actionOptions.data, { test: true }),
|
|
839
|
+
metadata: parseJsonObject(actionOptions.metadata, {})
|
|
840
|
+
}, { honorFilters: actionOptions.honorFilters });
|
|
707
841
|
print(result, wantsJson(actionOptions, command), `${result.status}: ${result.channelId}`);
|
|
708
842
|
});
|
|
843
|
+
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) => {
|
|
844
|
+
const result = await createClient(options).matchChannel(id, {
|
|
845
|
+
source: actionOptions.source ?? options.source,
|
|
846
|
+
type: actionOptions.type,
|
|
847
|
+
subject: actionOptions.subject ?? id,
|
|
848
|
+
message: actionOptions.message,
|
|
849
|
+
data: parseJsonObject(actionOptions.data, { test: true }),
|
|
850
|
+
metadata: parseJsonObject(actionOptions.metadata, {})
|
|
851
|
+
});
|
|
852
|
+
print(result, wantsJson(actionOptions, command), `${result.matched ? "matched" : "skipped"}: ${result.channelId}`);
|
|
853
|
+
});
|
|
709
854
|
return webhooks;
|
|
710
855
|
}
|
|
711
856
|
function registerEventCommands(program, options) {
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { EventFilter, FieldMatcherValue } from "./types.js";
|
|
2
|
+
type MatcherValue = FieldMatcherValue | {
|
|
3
|
+
not: FieldMatcherValue;
|
|
4
|
+
};
|
|
5
|
+
export interface FilterOptionInput {
|
|
6
|
+
source?: string;
|
|
7
|
+
type?: string;
|
|
8
|
+
subject?: string;
|
|
9
|
+
severity?: string;
|
|
10
|
+
data?: string[];
|
|
11
|
+
metadata?: string[];
|
|
12
|
+
dataJson?: string[];
|
|
13
|
+
metadataJson?: string[];
|
|
14
|
+
}
|
|
15
|
+
export declare function parseFieldMatchers(values: string[] | undefined, label: string, typed?: boolean): Record<string, MatcherValue> | undefined;
|
|
16
|
+
export declare function parseFilterOptions(options: FilterOptionInput): EventFilter[] | undefined;
|
|
17
|
+
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,29 +8,70 @@ 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)
|
|
25
38
|
return true;
|
|
26
39
|
return Object.entries(matcher).every(([path, expected]) => {
|
|
27
40
|
const actual = getPathValue(input, path);
|
|
28
|
-
|
|
29
|
-
return matchString(actual === undefined ? undefined : String(actual), expected);
|
|
30
|
-
}
|
|
31
|
-
return actual === expected;
|
|
41
|
+
return matchField(actual, expected, path);
|
|
32
42
|
});
|
|
33
43
|
}
|
|
44
|
+
function matchField(actual, expected, path) {
|
|
45
|
+
if (isNegativeMatcher(expected)) {
|
|
46
|
+
return !matchPositiveField(actual, expected.not, path);
|
|
47
|
+
}
|
|
48
|
+
return matchPositiveField(actual, expected, path);
|
|
49
|
+
}
|
|
50
|
+
function matchPositiveField(actual, expected, path) {
|
|
51
|
+
if (typeof expected === "string" || Array.isArray(expected)) {
|
|
52
|
+
return stringCandidates(actual).some((candidate) => matchString(candidate, expected, {
|
|
53
|
+
segmentSafe: path.endsWith("_path") || path.endsWith(".path")
|
|
54
|
+
}));
|
|
55
|
+
}
|
|
56
|
+
if (Array.isArray(actual)) {
|
|
57
|
+
return actual.some((item) => item === expected);
|
|
58
|
+
}
|
|
59
|
+
return actual === expected;
|
|
60
|
+
}
|
|
61
|
+
function stringCandidates(actual) {
|
|
62
|
+
if (actual === undefined)
|
|
63
|
+
return [];
|
|
64
|
+
if (Array.isArray(actual)) {
|
|
65
|
+
return actual.flatMap((item) => isPrimitiveFieldValue(item) ? [String(item)] : []);
|
|
66
|
+
}
|
|
67
|
+
return [String(actual)];
|
|
68
|
+
}
|
|
69
|
+
function isPrimitiveFieldValue(value) {
|
|
70
|
+
return value === null || typeof value === "string" || typeof value === "number" || typeof value === "boolean";
|
|
71
|
+
}
|
|
72
|
+
function isNegativeMatcher(value) {
|
|
73
|
+
return Boolean(value && typeof value === "object" && !Array.isArray(value) && "not" in value);
|
|
74
|
+
}
|
|
34
75
|
function eventMatchesFilter(event, filter) {
|
|
35
76
|
return matchString(event.source, filter.source) && matchString(event.type, filter.type) && matchString(event.subject, filter.subject) && matchString(event.severity, filter.severity) && matchRecord(event.data, filter.data) && matchRecord(event.metadata, filter.metadata);
|
|
36
77
|
}
|
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,29 +8,70 @@ 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)
|
|
25
38
|
return true;
|
|
26
39
|
return Object.entries(matcher).every(([path, expected]) => {
|
|
27
40
|
const actual = getPathValue(input, path);
|
|
28
|
-
|
|
29
|
-
return matchString(actual === undefined ? undefined : String(actual), expected);
|
|
30
|
-
}
|
|
31
|
-
return actual === expected;
|
|
41
|
+
return matchField(actual, expected, path);
|
|
32
42
|
});
|
|
33
43
|
}
|
|
44
|
+
function matchField(actual, expected, path) {
|
|
45
|
+
if (isNegativeMatcher(expected)) {
|
|
46
|
+
return !matchPositiveField(actual, expected.not, path);
|
|
47
|
+
}
|
|
48
|
+
return matchPositiveField(actual, expected, path);
|
|
49
|
+
}
|
|
50
|
+
function matchPositiveField(actual, expected, path) {
|
|
51
|
+
if (typeof expected === "string" || Array.isArray(expected)) {
|
|
52
|
+
return stringCandidates(actual).some((candidate) => matchString(candidate, expected, {
|
|
53
|
+
segmentSafe: path.endsWith("_path") || path.endsWith(".path")
|
|
54
|
+
}));
|
|
55
|
+
}
|
|
56
|
+
if (Array.isArray(actual)) {
|
|
57
|
+
return actual.some((item) => item === expected);
|
|
58
|
+
}
|
|
59
|
+
return actual === expected;
|
|
60
|
+
}
|
|
61
|
+
function stringCandidates(actual) {
|
|
62
|
+
if (actual === undefined)
|
|
63
|
+
return [];
|
|
64
|
+
if (Array.isArray(actual)) {
|
|
65
|
+
return actual.flatMap((item) => isPrimitiveFieldValue(item) ? [String(item)] : []);
|
|
66
|
+
}
|
|
67
|
+
return [String(actual)];
|
|
68
|
+
}
|
|
69
|
+
function isPrimitiveFieldValue(value) {
|
|
70
|
+
return value === null || typeof value === "string" || typeof value === "number" || typeof value === "boolean";
|
|
71
|
+
}
|
|
72
|
+
function isNegativeMatcher(value) {
|
|
73
|
+
return Boolean(value && typeof value === "object" && !Array.isArray(value) && "not" in value);
|
|
74
|
+
}
|
|
34
75
|
function eventMatchesFilter(event, filter) {
|
|
35
76
|
return matchString(event.source, filter.source) && matchString(event.type, filter.type) && matchString(event.subject, filter.subject) && matchString(event.severity, filter.severity) && matchRecord(event.data, filter.data) && matchRecord(event.metadata, filter.metadata);
|
|
36
77
|
}
|
|
@@ -474,7 +515,7 @@ class EventsClient {
|
|
|
474
515
|
}
|
|
475
516
|
return deliveries;
|
|
476
517
|
}
|
|
477
|
-
async
|
|
518
|
+
async matchChannel(id, input = {}) {
|
|
478
519
|
const channel = await this.store.getChannel(id);
|
|
479
520
|
if (!channel)
|
|
480
521
|
throw new Error(`Channel not found: ${id}`);
|
|
@@ -491,6 +532,34 @@ class EventsClient {
|
|
|
491
532
|
time: input.time,
|
|
492
533
|
id: input.id
|
|
493
534
|
});
|
|
535
|
+
const matched = channelMatchesEvent(channel, event);
|
|
536
|
+
return {
|
|
537
|
+
channelId: channel.id,
|
|
538
|
+
matched,
|
|
539
|
+
event,
|
|
540
|
+
filters: channel.filters,
|
|
541
|
+
reason: matched ? undefined : channel.enabled ? "event did not match channel filters" : "channel is disabled"
|
|
542
|
+
};
|
|
543
|
+
}
|
|
544
|
+
async testChannel(id, input = {}, options = {}) {
|
|
545
|
+
const channel = await this.store.getChannel(id);
|
|
546
|
+
if (!channel)
|
|
547
|
+
throw new Error(`Channel not found: ${id}`);
|
|
548
|
+
const match = await this.matchChannel(id, input);
|
|
549
|
+
const event = match.event;
|
|
550
|
+
if (options.honorFilters && !match.matched) {
|
|
551
|
+
const timestamp = new Date().toISOString();
|
|
552
|
+
const result2 = createDeliveryResult(event, channel, [{
|
|
553
|
+
attempt: 1,
|
|
554
|
+
status: "skipped",
|
|
555
|
+
startedAt: timestamp,
|
|
556
|
+
completedAt: timestamp,
|
|
557
|
+
error: match.reason
|
|
558
|
+
}]);
|
|
559
|
+
result2.metadata = { reason: "filter_mismatch" };
|
|
560
|
+
await this.store.appendDelivery(result2);
|
|
561
|
+
return result2;
|
|
562
|
+
}
|
|
494
563
|
const eventForChannel = await this.applyRedaction(event, channel);
|
|
495
564
|
const result = await this.deliverWithRetry(eventForChannel, channel);
|
|
496
565
|
await this.store.appendDelivery(result);
|
package/dist/types.d.ts
CHANGED
|
@@ -27,13 +27,18 @@ export interface EventInput<TData extends EventData = EventData> {
|
|
|
27
27
|
metadata?: Record<string, unknown>;
|
|
28
28
|
}
|
|
29
29
|
export type StringMatcher = string | string[];
|
|
30
|
+
export type FieldMatcherValue = StringMatcher | number | boolean | null;
|
|
31
|
+
export interface NegativeFieldMatcher {
|
|
32
|
+
not: FieldMatcherValue;
|
|
33
|
+
}
|
|
34
|
+
export type FieldMatcher = FieldMatcherValue | NegativeFieldMatcher;
|
|
30
35
|
export interface EventFilter {
|
|
31
36
|
source?: StringMatcher;
|
|
32
37
|
type?: StringMatcher;
|
|
33
38
|
subject?: StringMatcher;
|
|
34
39
|
severity?: StringMatcher;
|
|
35
|
-
data?: Record<string,
|
|
36
|
-
metadata?: Record<string,
|
|
40
|
+
data?: Record<string, FieldMatcher>;
|
|
41
|
+
metadata?: Record<string, FieldMatcher>;
|
|
37
42
|
}
|
|
38
43
|
export interface RetryPolicy {
|
|
39
44
|
maxAttempts?: number;
|