@nostrwatch/kuma 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) Sandwich Farm LLC
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,150 @@
1
+ # @nostrwatch/kuma
2
+
3
+ Uptime Kuma PUSH monitor for Nostr relays.
4
+
5
+ [![npm version](https://img.shields.io/npm/v/@nostrwatch/kuma?style=flat-square&label=npm)](https://www.npmjs.com/package/@nostrwatch/kuma)
6
+ [![License](https://img.shields.io/github/license/sandwichfarm/nostr-watch?style=flat-square)](LICENSE)
7
+ [![Status](https://img.shields.io/badge/status-alpha-orange?style=flat-square)](https://github.com/sandwichfarm/nostr-watch)
8
+ [![Runtime](https://img.shields.io/badge/runtime-cli-blue?style=flat-square)](https://github.com/sandwichfarm/nostr-watch)
9
+
10
+ ## Overview
11
+
12
+ `@nostrwatch/kuma` integrates Nostr relay (a WebSocket server that stores and forwards events) health checks with [Uptime Kuma](https://github.com/louislam/uptime-kuma) push monitors. It runs `open`, `read`, and optional `write` checks against a relay using the `@nostrwatch/nocap` WebSocket adapter, then pushes the result — status (`up` or `down`) and latency — to an Uptime Kuma push URL. Available as both a CLI tool and a TypeScript library.
13
+
14
+ ## Prerequisites
15
+
16
+ Node.js >=18 and pnpm >=9.
17
+
18
+ An Uptime Kuma instance with a configured push-type monitor. The push URL looks like `https://kuma.example.com/api/push/<key>`.
19
+
20
+ ## Installation
21
+
22
+ ```sh
23
+ pnpm add @nostrwatch/kuma
24
+ ```
25
+
26
+ Or with npm:
27
+
28
+ ```sh
29
+ npm install @nostrwatch/kuma
30
+ ```
31
+
32
+ ## Quick Start
33
+
34
+ Run a one-shot check via CLI:
35
+
36
+ ```sh
37
+ npx nostrwatch-kuma \
38
+ --relay wss://relay.damus.io \
39
+ --push-url https://kuma.example.com/api/push/abc123 \
40
+ --once
41
+ ```
42
+
43
+ Or use the library API:
44
+
45
+ ```ts
46
+ import {runOnce} from '@nostrwatch/kuma'
47
+
48
+ await runOnce({
49
+ relayUrl: 'wss://relay.damus.io',
50
+ pushUrl: 'https://kuma.example.com/api/push/abc123',
51
+ checks: ['open', 'read']
52
+ })
53
+ // Checks the relay and pushes status=up/down + ping to Uptime Kuma
54
+ ```
55
+
56
+ ## API
57
+
58
+ ### Library API
59
+
60
+ #### `runChecks(options)`
61
+
62
+ ```ts
63
+ async function runChecks(options: KumaMonitorOptions): Promise<CheckResultSummary>
64
+ ```
65
+
66
+ Runs nocap relay checks and returns a summary without pushing to Uptime Kuma. Use this to inspect results before deciding whether to push.
67
+
68
+ #### `runOnce(options)`
69
+
70
+ ```ts
71
+ async function runOnce(options: KumaMonitorOptions): Promise<void>
72
+ ```
73
+
74
+ Runs checks and pushes the result to Uptime Kuma once. Resolves when the push completes. Rejects if the push request fails.
75
+
76
+ #### `runForever(options)`
77
+
78
+ ```ts
79
+ async function runForever(options: KumaMonitorOptions): Promise<() => void>
80
+ ```
81
+
82
+ Runs checks and pushes on a repeating interval. Returns a `stop` function that halts the loop when called. The first check runs immediately.
83
+
84
+ #### `buildKumaPayload(summary, strategy?)`
85
+
86
+ ```ts
87
+ function buildKumaPayload(summary: CheckResultSummary, strategy?: PingStrategy): KumaPushPayload
88
+ ```
89
+
90
+ Converts a `CheckResultSummary` to a `KumaPushPayload`. Exposed for custom push implementations.
91
+
92
+ ### `KumaMonitorOptions`
93
+
94
+ | Option | Type | Default | Description |
95
+ |--------|------|---------|-------------|
96
+ | `relayUrl` | `string` | — | WebSocket URL of the relay to check |
97
+ | `pushUrl` | `string` | — | Full Uptime Kuma push URL |
98
+ | `checks` | `CheckKey[]` | `['open','read']` | Which checks to run: `'open'`, `'read'`, `'write'` |
99
+ | `requiredChecks` | `CheckKey[]` | same as `checks` | Checks that must pass for status to be `up` |
100
+ | `pingStrategy` | `'sum' \| 'max'` | `'sum'` | How to aggregate check durations into ping value |
101
+ | `intervalMs` | `number` | `60000` | Loop interval for `runForever` in milliseconds |
102
+ | `nocap` | `Partial<NocapConfig>` | — | Pass-through config for `@nostrwatch/nocap` |
103
+ | `headers` | `boolean` | `true` | Include HTTP headers in nocap result |
104
+
105
+ ### `CheckResultSummary`
106
+
107
+ ```ts
108
+ interface CheckResultSummary {
109
+ ok: boolean
110
+ failing: CheckKey[]
111
+ result: any
112
+ durations: Partial<Record<CheckKey, number>>
113
+ }
114
+ ```
115
+
116
+ `ok` is `true` only when all `requiredChecks` pass. `durations` contains per-check millisecond timings.
117
+
118
+ ### CLI Reference
119
+
120
+ ```sh
121
+ nostrwatch-kuma [options]
122
+
123
+ Options:
124
+ --relay <url> Relay WebSocket URL (or RELAY_URL env var)
125
+ --push-url <url> Uptime Kuma push URL (or KUMA_PUSH_URL env var)
126
+ --checks <list> Comma-separated: open,read[,write] (default: open,read)
127
+ --once Run once and exit (or KUMA_ONCE=true)
128
+ --interval <ms> Loop interval in milliseconds (default: 60000)
129
+ --log-level <level> Nocap log level: debug, info, warn (or NOCAP_LOG_LEVEL)
130
+ --write-sample-json <json> JSON event for write check (or NOCAP_WRITE_SAMPLE_JSON)
131
+ ```
132
+
133
+ Environment variables: `RELAY_URL`, `KUMA_PUSH_URL`, `CHECKS`, `KUMA_ONCE`, `KUMA_INTERVAL_MS`, `NOCAP_LOG_LEVEL`, `NOCAP_WRITE_SAMPLE_JSON`.
134
+
135
+ ## Known Limitations
136
+
137
+ No known limitations at this time.
138
+
139
+ ## Agent Skills
140
+
141
+ No agent skills defined yet for this package.
142
+
143
+ ## Related Packages
144
+
145
+ - [`@nostrwatch/nocap`](../nocap/README.md) — relay check engine that `kuma` uses internally for open/read/write checks
146
+ - [`@nostrwatch/nocap-websocket-adapter-default`](../websocket/README.md) — default WebSocket adapter used by `nocap`
147
+
148
+ ## License
149
+
150
+ [MIT](../../LICENSE)
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,78 @@
1
+ #!/usr/bin/env node
2
+ import { runOnce, runForever } from './index.js';
3
+ function parseArgs(argv) {
4
+ const args = {};
5
+ for (let i = 0; i < argv.length; i++) {
6
+ const a = argv[i];
7
+ if (!a.startsWith('--'))
8
+ continue;
9
+ const key = a.slice(2);
10
+ const next = argv[i + 1];
11
+ if (next && !next.startsWith('--')) {
12
+ args[key] = next;
13
+ i++;
14
+ }
15
+ else {
16
+ args[key] = true;
17
+ }
18
+ }
19
+ return args;
20
+ }
21
+ function asChecks(value) {
22
+ if (!value || typeof value !== 'string')
23
+ return undefined;
24
+ return value.split(',').map(s => s.trim()).filter(Boolean);
25
+ }
26
+ function asNumber(value) {
27
+ if (!value || typeof value !== 'string')
28
+ return undefined;
29
+ const n = Number(value);
30
+ return Number.isFinite(n) ? n : undefined;
31
+ }
32
+ async function main() {
33
+ const argv = process.argv.slice(2);
34
+ const a = parseArgs(argv);
35
+ const relayUrl = a.relay || (process.env.RELAY_URL ?? '');
36
+ const pushUrl = a['push-url'] || (process.env.KUMA_PUSH_URL ?? '');
37
+ const checks = asChecks(a.checks) || (process.env.CHECKS ? asChecks(process.env.CHECKS) : undefined);
38
+ const includeWrite = a.write === true || process.env.CHECK_WRITE === 'true';
39
+ const once = a.once === true || process.env.KUMA_ONCE === 'true';
40
+ const intervalMs = asNumber(a.interval) ?? (process.env.KUMA_INTERVAL_MS ? Number(process.env.KUMA_INTERVAL_MS) : undefined);
41
+ const logLevel = a['log-level'] || process.env.NOCAP_LOG_LEVEL;
42
+ if (!relayUrl || !pushUrl) {
43
+ console.error('Usage: nostrwatch-kuma --relay <wss://relay> --push-url <https://kuma/api/push/<key>> [--checks open,read[,write]] [--once] [--interval 60000] [--log-level debug] [--write-sample-json <json>]');
44
+ process.exit(2);
45
+ }
46
+ const opts = {
47
+ relayUrl,
48
+ pushUrl,
49
+ checks: checks ?? (includeWrite ? ['open', 'read', 'write'] : ['open', 'read']),
50
+ requiredChecks: undefined, // default = checks
51
+ nocap: {
52
+ logLevel: logLevel || 'info',
53
+ },
54
+ headers: true,
55
+ once,
56
+ intervalMs: intervalMs ?? 60000,
57
+ };
58
+ const writeSample = a['write-sample-json'] || process.env.NOCAP_WRITE_SAMPLE_JSON;
59
+ if (writeSample) {
60
+ try {
61
+ opts.nocap = opts.nocap || {};
62
+ opts.nocap.event_sample = JSON.parse(writeSample);
63
+ }
64
+ catch (e) {
65
+ console.warn('Ignoring invalid --write-sample-json (must be valid JSON)');
66
+ }
67
+ }
68
+ if (opts.once) {
69
+ await runOnce(opts);
70
+ }
71
+ else {
72
+ await runForever(opts);
73
+ }
74
+ }
75
+ main().catch(err => {
76
+ console.error(err);
77
+ process.exit(1);
78
+ });
@@ -0,0 +1,18 @@
1
+ import { KumaMonitorOptions, CheckKey, CheckResultSummary, KumaPushPayload, PingStrategy } from './types.js';
2
+ export declare function normalizeChecks(input?: CheckKey[]): CheckKey[];
3
+ export declare function aggregatePing(durations: Partial<Record<CheckKey, number>>, strategy?: PingStrategy): number;
4
+ export declare function summarizeResult(result: any, checks: CheckKey[], requiredChecks?: CheckKey[]): CheckResultSummary;
5
+ export declare function runChecks(options: KumaMonitorOptions): Promise<CheckResultSummary>;
6
+ export declare function buildKumaPayload(summary: CheckResultSummary, strategy?: PingStrategy): KumaPushPayload;
7
+ export declare function appendQuery(url: string, params: Record<string, string | number | undefined>): string;
8
+ export declare function pushToKuma(pushUrl: string, payload: KumaPushPayload): Promise<void>;
9
+ export declare function runOnce(options: KumaMonitorOptions): Promise<void>;
10
+ export declare function runForever(options: KumaMonitorOptions): Promise<() => void>;
11
+ declare const _default: {
12
+ runChecks: typeof runChecks;
13
+ runOnce: typeof runOnce;
14
+ runForever: typeof runForever;
15
+ buildKumaPayload: typeof buildKumaPayload;
16
+ pushToKuma: typeof pushToKuma;
17
+ };
18
+ export default _default;
package/dist/index.js ADDED
@@ -0,0 +1,143 @@
1
+ import Nocap from '@nostrwatch/nocap';
2
+ import WebsocketAdapterDefault from '@nostrwatch/nocap-websocket-adapter-default';
3
+ // Simple, dependency-free URL fetch wrapper supporting Node >=18 and Deno
4
+ async function httpGet(url) {
5
+ // Global fetch in Node >= 18 and Deno
6
+ if (typeof fetch === 'function')
7
+ return fetch(url);
8
+ // Lazy import fallback if needed (should not usually happen in this monorepo)
9
+ const { default: nodeFetch } = await import('node-fetch');
10
+ return nodeFetch(url);
11
+ }
12
+ export function normalizeChecks(input) {
13
+ if (!input || input.length === 0)
14
+ return ['open', 'read'];
15
+ // Remove duplicates and keep order
16
+ const seen = new Set();
17
+ const out = [];
18
+ for (const k of input) {
19
+ if (!seen.has(k)) {
20
+ seen.add(k);
21
+ out.push(k);
22
+ }
23
+ }
24
+ return out;
25
+ }
26
+ export function aggregatePing(durations, strategy = 'sum') {
27
+ const vals = Object.values(durations).filter((v) => typeof v === 'number' && v >= 0);
28
+ if (vals.length === 0)
29
+ return -1;
30
+ if (strategy === 'max')
31
+ return Math.max(...vals);
32
+ return vals.reduce((a, b) => a + b, 0);
33
+ }
34
+ export function summarizeResult(result, checks, requiredChecks) {
35
+ const durations = {};
36
+ const failing = [];
37
+ for (const k of checks) {
38
+ const data = (result?.[k]?.data ?? result?.[k]);
39
+ const dkey = `${k}_duration`;
40
+ const dur = typeof result?.[dkey] === 'number' ? result[dkey] : result?.[k]?.duration;
41
+ if (typeof dur === 'number')
42
+ durations[k] = dur;
43
+ if (data !== true)
44
+ failing.push(k);
45
+ }
46
+ const req = new Set((requiredChecks && requiredChecks.length ? requiredChecks : checks));
47
+ const requiredFailed = failing.filter(f => req.has(f));
48
+ const ok = requiredFailed.length === 0;
49
+ return { ok, failing: requiredFailed, result, durations };
50
+ }
51
+ export async function runChecks(options) {
52
+ const checks = normalizeChecks(options.checks);
53
+ const required = normalizeChecks(options.requiredChecks ?? checks);
54
+ const nocapCfg = options.nocap ?? {};
55
+ if (!nocapCfg.checked_by)
56
+ nocapCfg.checked_by = '@nostrwatch/kuma';
57
+ const nc = new Nocap(options.relayUrl, nocapCfg);
58
+ await nc.useAdapter(WebsocketAdapterDefault);
59
+ // nocap always terminates websocket unless configured otherwise
60
+ const headers = options.headers ?? true;
61
+ const raw = await nc.check(checks, headers);
62
+ return summarizeResult(raw, checks, required);
63
+ }
64
+ export function buildKumaPayload(summary, strategy = 'sum') {
65
+ const status = summary.ok ? 'up' : 'down';
66
+ const ping = aggregatePing(summary.durations, strategy);
67
+ let msg;
68
+ if (summary.ok) {
69
+ // show durations for visibility
70
+ const parts = Object.entries(summary.durations)
71
+ .map(([k, v]) => `${k}=${v}ms`)
72
+ .join(', ');
73
+ msg = parts ? `OK (${parts})` : 'OK';
74
+ }
75
+ else {
76
+ msg = `Fail: ${summary.failing.join(', ')}`;
77
+ }
78
+ const payload = { status, msg };
79
+ if (ping >= 0)
80
+ payload.ping = ping;
81
+ return payload;
82
+ }
83
+ export function appendQuery(url, params) {
84
+ const u = new URL(url);
85
+ for (const [k, v] of Object.entries(params)) {
86
+ if (v === undefined)
87
+ continue;
88
+ u.searchParams.set(k, String(v));
89
+ }
90
+ return u.toString();
91
+ }
92
+ export async function pushToKuma(pushUrl, payload) {
93
+ const finalUrl = appendQuery(pushUrl, {
94
+ status: payload.status,
95
+ msg: payload.msg ?? '',
96
+ ping: payload.ping ?? undefined,
97
+ });
98
+ const res = await httpGet(finalUrl);
99
+ if (!res.ok) {
100
+ const text = await res.text().catch(() => '');
101
+ throw new Error(`Kuma push failed: ${res.status} ${res.statusText} ${text}`);
102
+ }
103
+ }
104
+ export async function runOnce(options) {
105
+ const summary = await runChecks(options);
106
+ const payload = buildKumaPayload(summary, options.pingStrategy ?? 'sum');
107
+ await pushToKuma(options.pushUrl, payload);
108
+ }
109
+ export async function runForever(options) {
110
+ const interval = options.intervalMs ?? 60000; // default 60s
111
+ let stopped = false;
112
+ const tick = async () => {
113
+ if (stopped)
114
+ return;
115
+ try {
116
+ await runOnce(options);
117
+ }
118
+ catch (err) {
119
+ // As a safety net, signal down push if check or push threw synchronously
120
+ try {
121
+ const msg = err instanceof Error ? err.message : String(err);
122
+ await pushToKuma(options.pushUrl, { status: 'down', msg });
123
+ }
124
+ catch { }
125
+ }
126
+ finally {
127
+ if (!stopped)
128
+ setTimeout(tick, interval);
129
+ }
130
+ };
131
+ // Prime immediately
132
+ void tick();
133
+ return () => {
134
+ stopped = true;
135
+ };
136
+ }
137
+ export default {
138
+ runChecks,
139
+ runOnce,
140
+ runForever,
141
+ buildKumaPayload,
142
+ pushToKuma,
143
+ };
@@ -0,0 +1,42 @@
1
+ export type CheckKey = 'open' | 'read' | 'write';
2
+ export type PingStrategy = 'sum' | 'max';
3
+ export interface KumaPushPayload {
4
+ status: 'up' | 'down' | 'paused';
5
+ msg?: string;
6
+ ping?: number;
7
+ }
8
+ export interface KumaMonitorOptions {
9
+ relayUrl: string;
10
+ pushUrl: string;
11
+ checks?: CheckKey[];
12
+ requiredChecks?: CheckKey[];
13
+ pingStrategy?: PingStrategy;
14
+ nocap?: Partial<{
15
+ logLevel: string;
16
+ checked_by: string;
17
+ timeout: Partial<{
18
+ open: number;
19
+ read: number;
20
+ write: number;
21
+ }>;
22
+ tooManyEventsLimit: number;
23
+ autoDepsIgnoredInResult: boolean;
24
+ removeFromResult: string[];
25
+ failAllChecksOnConnectFailure: boolean;
26
+ rejectOnConnectFailure: boolean;
27
+ websocketAlwaysTerminate: boolean;
28
+ event_sample: any;
29
+ adapterOptions: Partial<{
30
+ websocket: Record<string, any>;
31
+ }>;
32
+ }>;
33
+ headers?: boolean;
34
+ once?: boolean;
35
+ intervalMs?: number;
36
+ }
37
+ export interface CheckResultSummary {
38
+ ok: boolean;
39
+ failing: CheckKey[];
40
+ result: any;
41
+ durations: Partial<Record<CheckKey, number>>;
42
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "@nostrwatch/kuma",
3
+ "version": "0.1.0",
4
+ "description": "Uptime Kuma PUSH monitor for Nostr relays using @nostrwatch/nocap (websocket adapter).",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "import": "./dist/index.js",
11
+ "types": "./dist/index.d.ts"
12
+ },
13
+ "./cli": {
14
+ "import": "./dist/cli.js",
15
+ "types": "./dist/cli.d.ts"
16
+ }
17
+ },
18
+ "bin": {
19
+ "nostrwatch-kuma": "dist/cli.js"
20
+ },
21
+ "dependencies": {
22
+ "@nostrwatch/nocap": "^0.9.1",
23
+ "@nostrwatch/nocap-websocket-adapter-default": "^1.4.0"
24
+ },
25
+ "devDependencies": {
26
+ "@types/node": "^20.17.30",
27
+ "node-fetch": "^3.3.2",
28
+ "typescript": "^5.6.3"
29
+ },
30
+ "scripts": {
31
+ "build": "tsc -p tsconfig.json",
32
+ "dev": "tsc -w -p tsconfig.json",
33
+ "bun:compile": "bun build --compile ./src/cli.ts --outfile ./dist/nostrwatch-kuma",
34
+ "build:bin:bun": "pnpm build && pnpm bun:compile"
35
+ }
36
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,84 @@
1
+ #!/usr/bin/env node
2
+ import { runOnce, runForever } from './index.js';
3
+ import type { CheckKey, KumaMonitorOptions } from './types.js';
4
+
5
+ function parseArgs(argv: string[]): Record<string, string | boolean> {
6
+ const args: Record<string, string | boolean> = {};
7
+ for (let i = 0; i < argv.length; i++) {
8
+ const a = argv[i];
9
+ if (!a.startsWith('--')) continue;
10
+ const key = a.slice(2);
11
+ const next = argv[i + 1];
12
+ if (next && !next.startsWith('--')) {
13
+ args[key] = next;
14
+ i++;
15
+ } else {
16
+ args[key] = true;
17
+ }
18
+ }
19
+ return args;
20
+ }
21
+
22
+ function asChecks(value?: string | boolean): CheckKey[] | undefined {
23
+ if (!value || typeof value !== 'string') return undefined;
24
+ return value.split(',').map(s => s.trim()).filter(Boolean) as CheckKey[];
25
+ }
26
+
27
+ function asNumber(value?: string | boolean): number | undefined {
28
+ if (!value || typeof value !== 'string') return undefined;
29
+ const n = Number(value);
30
+ return Number.isFinite(n) ? n : undefined;
31
+ }
32
+
33
+ async function main() {
34
+ const argv = process.argv.slice(2);
35
+ const a = parseArgs(argv);
36
+
37
+ const relayUrl = (a.relay as string) || (process.env.RELAY_URL ?? '');
38
+ const pushUrl = (a['push-url'] as string) || (process.env.KUMA_PUSH_URL ?? '');
39
+ const checks = asChecks(a.checks) || (process.env.CHECKS ? asChecks(process.env.CHECKS) : undefined);
40
+ const includeWrite = a.write === true || process.env.CHECK_WRITE === 'true';
41
+ const once = a.once === true || process.env.KUMA_ONCE === 'true';
42
+ const intervalMs = asNumber(a.interval) ?? (process.env.KUMA_INTERVAL_MS ? Number(process.env.KUMA_INTERVAL_MS) : undefined);
43
+ const logLevel = (a['log-level'] as string) || process.env.NOCAP_LOG_LEVEL;
44
+
45
+ if (!relayUrl || !pushUrl) {
46
+ console.error('Usage: nostrwatch-kuma --relay <wss://relay> --push-url <https://kuma/api/push/<key>> [--checks open,read[,write]] [--once] [--interval 60000] [--log-level debug] [--write-sample-json <json>]');
47
+ process.exit(2);
48
+ }
49
+
50
+ const opts: KumaMonitorOptions = {
51
+ relayUrl,
52
+ pushUrl,
53
+ checks: checks ?? (includeWrite ? ['open', 'read', 'write'] : ['open', 'read']),
54
+ requiredChecks: undefined, // default = checks
55
+ nocap: {
56
+ logLevel: logLevel || 'info',
57
+ },
58
+ headers: true,
59
+ once,
60
+ intervalMs: intervalMs ?? 60000,
61
+ };
62
+
63
+ const writeSample = (a['write-sample-json'] as string) || process.env.NOCAP_WRITE_SAMPLE_JSON;
64
+ if (writeSample) {
65
+ try {
66
+ opts.nocap = opts.nocap || {};
67
+ opts.nocap.event_sample = JSON.parse(writeSample);
68
+ } catch (e) {
69
+ console.warn('Ignoring invalid --write-sample-json (must be valid JSON)');
70
+ }
71
+ }
72
+
73
+ if (opts.once) {
74
+ await runOnce(opts);
75
+ } else {
76
+ await runForever(opts);
77
+ }
78
+ }
79
+
80
+ main().catch(err => {
81
+ console.error(err);
82
+ process.exit(1);
83
+ });
84
+
package/src/index.ts ADDED
@@ -0,0 +1,149 @@
1
+ import Nocap from '@nostrwatch/nocap';
2
+ import WebsocketAdapterDefault from '@nostrwatch/nocap-websocket-adapter-default';
3
+ import { KumaMonitorOptions, CheckKey, CheckResultSummary, KumaPushPayload, PingStrategy } from './types.js';
4
+
5
+ // Simple, dependency-free URL fetch wrapper supporting Node >=18 and Deno
6
+ async function httpGet(url: string): Promise<Response> {
7
+ // Global fetch in Node >= 18 and Deno
8
+ if (typeof fetch === 'function') return fetch(url);
9
+ // Lazy import fallback if needed (should not usually happen in this monorepo)
10
+ const { default: nodeFetch } = await import('node-fetch');
11
+ return nodeFetch(url) as unknown as Response;
12
+ }
13
+
14
+ export function normalizeChecks(input?: CheckKey[]): CheckKey[] {
15
+ if (!input || input.length === 0) return ['open', 'read'];
16
+ // Remove duplicates and keep order
17
+ const seen = new Set<CheckKey>();
18
+ const out: CheckKey[] = [];
19
+ for (const k of input) {
20
+ if (!seen.has(k)) {
21
+ seen.add(k);
22
+ out.push(k);
23
+ }
24
+ }
25
+ return out;
26
+ }
27
+
28
+ export function aggregatePing(durations: Partial<Record<CheckKey, number>>, strategy: PingStrategy = 'sum'): number {
29
+ const vals = Object.values(durations).filter((v): v is number => typeof v === 'number' && v >= 0);
30
+ if (vals.length === 0) return -1;
31
+ if (strategy === 'max') return Math.max(...vals);
32
+ return vals.reduce((a, b) => a + b, 0);
33
+ }
34
+
35
+ export function summarizeResult(result: any, checks: CheckKey[], requiredChecks?: CheckKey[]): CheckResultSummary {
36
+ const durations: Partial<Record<CheckKey, number>> = {};
37
+ const failing: CheckKey[] = [];
38
+ for (const k of checks) {
39
+ const data = (result?.[k]?.data ?? result?.[k]);
40
+ const dkey = `${k}_duration` as const;
41
+ const dur = typeof result?.[dkey] === 'number' ? result[dkey] : result?.[k]?.duration;
42
+ if (typeof dur === 'number') durations[k] = dur;
43
+ if (data !== true) failing.push(k);
44
+ }
45
+ const req = new Set<CheckKey>((requiredChecks && requiredChecks.length ? requiredChecks : checks));
46
+ const requiredFailed = failing.filter(f => req.has(f));
47
+ const ok = requiredFailed.length === 0;
48
+ return { ok, failing: requiredFailed, result, durations };
49
+ }
50
+
51
+ export async function runChecks(options: KumaMonitorOptions): Promise<CheckResultSummary> {
52
+ const checks = normalizeChecks(options.checks);
53
+ const required = normalizeChecks(options.requiredChecks ?? checks);
54
+
55
+ const nocapCfg: any = options.nocap ?? {};
56
+ if (!nocapCfg.checked_by) nocapCfg.checked_by = '@nostrwatch/kuma';
57
+
58
+ const nc = new Nocap(options.relayUrl, nocapCfg);
59
+ await nc.useAdapter(WebsocketAdapterDefault);
60
+
61
+ // nocap always terminates websocket unless configured otherwise
62
+ const headers = options.headers ?? true;
63
+ const raw = await nc.check(checks, headers);
64
+
65
+ return summarizeResult(raw, checks, required);
66
+ }
67
+
68
+ export function buildKumaPayload(summary: CheckResultSummary, strategy: PingStrategy = 'sum'): KumaPushPayload {
69
+ const status: KumaPushPayload['status'] = summary.ok ? 'up' : 'down';
70
+ const ping = aggregatePing(summary.durations, strategy);
71
+
72
+ let msg: string;
73
+ if (summary.ok) {
74
+ // show durations for visibility
75
+ const parts = Object.entries(summary.durations)
76
+ .map(([k, v]) => `${k}=${v}ms`)
77
+ .join(', ');
78
+ msg = parts ? `OK (${parts})` : 'OK';
79
+ } else {
80
+ msg = `Fail: ${summary.failing.join(', ')}`;
81
+ }
82
+
83
+ const payload: KumaPushPayload = { status, msg };
84
+ if (ping >= 0) payload.ping = ping;
85
+ return payload;
86
+ }
87
+
88
+ export function appendQuery(url: string, params: Record<string, string | number | undefined>): string {
89
+ const u = new URL(url);
90
+ for (const [k, v] of Object.entries(params)) {
91
+ if (v === undefined) continue;
92
+ u.searchParams.set(k, String(v));
93
+ }
94
+ return u.toString();
95
+ }
96
+
97
+ export async function pushToKuma(pushUrl: string, payload: KumaPushPayload): Promise<void> {
98
+ const finalUrl = appendQuery(pushUrl, {
99
+ status: payload.status,
100
+ msg: payload.msg ?? '',
101
+ ping: payload.ping ?? undefined,
102
+ });
103
+ const res = await httpGet(finalUrl);
104
+ if (!res.ok) {
105
+ const text = await res.text().catch(() => '');
106
+ throw new Error(`Kuma push failed: ${res.status} ${res.statusText} ${text}`);
107
+ }
108
+ }
109
+
110
+ export async function runOnce(options: KumaMonitorOptions): Promise<void> {
111
+ const summary = await runChecks(options);
112
+ const payload = buildKumaPayload(summary, options.pingStrategy ?? 'sum');
113
+ await pushToKuma(options.pushUrl, payload);
114
+ }
115
+
116
+ export async function runForever(options: KumaMonitorOptions): Promise<() => void> {
117
+ const interval = options.intervalMs ?? 60000; // default 60s
118
+ let stopped = false;
119
+
120
+ const tick = async () => {
121
+ if (stopped) return;
122
+ try {
123
+ await runOnce(options);
124
+ } catch (err) {
125
+ // As a safety net, signal down push if check or push threw synchronously
126
+ try {
127
+ const msg = err instanceof Error ? err.message : String(err);
128
+ await pushToKuma(options.pushUrl, { status: 'down', msg });
129
+ } catch {}
130
+ } finally {
131
+ if (!stopped) setTimeout(tick, interval);
132
+ }
133
+ };
134
+
135
+ // Prime immediately
136
+ void tick();
137
+
138
+ return () => {
139
+ stopped = true;
140
+ };
141
+ }
142
+
143
+ export default {
144
+ runChecks,
145
+ runOnce,
146
+ runForever,
147
+ buildKumaPayload,
148
+ pushToKuma,
149
+ };
package/src/types.ts ADDED
@@ -0,0 +1,51 @@
1
+ export type CheckKey = 'open' | 'read' | 'write';
2
+
3
+ export type PingStrategy = 'sum' | 'max';
4
+
5
+ export interface KumaPushPayload {
6
+ status: 'up' | 'down' | 'paused';
7
+ msg?: string;
8
+ ping?: number;
9
+ }
10
+
11
+ export interface KumaMonitorOptions {
12
+ relayUrl: string;
13
+ pushUrl: string; // Full Uptime Kuma push URL
14
+ checks?: CheckKey[]; // default: ['open','read']
15
+ requiredChecks?: CheckKey[]; // default: same as checks
16
+ pingStrategy?: PingStrategy;
17
+
18
+ // nocap passthrough config
19
+ nocap?: Partial<{
20
+ logLevel: string;
21
+ checked_by: string;
22
+ timeout: Partial<{
23
+ open: number;
24
+ read: number;
25
+ write: number;
26
+ }>;
27
+ tooManyEventsLimit: number;
28
+ autoDepsIgnoredInResult: boolean;
29
+ removeFromResult: string[];
30
+ failAllChecksOnConnectFailure: boolean;
31
+ rejectOnConnectFailure: boolean;
32
+ websocketAlwaysTerminate: boolean;
33
+ event_sample: any; // NostrEvent shape; permissive typing here
34
+ adapterOptions: Partial<{
35
+ websocket: Record<string, any>;
36
+ }>;
37
+ }>;
38
+
39
+ // behavior
40
+ headers?: boolean; // include headers in nocap result
41
+ once?: boolean; // run once then exit
42
+ intervalMs?: number; // when not once, run every N ms
43
+ }
44
+
45
+ export interface CheckResultSummary {
46
+ ok: boolean;
47
+ failing: CheckKey[];
48
+ result: any;
49
+ durations: Partial<Record<CheckKey, number>>;
50
+ }
51
+
package/tsconfig.json ADDED
@@ -0,0 +1,17 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "NodeNext",
5
+ "moduleResolution": "NodeNext",
6
+ "outDir": "dist",
7
+ "declaration": true,
8
+ "emitDeclarationOnly": false,
9
+ "strict": true,
10
+ "esModuleInterop": true,
11
+ "skipLibCheck": true,
12
+ "resolveJsonModule": true,
13
+ "forceConsistentCasingInFileNames": true
14
+ },
15
+ "include": ["src/**/*"],
16
+ "exclude": ["node_modules", "dist"]
17
+ }