ntfy-bridge 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/.gitattributes +3 -0
- package/README.md +87 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +101 -0
- package/dist/lib.d.ts +27 -0
- package/dist/lib.js +45 -0
- package/dist/lib.test.d.ts +1 -0
- package/dist/lib.test.js +88 -0
- package/package.json +41 -0
- package/src/index.ts +117 -0
- package/src/lib.test.ts +112 -0
- package/src/lib.ts +69 -0
- package/tsconfig.json +16 -0
package/.gitattributes
ADDED
package/README.md
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# ntfy-bridge
|
|
2
|
+
|
|
3
|
+
Local bridge from [ntfy.sh](https://ntfy.sh) to localhost.
|
|
4
|
+
|
|
5
|
+
Subscribe to ntfy.sh topics and forward messages to your local endpoint. Perfect for AI agents that need real-time notifications without exposing public webhooks.
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
ntfy.sh → ntfy-bridge (local) → localhost:8080
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npm install -g ntfy-bridge
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Or run directly with npx:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
npx ntfy-bridge --topic alerts --forward http://localhost:8080/hooks
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Usage
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
ntfy-bridge --topic <topic> --forward <url>
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
**Options:**
|
|
30
|
+
- `-t, --topic <topic>` — ntfy.sh topic to subscribe (can repeat)
|
|
31
|
+
- `-f, --forward <url>` — Local endpoint to forward messages to
|
|
32
|
+
|
|
33
|
+
**Examples:**
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
# Single topic
|
|
37
|
+
ntfy-bridge -t alerts -f http://localhost:8080/hooks
|
|
38
|
+
|
|
39
|
+
# Multiple topics
|
|
40
|
+
ntfy-bridge -t alerts -t news -f http://localhost:18789/hooks/ntfy
|
|
41
|
+
|
|
42
|
+
# Full URL
|
|
43
|
+
ntfy-bridge -t ntfy.sh/my-secret-topic -f http://localhost:8080/hooks
|
|
44
|
+
|
|
45
|
+
# Self-hosted ntfy
|
|
46
|
+
ntfy-bridge -t ntfy.example.com/alerts -f http://localhost:8080/hooks
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Payload Format
|
|
50
|
+
|
|
51
|
+
Messages are normalized and forwarded as JSON:
|
|
52
|
+
|
|
53
|
+
```json
|
|
54
|
+
{
|
|
55
|
+
"source": "ntfy",
|
|
56
|
+
"topic": "alerts",
|
|
57
|
+
"id": "abc123",
|
|
58
|
+
"time": 1707379800,
|
|
59
|
+
"title": "Alert",
|
|
60
|
+
"message": "Something happened",
|
|
61
|
+
"tags": ["warning"],
|
|
62
|
+
"priority": 3,
|
|
63
|
+
"raw": { ... }
|
|
64
|
+
}
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Use Cases
|
|
68
|
+
|
|
69
|
+
- **AI Agents** — Feed real-time signals to OpenClaw, AutoGPT, etc.
|
|
70
|
+
- **Automation** — Trigger local scripts from ntfy notifications
|
|
71
|
+
- **Development** — Test webhook integrations locally
|
|
72
|
+
|
|
73
|
+
## How It Works
|
|
74
|
+
|
|
75
|
+
1. ntfy-bridge connects to ntfy.sh topics via SSE (outbound only)
|
|
76
|
+
2. When a message arrives, it forwards to your local endpoint
|
|
77
|
+
3. Your app receives the webhook on localhost
|
|
78
|
+
|
|
79
|
+
No public endpoint needed. No firewall config. Just run locally.
|
|
80
|
+
|
|
81
|
+
## Related
|
|
82
|
+
|
|
83
|
+
For a managed signal marketplace with routing, billing, and delivery guarantees, see [Herald](https://github.com/starksama/herald).
|
|
84
|
+
|
|
85
|
+
## License
|
|
86
|
+
|
|
87
|
+
MIT
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import EventSource from "eventsource";
|
|
3
|
+
import { normalizeTopicUrl, createPayload } from "./lib.js";
|
|
4
|
+
function parseArgs() {
|
|
5
|
+
const args = process.argv.slice(2);
|
|
6
|
+
const topics = [];
|
|
7
|
+
let forward = "";
|
|
8
|
+
for (let i = 0; i < args.length; i++) {
|
|
9
|
+
const arg = args[i];
|
|
10
|
+
if (arg === "--topic" || arg === "-t") {
|
|
11
|
+
topics.push(args[++i]);
|
|
12
|
+
}
|
|
13
|
+
else if (arg === "--forward" || arg === "-f") {
|
|
14
|
+
forward = args[++i];
|
|
15
|
+
}
|
|
16
|
+
else if (arg === "--help" || arg === "-h") {
|
|
17
|
+
console.log(`
|
|
18
|
+
ntfy-bridge - Local bridge from ntfy.sh to localhost
|
|
19
|
+
|
|
20
|
+
Usage:
|
|
21
|
+
ntfy-bridge --topic <topic> --forward <url>
|
|
22
|
+
|
|
23
|
+
Options:
|
|
24
|
+
-t, --topic <topic> ntfy.sh topic to subscribe to (can be repeated)
|
|
25
|
+
-f, --forward <url> Local endpoint to forward messages to
|
|
26
|
+
-h, --help Show this help
|
|
27
|
+
|
|
28
|
+
Examples:
|
|
29
|
+
ntfy-bridge -t alerts -f http://localhost:8080/hooks
|
|
30
|
+
ntfy-bridge -t ntfy.sh/my-topic -t ntfy.sh/other -f http://localhost:18789/hooks/ntfy
|
|
31
|
+
`);
|
|
32
|
+
process.exit(0);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
if (topics.length === 0) {
|
|
36
|
+
console.error("Error: No topics specified. Use --topic <topic>");
|
|
37
|
+
process.exit(1);
|
|
38
|
+
}
|
|
39
|
+
if (!forward) {
|
|
40
|
+
console.error("Error: No forward URL specified. Use --forward <url>");
|
|
41
|
+
process.exit(1);
|
|
42
|
+
}
|
|
43
|
+
return { topics, forward };
|
|
44
|
+
}
|
|
45
|
+
async function forwardMessage(msg, forwardUrl) {
|
|
46
|
+
const payload = createPayload(msg);
|
|
47
|
+
try {
|
|
48
|
+
const response = await fetch(forwardUrl, {
|
|
49
|
+
method: "POST",
|
|
50
|
+
headers: { "Content-Type": "application/json" },
|
|
51
|
+
body: JSON.stringify(payload),
|
|
52
|
+
});
|
|
53
|
+
if (response.ok) {
|
|
54
|
+
console.log(`[${msg.topic}] Forwarded: ${msg.title || msg.message || msg.id}`);
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
console.warn(`[${msg.topic}] Forward returned ${response.status}`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
catch (error) {
|
|
61
|
+
console.error(`[${msg.topic}] Forward failed:`, error);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
function subscribeToTopic(topic, forwardUrl) {
|
|
65
|
+
const url = normalizeTopicUrl(topic);
|
|
66
|
+
console.log(`Subscribing to: ${url}`);
|
|
67
|
+
const es = new EventSource(url);
|
|
68
|
+
es.onopen = () => {
|
|
69
|
+
console.log(`Connected to ${topic}`);
|
|
70
|
+
};
|
|
71
|
+
es.onmessage = async (event) => {
|
|
72
|
+
try {
|
|
73
|
+
const msg = JSON.parse(event.data);
|
|
74
|
+
if (msg.event === "message" || !msg.event) {
|
|
75
|
+
await forwardMessage(msg, forwardUrl);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
console.warn(`Failed to parse message:`, error);
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
es.onerror = (error) => {
|
|
83
|
+
console.error(`SSE error on ${topic}:`, error);
|
|
84
|
+
// EventSource auto-reconnects
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
function main() {
|
|
88
|
+
const { topics, forward } = parseArgs();
|
|
89
|
+
console.log("ntfy-bridge starting");
|
|
90
|
+
console.log(`Forwarding to: ${forward}`);
|
|
91
|
+
console.log(`Subscribing to ${topics.length} topic(s)`);
|
|
92
|
+
for (const topic of topics) {
|
|
93
|
+
subscribeToTopic(topic, forward);
|
|
94
|
+
}
|
|
95
|
+
// Keep alive
|
|
96
|
+
process.on("SIGINT", () => {
|
|
97
|
+
console.log("\nShutting down...");
|
|
98
|
+
process.exit(0);
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
main();
|
package/dist/lib.d.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export interface NtfyMessage {
|
|
2
|
+
id: string;
|
|
3
|
+
time: number;
|
|
4
|
+
event?: string;
|
|
5
|
+
topic: string;
|
|
6
|
+
title?: string;
|
|
7
|
+
message?: string;
|
|
8
|
+
tags?: string[];
|
|
9
|
+
priority?: number;
|
|
10
|
+
}
|
|
11
|
+
export interface BridgePayload {
|
|
12
|
+
source: "ntfy";
|
|
13
|
+
topic: string;
|
|
14
|
+
id: string;
|
|
15
|
+
time: number;
|
|
16
|
+
title?: string;
|
|
17
|
+
message?: string;
|
|
18
|
+
tags: string[];
|
|
19
|
+
priority: number;
|
|
20
|
+
raw: NtfyMessage;
|
|
21
|
+
}
|
|
22
|
+
export declare function normalizeTopicUrl(topic: string): string;
|
|
23
|
+
export declare function createPayload(msg: NtfyMessage): BridgePayload;
|
|
24
|
+
export declare function parseArgs(argv: string[]): {
|
|
25
|
+
topics: string[];
|
|
26
|
+
forward: string;
|
|
27
|
+
} | null;
|
package/dist/lib.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
export function normalizeTopicUrl(topic) {
|
|
2
|
+
if (topic.startsWith("http")) {
|
|
3
|
+
return `${topic.replace(/\/$/, "")}/sse`;
|
|
4
|
+
}
|
|
5
|
+
else if (topic.includes("/")) {
|
|
6
|
+
return `https://${topic.replace(/\/$/, "")}/sse`;
|
|
7
|
+
}
|
|
8
|
+
else {
|
|
9
|
+
return `https://ntfy.sh/${topic}/sse`;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
export function createPayload(msg) {
|
|
13
|
+
return {
|
|
14
|
+
source: "ntfy",
|
|
15
|
+
topic: msg.topic,
|
|
16
|
+
id: msg.id,
|
|
17
|
+
time: msg.time,
|
|
18
|
+
title: msg.title,
|
|
19
|
+
message: msg.message,
|
|
20
|
+
tags: msg.tags || [],
|
|
21
|
+
priority: msg.priority || 3,
|
|
22
|
+
raw: msg,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
export function parseArgs(argv) {
|
|
26
|
+
const args = argv.slice(2);
|
|
27
|
+
const topics = [];
|
|
28
|
+
let forward = "";
|
|
29
|
+
for (let i = 0; i < args.length; i++) {
|
|
30
|
+
const arg = args[i];
|
|
31
|
+
if (arg === "--topic" || arg === "-t") {
|
|
32
|
+
topics.push(args[++i]);
|
|
33
|
+
}
|
|
34
|
+
else if (arg === "--forward" || arg === "-f") {
|
|
35
|
+
forward = args[++i];
|
|
36
|
+
}
|
|
37
|
+
else if (arg === "--help" || arg === "-h") {
|
|
38
|
+
return null; // Signal help requested
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
if (topics.length === 0 || !forward) {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
return { topics, forward };
|
|
45
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/lib.test.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { normalizeTopicUrl, createPayload, parseArgs } from "./lib.js";
|
|
3
|
+
describe("normalizeTopicUrl", () => {
|
|
4
|
+
it("handles full https URL", () => {
|
|
5
|
+
expect(normalizeTopicUrl("https://ntfy.sh/alerts")).toBe("https://ntfy.sh/alerts/sse");
|
|
6
|
+
});
|
|
7
|
+
it("handles full https URL with trailing slash", () => {
|
|
8
|
+
expect(normalizeTopicUrl("https://ntfy.sh/alerts/")).toBe("https://ntfy.sh/alerts/sse");
|
|
9
|
+
});
|
|
10
|
+
it("handles domain/topic format", () => {
|
|
11
|
+
expect(normalizeTopicUrl("ntfy.example.com/alerts")).toBe("https://ntfy.example.com/alerts/sse");
|
|
12
|
+
});
|
|
13
|
+
it("handles simple topic name (defaults to ntfy.sh)", () => {
|
|
14
|
+
expect(normalizeTopicUrl("alerts")).toBe("https://ntfy.sh/alerts/sse");
|
|
15
|
+
});
|
|
16
|
+
it("handles http URL", () => {
|
|
17
|
+
expect(normalizeTopicUrl("http://localhost:8080/test")).toBe("http://localhost:8080/test/sse");
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
describe("createPayload", () => {
|
|
21
|
+
it("creates payload with all fields", () => {
|
|
22
|
+
const msg = {
|
|
23
|
+
id: "abc123",
|
|
24
|
+
time: 1700000000,
|
|
25
|
+
event: "message",
|
|
26
|
+
topic: "alerts",
|
|
27
|
+
title: "Test Title",
|
|
28
|
+
message: "Test message",
|
|
29
|
+
tags: ["warning", "test"],
|
|
30
|
+
priority: 4,
|
|
31
|
+
};
|
|
32
|
+
const payload = createPayload(msg);
|
|
33
|
+
expect(payload.source).toBe("ntfy");
|
|
34
|
+
expect(payload.topic).toBe("alerts");
|
|
35
|
+
expect(payload.id).toBe("abc123");
|
|
36
|
+
expect(payload.time).toBe(1700000000);
|
|
37
|
+
expect(payload.title).toBe("Test Title");
|
|
38
|
+
expect(payload.message).toBe("Test message");
|
|
39
|
+
expect(payload.tags).toEqual(["warning", "test"]);
|
|
40
|
+
expect(payload.priority).toBe(4);
|
|
41
|
+
expect(payload.raw).toEqual(msg);
|
|
42
|
+
});
|
|
43
|
+
it("uses defaults for missing optional fields", () => {
|
|
44
|
+
const msg = {
|
|
45
|
+
id: "xyz",
|
|
46
|
+
time: 1700000000,
|
|
47
|
+
topic: "test",
|
|
48
|
+
};
|
|
49
|
+
const payload = createPayload(msg);
|
|
50
|
+
expect(payload.tags).toEqual([]);
|
|
51
|
+
expect(payload.priority).toBe(3);
|
|
52
|
+
expect(payload.title).toBeUndefined();
|
|
53
|
+
expect(payload.message).toBeUndefined();
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
describe("parseArgs", () => {
|
|
57
|
+
it("parses topic and forward", () => {
|
|
58
|
+
const result = parseArgs(["node", "script", "-t", "alerts", "-f", "http://localhost:8080"]);
|
|
59
|
+
expect(result).toEqual({
|
|
60
|
+
topics: ["alerts"],
|
|
61
|
+
forward: "http://localhost:8080",
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
it("parses multiple topics", () => {
|
|
65
|
+
const result = parseArgs([
|
|
66
|
+
"node", "script",
|
|
67
|
+
"--topic", "alerts",
|
|
68
|
+
"--topic", "news",
|
|
69
|
+
"--forward", "http://localhost:8080"
|
|
70
|
+
]);
|
|
71
|
+
expect(result).toEqual({
|
|
72
|
+
topics: ["alerts", "news"],
|
|
73
|
+
forward: "http://localhost:8080",
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
it("returns null for help flag", () => {
|
|
77
|
+
const result = parseArgs(["node", "script", "--help"]);
|
|
78
|
+
expect(result).toBeNull();
|
|
79
|
+
});
|
|
80
|
+
it("returns null if missing topics", () => {
|
|
81
|
+
const result = parseArgs(["node", "script", "-f", "http://localhost"]);
|
|
82
|
+
expect(result).toBeNull();
|
|
83
|
+
});
|
|
84
|
+
it("returns null if missing forward", () => {
|
|
85
|
+
const result = parseArgs(["node", "script", "-t", "alerts"]);
|
|
86
|
+
expect(result).toBeNull();
|
|
87
|
+
});
|
|
88
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "ntfy-bridge",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Local bridge from ntfy.sh to localhost",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"ntfy-bridge": "dist/index.js"
|
|
9
|
+
},
|
|
10
|
+
"keywords": [
|
|
11
|
+
"ntfy",
|
|
12
|
+
"notifications",
|
|
13
|
+
"webhook",
|
|
14
|
+
"bridge",
|
|
15
|
+
"localhost",
|
|
16
|
+
"ai-agents"
|
|
17
|
+
],
|
|
18
|
+
"author": "starksama",
|
|
19
|
+
"license": "MIT",
|
|
20
|
+
"repository": {
|
|
21
|
+
"type": "git",
|
|
22
|
+
"url": "git+https://github.com/starksama/ntfy-bridge.git"
|
|
23
|
+
},
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"eventsource": "^2.0.2"
|
|
26
|
+
},
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"@types/eventsource": "^1.1.15",
|
|
29
|
+
"@types/node": "^22.0.0",
|
|
30
|
+
"tsx": "^4.0.0",
|
|
31
|
+
"typescript": "^5.0.0",
|
|
32
|
+
"vitest": "^4.0.18"
|
|
33
|
+
},
|
|
34
|
+
"scripts": {
|
|
35
|
+
"build": "tsc",
|
|
36
|
+
"start": "node dist/index.js",
|
|
37
|
+
"dev": "tsx src/index.ts",
|
|
38
|
+
"test": "vitest run",
|
|
39
|
+
"test:watch": "vitest"
|
|
40
|
+
}
|
|
41
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import EventSource from "eventsource";
|
|
4
|
+
import { normalizeTopicUrl, createPayload, NtfyMessage } from "./lib.js";
|
|
5
|
+
|
|
6
|
+
function parseArgs(): { topics: string[]; forward: string } {
|
|
7
|
+
const args = process.argv.slice(2);
|
|
8
|
+
const topics: string[] = [];
|
|
9
|
+
let forward = "";
|
|
10
|
+
|
|
11
|
+
for (let i = 0; i < args.length; i++) {
|
|
12
|
+
const arg = args[i];
|
|
13
|
+
if (arg === "--topic" || arg === "-t") {
|
|
14
|
+
topics.push(args[++i]);
|
|
15
|
+
} else if (arg === "--forward" || arg === "-f") {
|
|
16
|
+
forward = args[++i];
|
|
17
|
+
} else if (arg === "--help" || arg === "-h") {
|
|
18
|
+
console.log(`
|
|
19
|
+
ntfy-bridge - Local bridge from ntfy.sh to localhost
|
|
20
|
+
|
|
21
|
+
Usage:
|
|
22
|
+
ntfy-bridge --topic <topic> --forward <url>
|
|
23
|
+
|
|
24
|
+
Options:
|
|
25
|
+
-t, --topic <topic> ntfy.sh topic to subscribe to (can be repeated)
|
|
26
|
+
-f, --forward <url> Local endpoint to forward messages to
|
|
27
|
+
-h, --help Show this help
|
|
28
|
+
|
|
29
|
+
Examples:
|
|
30
|
+
ntfy-bridge -t alerts -f http://localhost:8080/hooks
|
|
31
|
+
ntfy-bridge -t ntfy.sh/my-topic -t ntfy.sh/other -f http://localhost:18789/hooks/ntfy
|
|
32
|
+
`);
|
|
33
|
+
process.exit(0);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (topics.length === 0) {
|
|
38
|
+
console.error("Error: No topics specified. Use --topic <topic>");
|
|
39
|
+
process.exit(1);
|
|
40
|
+
}
|
|
41
|
+
if (!forward) {
|
|
42
|
+
console.error("Error: No forward URL specified. Use --forward <url>");
|
|
43
|
+
process.exit(1);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return { topics, forward };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function forwardMessage(
|
|
50
|
+
msg: NtfyMessage,
|
|
51
|
+
forwardUrl: string
|
|
52
|
+
): Promise<void> {
|
|
53
|
+
const payload = createPayload(msg);
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
const response = await fetch(forwardUrl, {
|
|
57
|
+
method: "POST",
|
|
58
|
+
headers: { "Content-Type": "application/json" },
|
|
59
|
+
body: JSON.stringify(payload),
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
if (response.ok) {
|
|
63
|
+
console.log(`[${msg.topic}] Forwarded: ${msg.title || msg.message || msg.id}`);
|
|
64
|
+
} else {
|
|
65
|
+
console.warn(`[${msg.topic}] Forward returned ${response.status}`);
|
|
66
|
+
}
|
|
67
|
+
} catch (error) {
|
|
68
|
+
console.error(`[${msg.topic}] Forward failed:`, error);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function subscribeToTopic(topic: string, forwardUrl: string): void {
|
|
73
|
+
const url = normalizeTopicUrl(topic);
|
|
74
|
+
console.log(`Subscribing to: ${url}`);
|
|
75
|
+
|
|
76
|
+
const es = new EventSource(url);
|
|
77
|
+
|
|
78
|
+
es.onopen = () => {
|
|
79
|
+
console.log(`Connected to ${topic}`);
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
es.onmessage = async (event) => {
|
|
83
|
+
try {
|
|
84
|
+
const msg: NtfyMessage = JSON.parse(event.data);
|
|
85
|
+
if (msg.event === "message" || !msg.event) {
|
|
86
|
+
await forwardMessage(msg, forwardUrl);
|
|
87
|
+
}
|
|
88
|
+
} catch (error) {
|
|
89
|
+
console.warn(`Failed to parse message:`, error);
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
es.onerror = (error) => {
|
|
94
|
+
console.error(`SSE error on ${topic}:`, error);
|
|
95
|
+
// EventSource auto-reconnects
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function main(): void {
|
|
100
|
+
const { topics, forward } = parseArgs();
|
|
101
|
+
|
|
102
|
+
console.log("ntfy-bridge starting");
|
|
103
|
+
console.log(`Forwarding to: ${forward}`);
|
|
104
|
+
console.log(`Subscribing to ${topics.length} topic(s)`);
|
|
105
|
+
|
|
106
|
+
for (const topic of topics) {
|
|
107
|
+
subscribeToTopic(topic, forward);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Keep alive
|
|
111
|
+
process.on("SIGINT", () => {
|
|
112
|
+
console.log("\nShutting down...");
|
|
113
|
+
process.exit(0);
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
main();
|
package/src/lib.test.ts
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { normalizeTopicUrl, createPayload, parseArgs, NtfyMessage } from "./lib.js";
|
|
3
|
+
|
|
4
|
+
describe("normalizeTopicUrl", () => {
|
|
5
|
+
it("handles full https URL", () => {
|
|
6
|
+
expect(normalizeTopicUrl("https://ntfy.sh/alerts")).toBe(
|
|
7
|
+
"https://ntfy.sh/alerts/sse"
|
|
8
|
+
);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it("handles full https URL with trailing slash", () => {
|
|
12
|
+
expect(normalizeTopicUrl("https://ntfy.sh/alerts/")).toBe(
|
|
13
|
+
"https://ntfy.sh/alerts/sse"
|
|
14
|
+
);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("handles domain/topic format", () => {
|
|
18
|
+
expect(normalizeTopicUrl("ntfy.example.com/alerts")).toBe(
|
|
19
|
+
"https://ntfy.example.com/alerts/sse"
|
|
20
|
+
);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("handles simple topic name (defaults to ntfy.sh)", () => {
|
|
24
|
+
expect(normalizeTopicUrl("alerts")).toBe("https://ntfy.sh/alerts/sse");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("handles http URL", () => {
|
|
28
|
+
expect(normalizeTopicUrl("http://localhost:8080/test")).toBe(
|
|
29
|
+
"http://localhost:8080/test/sse"
|
|
30
|
+
);
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe("createPayload", () => {
|
|
35
|
+
it("creates payload with all fields", () => {
|
|
36
|
+
const msg: NtfyMessage = {
|
|
37
|
+
id: "abc123",
|
|
38
|
+
time: 1700000000,
|
|
39
|
+
event: "message",
|
|
40
|
+
topic: "alerts",
|
|
41
|
+
title: "Test Title",
|
|
42
|
+
message: "Test message",
|
|
43
|
+
tags: ["warning", "test"],
|
|
44
|
+
priority: 4,
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const payload = createPayload(msg);
|
|
48
|
+
|
|
49
|
+
expect(payload.source).toBe("ntfy");
|
|
50
|
+
expect(payload.topic).toBe("alerts");
|
|
51
|
+
expect(payload.id).toBe("abc123");
|
|
52
|
+
expect(payload.time).toBe(1700000000);
|
|
53
|
+
expect(payload.title).toBe("Test Title");
|
|
54
|
+
expect(payload.message).toBe("Test message");
|
|
55
|
+
expect(payload.tags).toEqual(["warning", "test"]);
|
|
56
|
+
expect(payload.priority).toBe(4);
|
|
57
|
+
expect(payload.raw).toEqual(msg);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("uses defaults for missing optional fields", () => {
|
|
61
|
+
const msg: NtfyMessage = {
|
|
62
|
+
id: "xyz",
|
|
63
|
+
time: 1700000000,
|
|
64
|
+
topic: "test",
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const payload = createPayload(msg);
|
|
68
|
+
|
|
69
|
+
expect(payload.tags).toEqual([]);
|
|
70
|
+
expect(payload.priority).toBe(3);
|
|
71
|
+
expect(payload.title).toBeUndefined();
|
|
72
|
+
expect(payload.message).toBeUndefined();
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe("parseArgs", () => {
|
|
77
|
+
it("parses topic and forward", () => {
|
|
78
|
+
const result = parseArgs(["node", "script", "-t", "alerts", "-f", "http://localhost:8080"]);
|
|
79
|
+
expect(result).toEqual({
|
|
80
|
+
topics: ["alerts"],
|
|
81
|
+
forward: "http://localhost:8080",
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("parses multiple topics", () => {
|
|
86
|
+
const result = parseArgs([
|
|
87
|
+
"node", "script",
|
|
88
|
+
"--topic", "alerts",
|
|
89
|
+
"--topic", "news",
|
|
90
|
+
"--forward", "http://localhost:8080"
|
|
91
|
+
]);
|
|
92
|
+
expect(result).toEqual({
|
|
93
|
+
topics: ["alerts", "news"],
|
|
94
|
+
forward: "http://localhost:8080",
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("returns null for help flag", () => {
|
|
99
|
+
const result = parseArgs(["node", "script", "--help"]);
|
|
100
|
+
expect(result).toBeNull();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("returns null if missing topics", () => {
|
|
104
|
+
const result = parseArgs(["node", "script", "-f", "http://localhost"]);
|
|
105
|
+
expect(result).toBeNull();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("returns null if missing forward", () => {
|
|
109
|
+
const result = parseArgs(["node", "script", "-t", "alerts"]);
|
|
110
|
+
expect(result).toBeNull();
|
|
111
|
+
});
|
|
112
|
+
});
|
package/src/lib.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
export interface NtfyMessage {
|
|
2
|
+
id: string;
|
|
3
|
+
time: number;
|
|
4
|
+
event?: string;
|
|
5
|
+
topic: string;
|
|
6
|
+
title?: string;
|
|
7
|
+
message?: string;
|
|
8
|
+
tags?: string[];
|
|
9
|
+
priority?: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface BridgePayload {
|
|
13
|
+
source: "ntfy";
|
|
14
|
+
topic: string;
|
|
15
|
+
id: string;
|
|
16
|
+
time: number;
|
|
17
|
+
title?: string;
|
|
18
|
+
message?: string;
|
|
19
|
+
tags: string[];
|
|
20
|
+
priority: number;
|
|
21
|
+
raw: NtfyMessage;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function normalizeTopicUrl(topic: string): string {
|
|
25
|
+
if (topic.startsWith("http")) {
|
|
26
|
+
return `${topic.replace(/\/$/, "")}/sse`;
|
|
27
|
+
} else if (topic.includes("/")) {
|
|
28
|
+
return `https://${topic.replace(/\/$/, "")}/sse`;
|
|
29
|
+
} else {
|
|
30
|
+
return `https://ntfy.sh/${topic}/sse`;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function createPayload(msg: NtfyMessage): BridgePayload {
|
|
35
|
+
return {
|
|
36
|
+
source: "ntfy",
|
|
37
|
+
topic: msg.topic,
|
|
38
|
+
id: msg.id,
|
|
39
|
+
time: msg.time,
|
|
40
|
+
title: msg.title,
|
|
41
|
+
message: msg.message,
|
|
42
|
+
tags: msg.tags || [],
|
|
43
|
+
priority: msg.priority || 3,
|
|
44
|
+
raw: msg,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function parseArgs(argv: string[]): { topics: string[]; forward: string } | null {
|
|
49
|
+
const args = argv.slice(2);
|
|
50
|
+
const topics: string[] = [];
|
|
51
|
+
let forward = "";
|
|
52
|
+
|
|
53
|
+
for (let i = 0; i < args.length; i++) {
|
|
54
|
+
const arg = args[i];
|
|
55
|
+
if (arg === "--topic" || arg === "-t") {
|
|
56
|
+
topics.push(args[++i]);
|
|
57
|
+
} else if (arg === "--forward" || arg === "-f") {
|
|
58
|
+
forward = args[++i];
|
|
59
|
+
} else if (arg === "--help" || arg === "-h") {
|
|
60
|
+
return null; // Signal help requested
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (topics.length === 0 || !forward) {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return { topics, forward };
|
|
69
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "node",
|
|
6
|
+
"outDir": "dist",
|
|
7
|
+
"rootDir": "src",
|
|
8
|
+
"strict": true,
|
|
9
|
+
"esModuleInterop": true,
|
|
10
|
+
"skipLibCheck": true,
|
|
11
|
+
"forceConsistentCasingInFileNames": true,
|
|
12
|
+
"declaration": true
|
|
13
|
+
},
|
|
14
|
+
"include": ["src/**/*"],
|
|
15
|
+
"exclude": ["node_modules", "dist"]
|
|
16
|
+
}
|