palmier 0.6.0 → 0.6.2
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/.github/workflows/publish.yml +15 -2
- package/CLAUDE.md +2 -2
- package/DISCLAIMER.md +36 -0
- package/README.md +76 -87
- package/dist/agents/agent-instructions.md +1 -1
- package/dist/agents/agent.d.ts +2 -0
- package/dist/agents/agent.js +21 -0
- package/dist/agents/aider.d.ts +9 -0
- package/dist/agents/aider.js +32 -0
- package/dist/agents/cursor.d.ts +9 -0
- package/dist/agents/cursor.js +35 -0
- package/dist/agents/deepagents.d.ts +9 -0
- package/dist/agents/deepagents.js +35 -0
- package/dist/agents/droid.d.ts +9 -0
- package/dist/agents/droid.js +32 -0
- package/dist/agents/goose.d.ts +9 -0
- package/dist/agents/goose.js +32 -0
- package/dist/agents/opencode.d.ts +9 -0
- package/dist/agents/opencode.js +35 -0
- package/dist/agents/openhands.d.ts +9 -0
- package/dist/agents/openhands.js +35 -0
- package/dist/commands/pair.d.ts +1 -1
- package/dist/commands/pair.js +1 -1
- package/dist/commands/run.js +2 -2
- package/dist/pwa/apple-touch-icon.png +0 -0
- package/dist/pwa/assets/index-ByhOhTz1.js +118 -0
- package/dist/pwa/assets/index-_AmC1Rkn.css +1 -0
- package/dist/pwa/assets/plus-jakarta-sans-latin-ext-wght-normal-DmpS2jIq.woff2 +0 -0
- package/dist/pwa/assets/plus-jakarta-sans-latin-wght-normal-eXO_dkmS.woff2 +0 -0
- package/dist/pwa/assets/plus-jakarta-sans-vietnamese-wght-normal-qRpaaN48.woff2 +0 -0
- package/dist/pwa/favicon.ico +0 -0
- package/dist/pwa/index.html +17 -0
- package/dist/pwa/manifest.webmanifest +1 -0
- package/dist/pwa/pwa-192x192.png +0 -0
- package/dist/pwa/pwa-512x512.png +0 -0
- package/dist/pwa/registerSW.js +1 -0
- package/dist/pwa/service-worker.js +2 -0
- package/dist/rpc-handler.d.ts +4 -0
- package/dist/rpc-handler.js +5 -4
- package/dist/transports/http-transport.js +29 -41
- package/package.json +2 -2
- package/palmier-server/.github/workflows/ci.yml +21 -0
- package/palmier-server/.github/workflows/deploy.yml +38 -0
- package/palmier-server/CLAUDE.md +13 -0
- package/palmier-server/PRODUCTION.md +355 -0
- package/palmier-server/README.md +187 -0
- package/palmier-server/nats.conf +15 -0
- package/palmier-server/package.json +8 -0
- package/palmier-server/pnpm-lock.yaml +6597 -0
- package/palmier-server/pnpm-workspace.yaml +3 -0
- package/palmier-server/pwa/index.html +16 -0
- package/palmier-server/pwa/logo/logo-prompt.md +28 -0
- package/palmier-server/pwa/logo/logo_20260330.png +0 -0
- package/palmier-server/pwa/package.json +30 -0
- package/palmier-server/pwa/public/apple-touch-icon.png +0 -0
- package/palmier-server/pwa/public/favicon.ico +0 -0
- package/palmier-server/pwa/public/pwa-192x192.png +0 -0
- package/palmier-server/pwa/public/pwa-512x512.png +0 -0
- package/palmier-server/pwa/src/App.css +2387 -0
- package/palmier-server/pwa/src/App.tsx +21 -0
- package/palmier-server/pwa/src/agentLabels.ts +11 -0
- package/palmier-server/pwa/src/api.ts +61 -0
- package/palmier-server/pwa/src/components/HostMenu.tsx +289 -0
- package/palmier-server/pwa/src/components/PlanDialog.tsx +41 -0
- package/palmier-server/pwa/src/components/RunDetailView.tsx +293 -0
- package/palmier-server/pwa/src/components/RunsView.tsx +254 -0
- package/palmier-server/pwa/src/components/TabBar.tsx +31 -0
- package/palmier-server/pwa/src/components/TaskCard.tsx +213 -0
- package/palmier-server/pwa/src/components/TaskForm.tsx +580 -0
- package/palmier-server/pwa/src/components/TaskListView.tsx +415 -0
- package/palmier-server/pwa/src/constants.ts +2 -0
- package/palmier-server/pwa/src/contexts/HostConnectionContext.tsx +313 -0
- package/palmier-server/pwa/src/contexts/HostStoreContext.tsx +135 -0
- package/palmier-server/pwa/src/formatTime.ts +10 -0
- package/palmier-server/pwa/src/hooks/useBackClose.ts +75 -0
- package/palmier-server/pwa/src/hooks/useMediaQuery.ts +17 -0
- package/palmier-server/pwa/src/hooks/usePushSubscription.ts +75 -0
- package/palmier-server/pwa/src/main.tsx +14 -0
- package/palmier-server/pwa/src/pages/Dashboard.tsx +223 -0
- package/palmier-server/pwa/src/pages/PairHost.tsx +178 -0
- package/palmier-server/pwa/src/service-worker.ts +139 -0
- package/palmier-server/pwa/src/types.ts +79 -0
- package/palmier-server/pwa/src/vite-env.d.ts +11 -0
- package/palmier-server/pwa/tsconfig.json +21 -0
- package/palmier-server/pwa/tsconfig.node.json +19 -0
- package/palmier-server/pwa/vite.config.ts +47 -0
- package/palmier-server/server/.env.example +16 -0
- package/palmier-server/server/package.json +33 -0
- package/palmier-server/server/src/db.ts +34 -0
- package/palmier-server/server/src/index.ts +219 -0
- package/palmier-server/server/src/nats.ts +25 -0
- package/palmier-server/server/src/push.ts +68 -0
- package/palmier-server/server/src/routes/hosts.ts +45 -0
- package/palmier-server/server/src/routes/push.ts +100 -0
- package/palmier-server/server/tsconfig.json +20 -0
- package/palmier-server/spec.md +415 -0
- package/src/agents/agent-instructions.md +1 -1
- package/src/agents/agent.ts +23 -0
- package/src/agents/aider.ts +37 -0
- package/src/agents/cursor.ts +38 -0
- package/src/agents/deepagents.ts +38 -0
- package/src/agents/droid.ts +37 -0
- package/src/agents/goose.ts +35 -0
- package/src/agents/opencode.ts +38 -0
- package/src/agents/openhands.ts +38 -0
- package/src/commands/pair.ts +1 -1
- package/src/commands/run.ts +2 -2
- package/src/rpc-handler.ts +5 -4
- package/src/transports/http-transport.ts +31 -43
- package/test/result-state.test.ts +110 -0
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { defineConfig } from "vite";
|
|
2
|
+
import react from "@vitejs/plugin-react";
|
|
3
|
+
import { VitePWA } from "vite-plugin-pwa";
|
|
4
|
+
|
|
5
|
+
export default defineConfig({
|
|
6
|
+
server: {
|
|
7
|
+
host: true,
|
|
8
|
+
proxy: {
|
|
9
|
+
"/api": process.env.API_URL || "http://localhost:3000"
|
|
10
|
+
},
|
|
11
|
+
},
|
|
12
|
+
plugins: [
|
|
13
|
+
react(),
|
|
14
|
+
VitePWA({
|
|
15
|
+
strategies: "injectManifest",
|
|
16
|
+
srcDir: "src",
|
|
17
|
+
filename: "service-worker.ts",
|
|
18
|
+
registerType: "autoUpdate",
|
|
19
|
+
devOptions: {
|
|
20
|
+
enabled: true,
|
|
21
|
+
type: "module",
|
|
22
|
+
},
|
|
23
|
+
includeAssets: ["favicon.ico", "apple-touch-icon.png"],
|
|
24
|
+
manifest: {
|
|
25
|
+
name: "Palmier",
|
|
26
|
+
short_name: "Palmier",
|
|
27
|
+
description: "Control AI agents running on your machine from any device. Schedule tasks, monitor runs, and stay in control.",
|
|
28
|
+
start_url: "/",
|
|
29
|
+
display: "standalone",
|
|
30
|
+
background_color: "#ffffff",
|
|
31
|
+
theme_color: "#2E5CE5",
|
|
32
|
+
icons: [
|
|
33
|
+
{
|
|
34
|
+
src: "pwa-192x192.png",
|
|
35
|
+
sizes: "192x192",
|
|
36
|
+
type: "image/png",
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
src: "pwa-512x512.png",
|
|
40
|
+
sizes: "512x512",
|
|
41
|
+
type: "image/png",
|
|
42
|
+
},
|
|
43
|
+
],
|
|
44
|
+
},
|
|
45
|
+
}),
|
|
46
|
+
],
|
|
47
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
PORT=3000
|
|
2
|
+
|
|
3
|
+
# PostgreSQL
|
|
4
|
+
DATABASE_URL=postgresql://user:password@localhost:5432/palmier
|
|
5
|
+
|
|
6
|
+
# NATS
|
|
7
|
+
NATS_URL=nats://localhost:4222
|
|
8
|
+
NATS_HOST_URL=nats://192.168.1.100:4222
|
|
9
|
+
# Production: wss://nats.palmier.me LAN dev: ws://192.168.1.100:9222
|
|
10
|
+
NATS_WS_URL=ws://192.168.1.100:9222
|
|
11
|
+
NATS_TOKEN=
|
|
12
|
+
|
|
13
|
+
# Web Push (generate with: npx web-push generate-vapid-keys)
|
|
14
|
+
VAPID_PUBLIC_KEY=
|
|
15
|
+
VAPID_PRIVATE_KEY=
|
|
16
|
+
VAPID_MAILTO=mailto:admin@example.com
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "palmier-server",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"private": true,
|
|
5
|
+
"scripts": {
|
|
6
|
+
"dev": "tsx watch src/index.ts",
|
|
7
|
+
"build": "tsc",
|
|
8
|
+
"start": "node dist/index.js"
|
|
9
|
+
},
|
|
10
|
+
"dependencies": {
|
|
11
|
+
"bcrypt": "^5.1.1",
|
|
12
|
+
"cors": "^2.8.5",
|
|
13
|
+
"dotenv": "^16.4.7",
|
|
14
|
+
"express": "^4.21.2",
|
|
15
|
+
"helmet": "^8.0.0",
|
|
16
|
+
"jsonwebtoken": "^9.0.2",
|
|
17
|
+
"nats": "^2.29.1",
|
|
18
|
+
"pg": "^8.13.1",
|
|
19
|
+
"uuid": "^11.0.5",
|
|
20
|
+
"web-push": "^3.6.7"
|
|
21
|
+
},
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"@types/bcrypt": "^5.0.2",
|
|
24
|
+
"@types/cors": "^2.8.17",
|
|
25
|
+
"@types/express": "^5.0.0",
|
|
26
|
+
"@types/jsonwebtoken": "^9.0.7",
|
|
27
|
+
"@types/pg": "^8.11.10",
|
|
28
|
+
"@types/uuid": "^10.0.0",
|
|
29
|
+
"@types/web-push": "^3.6.4",
|
|
30
|
+
"tsx": "^4.19.2",
|
|
31
|
+
"typescript": "^5.7.3"
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import pg from "pg";
|
|
2
|
+
|
|
3
|
+
const { Pool } = pg;
|
|
4
|
+
|
|
5
|
+
export const pool = new Pool({
|
|
6
|
+
connectionString: process.env.DATABASE_URL,
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
export async function initDb(): Promise<void> {
|
|
10
|
+
const client = await pool.connect();
|
|
11
|
+
try {
|
|
12
|
+
await client.query(`
|
|
13
|
+
CREATE TABLE IF NOT EXISTS hosts (
|
|
14
|
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
15
|
+
name VARCHAR(255),
|
|
16
|
+
created_at TIMESTAMPTZ DEFAULT NOW()
|
|
17
|
+
);
|
|
18
|
+
`);
|
|
19
|
+
await client.query(`
|
|
20
|
+
CREATE TABLE IF NOT EXISTS push_subscriptions (
|
|
21
|
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
22
|
+
host_id UUID NOT NULL REFERENCES hosts(id) ON DELETE CASCADE,
|
|
23
|
+
endpoint TEXT NOT NULL,
|
|
24
|
+
p256dh TEXT NOT NULL,
|
|
25
|
+
auth TEXT NOT NULL,
|
|
26
|
+
created_at TIMESTAMPTZ DEFAULT NOW(),
|
|
27
|
+
UNIQUE(host_id, endpoint)
|
|
28
|
+
);
|
|
29
|
+
`);
|
|
30
|
+
console.log("Database tables initialized.");
|
|
31
|
+
} finally {
|
|
32
|
+
client.release();
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import "dotenv/config";
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import express from "express";
|
|
5
|
+
import helmet from "helmet";
|
|
6
|
+
import { pool, initDb } from "./db.js";
|
|
7
|
+
import { connectNats, getNatsConnection } from "./nats.js";
|
|
8
|
+
import { sendPushToHost } from "./push.js";
|
|
9
|
+
|
|
10
|
+
import { StringCodec } from "nats";
|
|
11
|
+
|
|
12
|
+
import hostsRoutes from "./routes/hosts.js";
|
|
13
|
+
import pushRoutes from "./routes/push.js";
|
|
14
|
+
|
|
15
|
+
const PORT = parseInt(process.env.PORT || "3000", 10);
|
|
16
|
+
|
|
17
|
+
async function main(): Promise<void> {
|
|
18
|
+
// Initialize database tables
|
|
19
|
+
await initDb();
|
|
20
|
+
|
|
21
|
+
// Connect to NATS
|
|
22
|
+
await connectNats();
|
|
23
|
+
const sc = StringCodec();
|
|
24
|
+
|
|
25
|
+
// Subscribe to unified host-event pub/sub for push notifications
|
|
26
|
+
(async () => {
|
|
27
|
+
try {
|
|
28
|
+
const conn = await getNatsConnection();
|
|
29
|
+
const sub = conn.subscribe("host-event.>");
|
|
30
|
+
console.log("Listening for host-event notifications");
|
|
31
|
+
|
|
32
|
+
for await (const msg of sub) {
|
|
33
|
+
try {
|
|
34
|
+
// Subject: host-event.<host_id>.<task_id>
|
|
35
|
+
const tokens = msg.subject.split(".");
|
|
36
|
+
if (tokens.length < 3) continue;
|
|
37
|
+
const hostId = tokens[1];
|
|
38
|
+
const taskId = tokens.slice(2).join(".");
|
|
39
|
+
|
|
40
|
+
const data = JSON.parse(sc.decode(msg.data)) as {
|
|
41
|
+
event_type: string;
|
|
42
|
+
running_state?: string;
|
|
43
|
+
name?: string;
|
|
44
|
+
run_id?: string;
|
|
45
|
+
required_permissions?: Array<{ name: string; description: string }>;
|
|
46
|
+
input_descriptions?: string[];
|
|
47
|
+
result_file?: string;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
if (data.event_type === "confirm-request") {
|
|
51
|
+
await sendPushToHost(hostId, {
|
|
52
|
+
type: "confirm",
|
|
53
|
+
task_id: taskId,
|
|
54
|
+
host_id: hostId,
|
|
55
|
+
});
|
|
56
|
+
} else if (data.event_type === "confirm-resolved") {
|
|
57
|
+
await sendPushToHost(hostId, {
|
|
58
|
+
type: "confirm-dismiss",
|
|
59
|
+
task_id: taskId,
|
|
60
|
+
host_id: hostId,
|
|
61
|
+
});
|
|
62
|
+
} else if (data.event_type === "permission-request") {
|
|
63
|
+
const taskLabel = data.name
|
|
64
|
+
? data.name.length > 60 ? data.name.slice(0, 60) + "…" : data.name
|
|
65
|
+
: "A task";
|
|
66
|
+
await sendPushToHost(hostId, {
|
|
67
|
+
type: "permission",
|
|
68
|
+
title: "Permission Required",
|
|
69
|
+
body: `${taskLabel} needs additional permissions to continue.`,
|
|
70
|
+
task_id: taskId,
|
|
71
|
+
host_id: hostId,
|
|
72
|
+
});
|
|
73
|
+
} else if (data.event_type === "permission-resolved") {
|
|
74
|
+
await sendPushToHost(hostId, {
|
|
75
|
+
type: "permission-dismiss",
|
|
76
|
+
task_id: taskId,
|
|
77
|
+
host_id: hostId,
|
|
78
|
+
});
|
|
79
|
+
} else if (data.event_type === "input-request") {
|
|
80
|
+
const taskLabel = data.name
|
|
81
|
+
? data.name.length > 60 ? data.name.slice(0, 60) + "…" : data.name
|
|
82
|
+
: "A task";
|
|
83
|
+
await sendPushToHost(hostId, {
|
|
84
|
+
type: "input",
|
|
85
|
+
title: "Input Required",
|
|
86
|
+
body: `${taskLabel} needs your input to continue.`,
|
|
87
|
+
task_id: taskId,
|
|
88
|
+
host_id: hostId,
|
|
89
|
+
});
|
|
90
|
+
} else if (data.event_type === "input-resolved") {
|
|
91
|
+
await sendPushToHost(hostId, {
|
|
92
|
+
type: "input-dismiss",
|
|
93
|
+
task_id: taskId,
|
|
94
|
+
host_id: hostId,
|
|
95
|
+
});
|
|
96
|
+
} else if (data.event_type === "report-generated" || (data.event_type === "running-state" && data.running_state === "failed")) {
|
|
97
|
+
const label = data.name;
|
|
98
|
+
const taskLabel = label
|
|
99
|
+
? label.length > 60 ? label.slice(0, 60) + "…" : label
|
|
100
|
+
: "Task";
|
|
101
|
+
const isFailure = data.running_state === "failed";
|
|
102
|
+
const body = isFailure ? `${taskLabel} — failed` : `${taskLabel} — report ready`;
|
|
103
|
+
await sendPushToHost(hostId, {
|
|
104
|
+
type: isFailure ? "fail" : "complete",
|
|
105
|
+
title: "Palmier",
|
|
106
|
+
body,
|
|
107
|
+
task_id: taskId,
|
|
108
|
+
host_id: hostId,
|
|
109
|
+
run_id: data.run_id,
|
|
110
|
+
result_file: data.result_file,
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
} catch (err) {
|
|
114
|
+
console.error("[host-event→Push] Error:", err);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
} catch (err) {
|
|
118
|
+
console.error("Failed to subscribe to host-event:", err);
|
|
119
|
+
}
|
|
120
|
+
})();
|
|
121
|
+
|
|
122
|
+
// Subscribe to push notification requests from hosts
|
|
123
|
+
(async () => {
|
|
124
|
+
try {
|
|
125
|
+
const conn = await getNatsConnection();
|
|
126
|
+
const sub = conn.subscribe("host.*.push.send");
|
|
127
|
+
console.log("Listening for push notification requests");
|
|
128
|
+
|
|
129
|
+
for await (const msg of sub) {
|
|
130
|
+
try {
|
|
131
|
+
const data = JSON.parse(sc.decode(msg.data)) as {
|
|
132
|
+
hostId: string;
|
|
133
|
+
title: string;
|
|
134
|
+
body: string;
|
|
135
|
+
task_id?: string;
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
// Validate hostId in subject matches payload
|
|
139
|
+
const subjectHostId = msg.subject.split(".")[1];
|
|
140
|
+
if (data.hostId !== subjectHostId) {
|
|
141
|
+
if (msg.reply) {
|
|
142
|
+
msg.respond(sc.encode(JSON.stringify({ error: "hostId mismatch" })));
|
|
143
|
+
}
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
console.log(`[Push] Sending notification for host ${data.hostId}`);
|
|
148
|
+
await sendPushToHost(data.hostId, {
|
|
149
|
+
type: "notification",
|
|
150
|
+
title: data.title,
|
|
151
|
+
body: data.body,
|
|
152
|
+
...(data.task_id ? { task_id: data.task_id } : {}),
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
if (msg.reply) {
|
|
156
|
+
msg.respond(sc.encode(JSON.stringify({ ok: true })));
|
|
157
|
+
}
|
|
158
|
+
} catch (err) {
|
|
159
|
+
console.error("[Push] Error sending notification:", err);
|
|
160
|
+
if (msg.reply) {
|
|
161
|
+
msg.respond(sc.encode(JSON.stringify({ error: String(err) })));
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
} catch (err) {
|
|
166
|
+
console.error("Failed to subscribe to push requests:", err);
|
|
167
|
+
}
|
|
168
|
+
})();
|
|
169
|
+
|
|
170
|
+
// Create Express app
|
|
171
|
+
const app = express();
|
|
172
|
+
|
|
173
|
+
app.use(
|
|
174
|
+
helmet({
|
|
175
|
+
contentSecurityPolicy: false,
|
|
176
|
+
})
|
|
177
|
+
);
|
|
178
|
+
app.use(express.json());
|
|
179
|
+
|
|
180
|
+
// Mount routes
|
|
181
|
+
app.use("/api/hosts", hostsRoutes);
|
|
182
|
+
app.use("/api/push", pushRoutes);
|
|
183
|
+
|
|
184
|
+
// Public NATS config endpoint (used by PWA for pairing)
|
|
185
|
+
app.get("/api/config", (_req, res) => {
|
|
186
|
+
res.json({
|
|
187
|
+
natsWsUrl: process.env.NATS_WS_URL || "",
|
|
188
|
+
natsToken: process.env.NATS_TOKEN || "",
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
// Health check
|
|
193
|
+
app.get("/health", async (_req, res) => {
|
|
194
|
+
try {
|
|
195
|
+
await pool.query("SELECT 1");
|
|
196
|
+
res.json({ status: "ok" });
|
|
197
|
+
} catch {
|
|
198
|
+
res.status(503).json({ status: "unhealthy" });
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
// Serve built PWA static files (production only)
|
|
203
|
+
const pwaDistPath = path.resolve(process.cwd(), "../pwa/dist");
|
|
204
|
+
if (fs.existsSync(pwaDistPath)) {
|
|
205
|
+
app.use(express.static(pwaDistPath));
|
|
206
|
+
app.get("*", (_req, res) => {
|
|
207
|
+
res.sendFile(path.join(pwaDistPath, "index.html"));
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
app.listen(PORT, () => {
|
|
212
|
+
console.log(`Palmier server listening on port ${PORT}`);
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
main().catch((err) => {
|
|
217
|
+
console.error("Failed to start server:", err);
|
|
218
|
+
process.exit(1);
|
|
219
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { connect, NatsConnection } from "nats";
|
|
2
|
+
|
|
3
|
+
let nc: NatsConnection | null = null;
|
|
4
|
+
|
|
5
|
+
export async function connectNats(): Promise<NatsConnection> {
|
|
6
|
+
if (nc) return nc;
|
|
7
|
+
|
|
8
|
+
const url = process.env.NATS_URL || "nats://localhost:4222";
|
|
9
|
+
const token = process.env.NATS_TOKEN;
|
|
10
|
+
|
|
11
|
+
nc = await connect({
|
|
12
|
+
servers: url,
|
|
13
|
+
...(token ? { token } : {}),
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
console.log(`Connected to NATS at ${url}`);
|
|
17
|
+
return nc;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function getNatsConnection(): Promise<NatsConnection> {
|
|
21
|
+
if (!nc) {
|
|
22
|
+
return connectNats();
|
|
23
|
+
}
|
|
24
|
+
return nc;
|
|
25
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import webPush from "web-push";
|
|
2
|
+
import { pool } from "./db.js";
|
|
3
|
+
|
|
4
|
+
let configured = false;
|
|
5
|
+
|
|
6
|
+
function ensureConfigured(): void {
|
|
7
|
+
if (configured) return;
|
|
8
|
+
|
|
9
|
+
const publicKey = process.env.VAPID_PUBLIC_KEY;
|
|
10
|
+
const privateKey = process.env.VAPID_PRIVATE_KEY;
|
|
11
|
+
const mailto = process.env.VAPID_MAILTO || "mailto:admin@example.com";
|
|
12
|
+
|
|
13
|
+
if (!publicKey || !privateKey) {
|
|
14
|
+
console.warn("VAPID keys not configured. Web Push will not work.");
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
webPush.setVapidDetails(mailto, publicKey, privateKey);
|
|
19
|
+
configured = true;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function sendPushToHost(
|
|
23
|
+
hostId: string,
|
|
24
|
+
payload: object | string
|
|
25
|
+
): Promise<void> {
|
|
26
|
+
ensureConfigured();
|
|
27
|
+
if (!configured) {
|
|
28
|
+
console.warn("[Push] VAPID not configured, skipping push for host", hostId);
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const result = await pool.query(
|
|
33
|
+
"SELECT endpoint, p256dh, auth FROM push_subscriptions WHERE host_id = $1",
|
|
34
|
+
[hostId]
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
const body = typeof payload === "string" ? payload : JSON.stringify(payload);
|
|
38
|
+
|
|
39
|
+
const sendPromises = result.rows.map(async (row) => {
|
|
40
|
+
const subscription: webPush.PushSubscription = {
|
|
41
|
+
endpoint: row.endpoint,
|
|
42
|
+
keys: {
|
|
43
|
+
p256dh: row.p256dh,
|
|
44
|
+
auth: row.auth,
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
await webPush.sendNotification(subscription, body);
|
|
50
|
+
} catch (err: any) {
|
|
51
|
+
if (err.statusCode === 410 || err.statusCode === 404) {
|
|
52
|
+
// Subscription expired or invalid, remove it
|
|
53
|
+
await pool.query(
|
|
54
|
+
"DELETE FROM push_subscriptions WHERE host_id = $1 AND endpoint = $2",
|
|
55
|
+
[hostId, row.endpoint]
|
|
56
|
+
);
|
|
57
|
+
console.log(`Removed stale push subscription for host ${hostId}`);
|
|
58
|
+
} else {
|
|
59
|
+
console.error(
|
|
60
|
+
`Failed to send push to ${row.endpoint}:`,
|
|
61
|
+
err.message
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
await Promise.allSettled(sendPromises);
|
|
68
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { Router, Request, Response } from "express";
|
|
2
|
+
import type { Router as RouterType } from "express";
|
|
3
|
+
import { pool } from "../db.js";
|
|
4
|
+
|
|
5
|
+
const router: RouterType = Router();
|
|
6
|
+
|
|
7
|
+
// POST /api/hosts/register - Register a host (called by palmier init).
|
|
8
|
+
// If the request includes a hostId that already exists, the row is reused
|
|
9
|
+
// so re-initializing a host doesn't create a duplicate entry.
|
|
10
|
+
router.post("/register", async (req: Request, res: Response) => {
|
|
11
|
+
try {
|
|
12
|
+
const requestedId: string | undefined = req.body?.hostId;
|
|
13
|
+
|
|
14
|
+
let hostId: string;
|
|
15
|
+
if (requestedId) {
|
|
16
|
+
// Reuse existing row or create one with the requested id.
|
|
17
|
+
await pool.query(
|
|
18
|
+
`INSERT INTO hosts (id) VALUES ($1) ON CONFLICT (id) DO NOTHING`,
|
|
19
|
+
[requestedId],
|
|
20
|
+
);
|
|
21
|
+
hostId = requestedId;
|
|
22
|
+
} else {
|
|
23
|
+
const result = await pool.query(
|
|
24
|
+
"INSERT INTO hosts DEFAULT VALUES RETURNING id"
|
|
25
|
+
);
|
|
26
|
+
hostId = result.rows[0].id;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const natsUrl = process.env.NATS_HOST_URL || process.env.NATS_URL || "nats://localhost:4222";
|
|
30
|
+
const natsWsUrl = process.env.NATS_WS_URL || "";
|
|
31
|
+
const natsToken = process.env.NATS_TOKEN || "";
|
|
32
|
+
|
|
33
|
+
res.status(201).json({
|
|
34
|
+
hostId,
|
|
35
|
+
natsUrl,
|
|
36
|
+
natsWsUrl,
|
|
37
|
+
natsToken,
|
|
38
|
+
});
|
|
39
|
+
} catch (err) {
|
|
40
|
+
console.error("Register host error:", err);
|
|
41
|
+
res.status(500).json({ error: "Internal server error" });
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
export default router;
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { Router, Request, Response } from "express";
|
|
2
|
+
import type { Router as RouterType } from "express";
|
|
3
|
+
import { pool } from "../db.js";
|
|
4
|
+
import { getNatsConnection } from "../nats.js";
|
|
5
|
+
import { StringCodec } from "nats";
|
|
6
|
+
|
|
7
|
+
const router: RouterType = Router();
|
|
8
|
+
|
|
9
|
+
// POST /api/push/subscribe - Upsert push subscription (keyed by hostId)
|
|
10
|
+
router.post("/subscribe", async (req: Request, res: Response) => {
|
|
11
|
+
try {
|
|
12
|
+
const { hostId, endpoint, keys } = req.body;
|
|
13
|
+
|
|
14
|
+
if (!hostId || !endpoint || !keys || !keys.p256dh || !keys.auth) {
|
|
15
|
+
res.status(400).json({
|
|
16
|
+
error: "hostId, endpoint, keys.p256dh, and keys.auth are required",
|
|
17
|
+
});
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
await pool.query(
|
|
22
|
+
`INSERT INTO push_subscriptions (host_id, endpoint, p256dh, auth)
|
|
23
|
+
VALUES ($1, $2, $3, $4)
|
|
24
|
+
ON CONFLICT (host_id, endpoint)
|
|
25
|
+
DO UPDATE SET p256dh = EXCLUDED.p256dh, auth = EXCLUDED.auth`,
|
|
26
|
+
[hostId, endpoint, keys.p256dh, keys.auth]
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
res.status(201).json({ message: "Push subscription saved" });
|
|
30
|
+
} catch (err) {
|
|
31
|
+
console.error("Push subscribe error:", err);
|
|
32
|
+
res.status(500).json({ error: "Internal server error" });
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
// DELETE /api/push/subscribe - Delete push subscription by endpoint
|
|
37
|
+
router.delete("/subscribe", async (req: Request, res: Response) => {
|
|
38
|
+
try {
|
|
39
|
+
const { hostId, endpoint } = req.body;
|
|
40
|
+
|
|
41
|
+
if (!hostId || !endpoint) {
|
|
42
|
+
res.status(400).json({ error: "hostId and endpoint are required" });
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const result = await pool.query(
|
|
47
|
+
"DELETE FROM push_subscriptions WHERE host_id = $1 AND endpoint = $2 RETURNING id",
|
|
48
|
+
[hostId, endpoint]
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
if (result.rows.length === 0) {
|
|
52
|
+
res.status(404).json({ error: "Subscription not found" });
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
res.json({ message: "Push subscription removed" });
|
|
57
|
+
} catch (err) {
|
|
58
|
+
console.error("Push unsubscribe error:", err);
|
|
59
|
+
res.status(500).json({ error: "Internal server error" });
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// GET /api/push/vapid-key - Public endpoint
|
|
64
|
+
router.get("/vapid-key", (_req, res: Response) => {
|
|
65
|
+
const publicKey = process.env.VAPID_PUBLIC_KEY || "";
|
|
66
|
+
res.json({ publicKey });
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// POST /api/push/respond - Respond to a pending confirmation via NATS request-reply
|
|
70
|
+
router.post("/respond", async (req: Request, res: Response) => {
|
|
71
|
+
try {
|
|
72
|
+
const { task_id, host_id, response } = req.body;
|
|
73
|
+
|
|
74
|
+
if (!task_id || !host_id || !response) {
|
|
75
|
+
res.status(400).json({
|
|
76
|
+
error: "task_id, host_id, and response are required",
|
|
77
|
+
});
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const conn = await getNatsConnection();
|
|
82
|
+
const sc = StringCodec();
|
|
83
|
+
const subject = `host.${host_id}.rpc.task.user_input`;
|
|
84
|
+
const payload = sc.encode(JSON.stringify({ id: task_id, value: [response] }));
|
|
85
|
+
|
|
86
|
+
const reply = await conn.request(subject, payload, { timeout: 5000 });
|
|
87
|
+
const result = JSON.parse(sc.decode(reply.data));
|
|
88
|
+
|
|
89
|
+
res.json({ message: "Confirmation response recorded", ...result });
|
|
90
|
+
} catch (err: any) {
|
|
91
|
+
console.error("Push respond error:", err);
|
|
92
|
+
if (err.code === "503" || err.message?.includes("timeout")) {
|
|
93
|
+
res.status(504).json({ error: "Host did not respond" });
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
res.status(500).json({ error: "Internal server error" });
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
export default router;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2023",
|
|
4
|
+
"module": "Node16",
|
|
5
|
+
"moduleResolution": "Node16",
|
|
6
|
+
"lib": ["ES2023"],
|
|
7
|
+
"outDir": "dist",
|
|
8
|
+
"rootDir": "src",
|
|
9
|
+
"strict": true,
|
|
10
|
+
"esModuleInterop": true,
|
|
11
|
+
"skipLibCheck": true,
|
|
12
|
+
"forceConsistentCasingInFileNames": true,
|
|
13
|
+
"resolveJsonModule": true,
|
|
14
|
+
"declaration": true,
|
|
15
|
+
"declarationMap": true,
|
|
16
|
+
"sourceMap": true
|
|
17
|
+
},
|
|
18
|
+
"include": ["src"],
|
|
19
|
+
"exclude": ["node_modules", "dist"]
|
|
20
|
+
}
|