diff --git a/bun.lock b/bun.lock index ecbaa37e..074c4634 100644 --- a/bun.lock +++ b/bun.lock @@ -64,10 +64,12 @@ "hono": "^4.11.2", "jose": "^6.1.3", "socket.io": "^4.8.3", + "web-push": "^3.6.7", "zod": "^4.2.1", }, "devDependencies": { "@types/node": ">=25", + "@types/web-push": "^3.6.4", "bun-types": "^1.3.5", "typescript": "^5", }, @@ -98,6 +100,10 @@ "shiki": "^3.20.0", "socket.io-client": "^4.8.3", "tailwind-merge": "^3.4.0", + "workbox-expiration": "^7.4.0", + "workbox-precaching": "^7.4.0", + "workbox-routing": "^7.4.0", + "workbox-strategies": "^7.4.0", "workbox-window": "^7.4.0", }, "devDependencies": { @@ -959,6 +965,8 @@ "@types/web-bluetooth": ["@types/web-bluetooth@0.0.21", "", {}, "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA=="], + "@types/web-push": ["@types/web-push@3.6.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ=="], + "@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="], "@vitejs/plugin-react": ["@vitejs/plugin-react@5.1.2", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.53", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-EcA07pHJouywpzsoTUqNh5NwGayl2PPVEJKUSinGGSxFGYn+shYbqMGBg6FXDqgXum9Ou/ecb+411ssw8HImJQ=="], @@ -1027,6 +1035,8 @@ "add": ["add@2.0.6", "", {}, "sha512-j5QzrmsokwWWp6kUcJQySpbG+xfOBqqKnup3OIk1pz+kB/80SLorZ9V8zHFLO92Lcd+hbvq8bT+zOGoPkmBV0Q=="], + "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + "ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="], "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], @@ -1047,6 +1057,8 @@ "arraybuffer.prototype.slice": ["arraybuffer.prototype.slice@1.0.4", "", { "dependencies": { "array-buffer-byte-length": "^1.0.1", "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "is-array-buffer": "^3.0.4" } }, "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ=="], + "asn1.js": ["asn1.js@5.4.1", "", { "dependencies": { "bn.js": "^4.0.0", "inherits": "^2.0.1", "minimalistic-assert": "^1.0.0", "safer-buffer": "^2.1.0" } }, "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA=="], + "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], "assistant-cloud": ["assistant-cloud@0.1.12", "", { "dependencies": { "assistant-stream": "^0.2.46" } }, "sha512-A2tY6QIdP9+RkE8Mmpm4kAoO0NyKsKpJKYebbYFZ3bAnQKyB15Bw/PS9AovpdeziGU9At97TyiMrT36pDjCD7A=="], @@ -1089,12 +1101,16 @@ "birpc": ["birpc@2.9.0", "", {}, "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw=="], + "bn.js": ["bn.js@4.12.2", "", {}, "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw=="], + "body-parser": ["body-parser@1.20.4", "", { "dependencies": { "bytes": "~3.1.2", "content-type": "~1.0.5", "debug": "2.6.9", "depd": "2.0.0", "destroy": "~1.2.0", "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "on-finished": "~2.4.1", "qs": "~6.14.0", "raw-body": "~2.5.3", "type-is": "~1.6.18", "unpipe": "~1.0.0" } }, "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA=="], "brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], "browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="], + "buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="], + "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], "bun-types": ["bun-types@1.3.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-inmAYe2PFLs0SUbFOWSVD24sg1jFlMPxOjOSSCYqUgn4Hsc3rDc7dFvfVYjFPNHtov6kgUeulV4SxbuIV/stPw=="], @@ -1323,6 +1339,8 @@ "eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="], + "ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="], + "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], "ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="], @@ -1553,6 +1571,10 @@ "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], + "http_ece": ["http_ece@1.2.0", "", {}, "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA=="], + + "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], + "i18next": ["i18next@25.7.3", "", { "dependencies": { "@babel/runtime": "^7.28.4" }, "peerDependencies": { "typescript": "^5" }, "optionalPeers": ["typescript"] }, "sha512-2XaT+HpYGuc2uTExq9TVRhLsso+Dxym6PWaKpn36wfBmTI779OQ7iP/XaZHzrnGyzU4SHpFrTYLKfVyBfAhVNA=="], "i18next-browser-languagedetector": ["i18next-browser-languagedetector@8.2.0", "", { "dependencies": { "@babel/runtime": "^7.23.2" } }, "sha512-P+3zEKLnOF0qmiesW383vsLdtQVyKtCNA9cjSoKCppTKPQVfKd2W8hbVo5ZhNJKDqeM7BOcvNoKJOjpHh4Js9g=="], @@ -1683,6 +1705,10 @@ "jsonpointer": ["jsonpointer@5.0.1", "", {}, "sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ=="], + "jwa": ["jwa@2.0.1", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="], + + "jws": ["jws@4.0.1", "", { "dependencies": { "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA=="], + "katex": ["katex@0.16.27", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-aeQoDkuRWSqQN6nSvVCEFvfXdqo1OQiCmmW1kc9xSdjutPv7BGO7pqY9sQRJpMOGrEdfDgF2TfRXe5eUAD2Waw=="], "khroma": ["khroma@2.1.0", "", {}, "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw=="], @@ -1859,8 +1885,12 @@ "mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="], + "minimalistic-assert": ["minimalistic-assert@1.0.1", "", {}, "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A=="], + "minimatch": ["minimatch@10.1.1", "", { "dependencies": { "@isaacs/brace-expansion": "^5.0.0" } }, "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ=="], + "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], + "minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="], "minisearch": ["minisearch@7.2.0", "", {}, "sha512-dqT2XBYUOZOiC5t2HRnwADjhNS2cecp9u+TJRiJ1Qp/f5qjkeT5APcGPjHw+bz89Ms8Jp+cG4AlE+QZ/QnDglg=="], @@ -2427,6 +2457,8 @@ "web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="], + "web-push": ["web-push@3.6.7", "", { "dependencies": { "asn1.js": "^5.3.0", "http_ece": "1.2.0", "https-proxy-agent": "^7.0.0", "jws": "^4.0.0", "minimist": "^1.2.5" }, "bin": { "web-push": "src/cli.js" } }, "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A=="], + "webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], "whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="], diff --git a/server/package.json b/server/package.json index 7b220957..b78dfcc1 100644 --- a/server/package.json +++ b/server/package.json @@ -20,10 +20,12 @@ "hono": "^4.11.2", "jose": "^6.1.3", "socket.io": "^4.8.3", + "web-push": "^3.6.7", "zod": "^4.2.1" }, "devDependencies": { "@types/node": ">=25", + "@types/web-push": "^3.6.4", "bun-types": "^1.3.5", "typescript": "^5" } diff --git a/server/src/configuration.ts b/server/src/configuration.ts index 120285ca..04f9a983 100644 --- a/server/src/configuration.ts +++ b/server/src/configuration.ts @@ -11,6 +11,7 @@ * - WEBAPP_PORT: Port for Mini App HTTP server (default: 3006) * - WEBAPP_URL: Public URL for Telegram Mini App * - CORS_ORIGINS: Comma-separated CORS origins + * - VAPID_SUBJECT: Contact email or URL for Web Push (defaults to mailto:admin@hapi.run) * - HAPI_HOME: Data directory (default: ~/.hapi) * - DB_PATH: SQLite database path (default: {HAPI_HOME}/hapi.db) */ diff --git a/server/src/index.ts b/server/src/index.ts index 8e058bdf..5e566f9e 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -16,6 +16,9 @@ import { startWebServer } from './web/server' import { getOrCreateJwtSecret } from './web/jwtSecret' import { createSocketServer } from './socket/server' import { SSEManager } from './sse/sseManager' +import { getOrCreateVapidKeys } from './push/vapidKeys' +import { PushService } from './push/pushService' +import { PushNotifier } from './push/pushNotifier' import type { Server as BunServer } from 'bun' import type { WebSocketData } from '@socket.io/bun-engine' @@ -37,6 +40,7 @@ let syncEngine: SyncEngine | null = null let happyBot: HappyBot | null = null let webServer: BunServer | null = null let sseManager: SSEManager | null = null +let pushNotifier: PushNotifier | null = null async function main() { console.log('HAPI Server starting...') @@ -74,6 +78,9 @@ async function main() { const store = new Store(config.dbPath) const jwtSecret = await getOrCreateJwtSecret() + const vapidKeys = await getOrCreateVapidKeys(config.dataDir) + const vapidSubject = process.env.VAPID_SUBJECT ?? 'mailto:admin@hapi.run' + const pushService = new PushService(vapidKeys, vapidSubject, store) sseManager = new SSEManager(30_000) @@ -88,6 +95,7 @@ async function main() { }) syncEngine = new SyncEngine(store, socketServer.io, socketServer.rpcRegistry, sseManager) + pushNotifier = new PushNotifier(syncEngine, pushService, config.miniAppUrl) // Initialize Telegram bot (optional) if (config.telegramEnabled && config.telegramBotToken) { @@ -105,6 +113,7 @@ async function main() { getSseManager: () => sseManager, jwtSecret, store, + vapidPublicKey: vapidKeys.publicKey, socketEngine: socketServer.engine }) @@ -119,6 +128,7 @@ async function main() { const shutdown = async () => { console.log('\nShutting down...') await happyBot?.stop() + pushNotifier?.stop() syncEngine?.stop() sseManager?.stop() webServer?.stop() diff --git a/server/src/push/pushNotifier.ts b/server/src/push/pushNotifier.ts new file mode 100644 index 00000000..cbb96acd --- /dev/null +++ b/server/src/push/pushNotifier.ts @@ -0,0 +1,211 @@ +import type { Session, SyncEngine, SyncEvent } from '../sync/syncEngine' +import type { PushPayload, PushService } from './pushService' + +export class PushNotifier { + private readonly lastKnownRequests: Map> = new Map() + private readonly notificationDebounce: Map = new Map() + private readonly lastReadyNotificationAt: Map = new Map() + private unsubscribeSyncEvents: (() => void) | null = null + + constructor( + private readonly syncEngine: SyncEngine, + private readonly pushService: PushService, + private readonly appUrl: string + ) { + this.unsubscribeSyncEvents = this.syncEngine.subscribe((event) => { + this.handleSyncEvent(event) + }) + } + + stop(): void { + if (this.unsubscribeSyncEvents) { + this.unsubscribeSyncEvents() + this.unsubscribeSyncEvents = null + } + + for (const timer of this.notificationDebounce.values()) { + clearTimeout(timer) + } + this.notificationDebounce.clear() + this.lastKnownRequests.clear() + this.lastReadyNotificationAt.clear() + } + + private handleSyncEvent(event: SyncEvent): void { + if (event.type === 'session-updated' && event.sessionId) { + const session = this.syncEngine.getSession(event.sessionId) + if (!session || !session.active) { + this.clearSessionState(event.sessionId) + return + } + this.checkForPermissionNotification(session) + } + + if (event.type === 'session-removed' && event.sessionId) { + this.clearSessionState(event.sessionId) + } + + if (event.type === 'message-received' && event.sessionId) { + const message = (event.message?.content ?? event.data) as unknown + const messageContent = message as { type?: string; data?: { type?: string } } + const eventType = messageContent?.type === 'event' ? messageContent?.data?.type : null + + if (eventType === 'ready') { + this.sendReadyNotification(event.sessionId).catch((error) => { + console.error('[PushNotifier] Failed to send ready notification:', error) + }) + } + } + } + + private clearSessionState(sessionId: string): void { + const existingTimer = this.notificationDebounce.get(sessionId) + if (existingTimer) { + clearTimeout(existingTimer) + this.notificationDebounce.delete(sessionId) + } + this.lastKnownRequests.delete(sessionId) + this.lastReadyNotificationAt.delete(sessionId) + } + + private getNotifiableSession(sessionId: string): Session | null { + const session = this.syncEngine.getSession(sessionId) + if (!session || !session.active) { + return null + } + return session + } + + private checkForPermissionNotification(session: Session): void { + const currentSession = this.getNotifiableSession(session.id) + if (!currentSession) { + return + } + + const requests = currentSession.agentState?.requests + if (requests == null) { + return + } + + const newRequestIds = new Set(Object.keys(requests)) + const oldRequestIds = this.lastKnownRequests.get(session.id) || new Set() + + let hasNewRequests = false + for (const requestId of newRequestIds) { + if (!oldRequestIds.has(requestId)) { + hasNewRequests = true + break + } + } + + this.lastKnownRequests.set(session.id, newRequestIds) + + if (!hasNewRequests) { + return + } + + const existingTimer = this.notificationDebounce.get(currentSession.id) + if (existingTimer) { + clearTimeout(existingTimer) + } + + const timer = setTimeout(() => { + this.notificationDebounce.delete(currentSession.id) + this.sendPermissionNotification(currentSession.id).catch((error) => { + console.error('[PushNotifier] Failed to send permission notification:', error) + }) + }, 500) + + this.notificationDebounce.set(currentSession.id, timer) + } + + private async sendPermissionNotification(sessionId: string): Promise { + const session = this.getNotifiableSession(sessionId) + if (!session) { + return + } + + const name = this.getSessionName(session) + const request = session.agentState?.requests + ? Object.values(session.agentState.requests)[0] + : null + const toolName = request?.tool ? ` (${request.tool})` : '' + + const payload: PushPayload = { + title: 'Permission Request', + body: `${name}${toolName}`, + tag: `permission-${session.id}`, + data: { + type: 'permission-request', + sessionId: session.id, + url: this.buildSessionUrl(session.id) + } + } + + await this.pushService.sendToNamespace(session.namespace, payload) + } + + private async sendReadyNotification(sessionId: string): Promise { + const session = this.getNotifiableSession(sessionId) + if (!session) { + return + } + + const now = Date.now() + const last = this.lastReadyNotificationAt.get(sessionId) ?? 0 + if (now - last < 5000) { + return + } + this.lastReadyNotificationAt.set(sessionId, now) + + const agentName = this.getAgentName(session) + const name = this.getSessionName(session) + + const payload: PushPayload = { + title: 'Ready for input', + body: `${agentName} is waiting in ${name}`, + tag: `ready-${session.id}`, + data: { + type: 'ready', + sessionId: session.id, + url: this.buildSessionUrl(session.id) + } + } + + await this.pushService.sendToNamespace(session.namespace, payload) + } + + private getSessionName(session: Session): string { + if (session.metadata?.name) return session.metadata.name + if (session.metadata?.summary?.text) return session.metadata.summary.text + if (session.metadata?.path) { + const parts = session.metadata.path.split('/').filter(Boolean) + return parts.length > 0 ? parts[parts.length - 1] : session.id.slice(0, 8) + } + return session.id.slice(0, 8) + } + + private getAgentName(session: Session): string { + const flavor = session.metadata?.flavor + if (flavor === 'claude') return 'Claude' + if (flavor === 'codex') return 'Codex' + if (flavor === 'gemini') return 'Gemini' + return 'Agent' + } + + private buildSessionUrl(sessionId: string): string { + try { + const baseUrl = new URL(this.appUrl) + const basePath = baseUrl.pathname === '/' + ? '' + : baseUrl.pathname.replace(/\/$/, '') + baseUrl.pathname = `${basePath}/sessions/${sessionId}` + baseUrl.search = '' + baseUrl.hash = '' + return baseUrl.toString() + } catch { + const trimmed = this.appUrl.replace(/\/$/, '') + return `${trimmed}/sessions/${sessionId}` + } + } +} diff --git a/server/src/push/pushService.ts b/server/src/push/pushService.ts new file mode 100644 index 00000000..d14ed953 --- /dev/null +++ b/server/src/push/pushService.ts @@ -0,0 +1,79 @@ +import * as webPush from 'web-push' +import type { Store } from '../store' +import type { VapidKeys } from './vapidKeys' + +export type PushPayload = { + title: string + body: string + tag?: string + data?: { + type: string + sessionId: string + url: string + } +} + +type StoredSubscription = { + endpoint: string + p256dh: string + auth: string +} + +type PushSubscription = { + endpoint: string + keys: { + p256dh: string + auth: string + } +} + +export class PushService { + constructor( + private readonly vapidKeys: VapidKeys, + private readonly subject: string, + private readonly store: Store + ) { + webPush.setVapidDetails(this.subject, this.vapidKeys.publicKey, this.vapidKeys.privateKey) + } + + async sendToNamespace(namespace: string, payload: PushPayload): Promise { + const subscriptions = this.store.getPushSubscriptionsByNamespace(namespace) + if (subscriptions.length === 0) { + return + } + + const body = JSON.stringify(payload) + await Promise.all(subscriptions.map((subscription) => { + return this.sendToSubscription(namespace, subscription, body) + })) + } + + private async sendToSubscription( + namespace: string, + subscription: StoredSubscription, + body: string + ): Promise { + const pushSubscription: PushSubscription = { + endpoint: subscription.endpoint, + keys: { + p256dh: subscription.p256dh, + auth: subscription.auth + } + } + + try { + await webPush.sendNotification(pushSubscription, body) + } catch (error) { + const statusCode = typeof (error as { statusCode?: unknown }).statusCode === 'number' + ? (error as { statusCode: number }).statusCode + : null + + if (statusCode === 410) { + this.store.removePushSubscription(namespace, subscription.endpoint) + return + } + + console.error('[PushService] Failed to send notification:', error) + } + } +} diff --git a/server/src/push/vapidKeys.ts b/server/src/push/vapidKeys.ts new file mode 100644 index 00000000..80c6d8cf --- /dev/null +++ b/server/src/push/vapidKeys.ts @@ -0,0 +1,32 @@ +import { join } from 'node:path' +import { generateVAPIDKeys } from 'web-push' +import { readSettings, writeSettings } from '../web/cliApiToken' + +export type VapidKeys = { + publicKey: string + privateKey: string +} + +export async function getOrCreateVapidKeys(dataDir: string): Promise { + const settingsFile = join(dataDir, 'settings.json') + const settings = await readSettings(settingsFile) + + if (settings === null) { + throw new Error(`Cannot read ${settingsFile}. Please fix or remove the file and restart.`) + } + + if (settings.vapidKeys?.publicKey && settings.vapidKeys?.privateKey) { + return settings.vapidKeys + } + + const generated = generateVAPIDKeys() + const keys: VapidKeys = { + publicKey: generated.publicKey, + privateKey: generated.privateKey + } + + settings.vapidKeys = keys + await writeSettings(settingsFile, settings) + + return keys +} diff --git a/server/src/store/index.ts b/server/src/store/index.ts index e3f59899..cd11b984 100644 --- a/server/src/store/index.ts +++ b/server/src/store/index.ts @@ -52,6 +52,15 @@ export type StoredUser = { createdAt: number } +export type StoredPushSubscription = { + id: number + namespace: string + endpoint: string + p256dh: string + auth: string + createdAt: number +} + export type VersionedUpdateResult = | { result: 'success'; version: number; value: T } | { result: 'version-mismatch'; version: number; value: T } @@ -106,6 +115,15 @@ type DbUserRow = { created_at: number } +type DbPushSubscriptionRow = { + id: number + namespace: string + endpoint: string + p256dh: string + auth: string + created_at: number +} + function safeJsonParse(value: string | null): unknown | null { if (value === null) return null try { @@ -172,6 +190,17 @@ function toStoredUser(row: DbUserRow): StoredUser { } } +function toStoredPushSubscription(row: DbPushSubscriptionRow): StoredPushSubscription { + return { + id: row.id, + namespace: row.namespace, + endpoint: row.endpoint, + p256dh: row.p256dh, + auth: row.auth, + createdAt: row.created_at + } +} + export class Store { private db: Database @@ -267,6 +296,17 @@ export class Store { UNIQUE(platform, platform_user_id) ); CREATE INDEX IF NOT EXISTS idx_users_platform ON users(platform); + + CREATE TABLE IF NOT EXISTS push_subscriptions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + namespace TEXT NOT NULL, + endpoint TEXT NOT NULL, + p256dh TEXT NOT NULL, + auth TEXT NOT NULL, + created_at INTEGER NOT NULL, + UNIQUE(namespace, endpoint) + ); + CREATE INDEX IF NOT EXISTS idx_push_subscriptions_namespace ON push_subscriptions(namespace); `) // Step 2: Migrate existing tables (add missing columns) @@ -755,4 +795,42 @@ export class Store { ).run(id, namespace) return result.changes > 0 } + + addPushSubscription( + namespace: string, + subscription: { endpoint: string; p256dh: string; auth: string } + ): void { + const now = Date.now() + this.db.prepare(` + INSERT INTO push_subscriptions ( + namespace, endpoint, p256dh, auth, created_at + ) VALUES ( + @namespace, @endpoint, @p256dh, @auth, @created_at + ) + ON CONFLICT(namespace, endpoint) + DO UPDATE SET + p256dh = excluded.p256dh, + auth = excluded.auth, + created_at = excluded.created_at + `).run({ + namespace, + endpoint: subscription.endpoint, + p256dh: subscription.p256dh, + auth: subscription.auth, + created_at: now + }) + } + + removePushSubscription(namespace: string, endpoint: string): void { + this.db.prepare( + 'DELETE FROM push_subscriptions WHERE namespace = ? AND endpoint = ?' + ).run(namespace, endpoint) + } + + getPushSubscriptionsByNamespace(namespace: string): StoredPushSubscription[] { + const rows = this.db.prepare( + 'SELECT * FROM push_subscriptions WHERE namespace = ? ORDER BY created_at DESC' + ).all(namespace) as DbPushSubscriptionRow[] + return rows.map(toStoredPushSubscription) + } } diff --git a/server/src/web/cliApiToken.ts b/server/src/web/cliApiToken.ts index 5cebc822..f0d69677 100644 --- a/server/src/web/cliApiToken.ts +++ b/server/src/web/cliApiToken.ts @@ -16,6 +16,10 @@ export interface Settings { machineIdConfirmedByServer?: boolean daemonAutoStartWhenRunningHappy?: boolean cliApiToken?: string + vapidKeys?: { + publicKey: string + privateKey: string + } // Server configuration (persisted from environment variables) telegramBotToken?: string webappPort?: number diff --git a/server/src/web/routes/push.ts b/server/src/web/routes/push.ts new file mode 100644 index 00000000..f05a93a8 --- /dev/null +++ b/server/src/web/routes/push.ts @@ -0,0 +1,56 @@ +import { Hono } from 'hono' +import { z } from 'zod' +import type { Store } from '../../store' +import type { WebAppEnv } from '../middleware/auth' + +const subscriptionSchema = z.object({ + endpoint: z.string().min(1), + keys: z.object({ + p256dh: z.string().min(1), + auth: z.string().min(1) + }) +}) + +const unsubscribeSchema = z.object({ + endpoint: z.string().min(1) +}) + +export function createPushRoutes(store: Store, vapidPublicKey: string): Hono { + const app = new Hono() + + app.get('/push/vapid-public-key', (c) => { + return c.json({ publicKey: vapidPublicKey }) + }) + + app.post('/push/subscribe', async (c) => { + const json = await c.req.json().catch(() => null) + const parsed = subscriptionSchema.safeParse(json) + if (!parsed.success) { + return c.json({ error: 'Invalid body' }, 400) + } + + const namespace = c.get('namespace') + const { endpoint, keys } = parsed.data + store.addPushSubscription(namespace, { + endpoint, + p256dh: keys.p256dh, + auth: keys.auth + }) + + return c.json({ ok: true }) + }) + + app.delete('/push/subscribe', async (c) => { + const json = await c.req.json().catch(() => null) + const parsed = unsubscribeSchema.safeParse(json) + if (!parsed.success) { + return c.json({ error: 'Invalid body' }, 400) + } + + const namespace = c.get('namespace') + store.removePushSubscription(namespace, parsed.data.endpoint) + return c.json({ ok: true }) + }) + + return app +} diff --git a/server/src/web/server.ts b/server/src/web/server.ts index 894f69b5..b8085975 100644 --- a/server/src/web/server.ts +++ b/server/src/web/server.ts @@ -16,6 +16,7 @@ import { createPermissionsRoutes } from './routes/permissions' import { createMachinesRoutes } from './routes/machines' import { createGitRoutes } from './routes/git' import { createCliRoutes } from './routes/cli' +import { createPushRoutes } from './routes/push' import type { SSEManager } from '../sse/sseManager' import type { Server as BunServer } from 'bun' import type { Server as SocketEngine } from '@socket.io/bun-engine' @@ -55,6 +56,7 @@ function createWebApp(options: { getSseManager: () => SSEManager | null jwtSecret: Uint8Array store: Store + vapidPublicKey: string embeddedAssetMap: Map | null }): Hono { const app = new Hono() @@ -68,7 +70,7 @@ function createWebApp(options: { const corsOriginOption = corsOrigins.includes('*') ? '*' : corsOrigins const corsMiddleware = cors({ origin: corsOriginOption, - allowMethods: ['GET', 'POST', 'OPTIONS'], + allowMethods: ['GET', 'POST', 'DELETE', 'OPTIONS'], allowHeaders: ['authorization', 'content-type'] }) app.use('/api/*', corsMiddleware) @@ -86,6 +88,7 @@ function createWebApp(options: { app.route('/api', createPermissionsRoutes(options.getSyncEngine)) app.route('/api', createMachinesRoutes(options.getSyncEngine)) app.route('/api', createGitRoutes(options.getSyncEngine)) + app.route('/api', createPushRoutes(options.store, options.vapidPublicKey)) if (options.embeddedAssetMap) { const embeddedAssetMap = options.embeddedAssetMap @@ -170,6 +173,7 @@ export async function startWebServer(options: { getSseManager: () => SSEManager | null jwtSecret: Uint8Array store: Store + vapidPublicKey: string socketEngine: SocketEngine }): Promise> { const isCompiled = isBunCompiled() @@ -179,6 +183,7 @@ export async function startWebServer(options: { getSseManager: options.getSseManager, jwtSecret: options.jwtSecret, store: options.store, + vapidPublicKey: options.vapidPublicKey, embeddedAssetMap }) diff --git a/web/package.json b/web/package.json index 85f9fcad..6f2186c7 100644 --- a/web/package.json +++ b/web/package.json @@ -15,14 +15,14 @@ "@assistant-ui/react-markdown": "^0.11.9", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-slot": "^1.2.4", - "@xterm/addon-fit": "^0.11.0", - "@xterm/addon-web-links": "^0.12.0", - "@xterm/xterm": "^6.0.0", "@shikijs/langs": "^3.20.0", "@shikijs/themes": "^3.20.0", "@tanstack/react-query": "^5.90.12", "@tanstack/react-query-devtools": "^5.91.1", "@tanstack/react-router": "^1.143.6", + "@xterm/addon-fit": "^0.11.0", + "@xterm/addon-web-links": "^0.12.0", + "@xterm/xterm": "^6.0.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "diff": "^8.0.2", @@ -33,6 +33,10 @@ "shiki": "^3.20.0", "socket.io-client": "^4.8.3", "tailwind-merge": "^3.4.0", + "workbox-expiration": "^7.4.0", + "workbox-precaching": "^7.4.0", + "workbox-routing": "^7.4.0", + "workbox-strategies": "^7.4.0", "workbox-window": "^7.4.0" }, "devDependencies": { diff --git a/web/src/App.tsx b/web/src/App.tsx index 5cad0a11..a9745b2b 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,13 +1,14 @@ import { useCallback, useEffect, useMemo, useRef } from 'react' import { Outlet, useLocation, useMatchRoute } from '@tanstack/react-router' import { useQueryClient } from '@tanstack/react-query' -import { getTelegramWebApp } from '@/hooks/useTelegram' +import { getTelegramWebApp, isTelegramApp } from '@/hooks/useTelegram' import { initializeTheme } from '@/hooks/useTheme' import { useAuth } from '@/hooks/useAuth' import { useAuthSource } from '@/hooks/useAuthSource' import { useServerUrl } from '@/hooks/useServerUrl' import { useSSE } from '@/hooks/useSSE' import { useSyncingState } from '@/hooks/useSyncingState' +import { usePushNotifications } from '@/hooks/usePushNotifications' import { queryKeys } from '@/lib/query-keys' import { AppContextProvider } from '@/lib/app-context' import { useAppGoBack } from '@/hooks/useAppGoBack' @@ -93,6 +94,8 @@ export function App() { const syncTokenRef = useRef(0) const isFirstConnectRef = useRef(true) const baseUrlRef = useRef(baseUrl) + const pushPromptedRef = useRef(false) + const { isSupported: isPushSupported, permission: pushPermission, requestPermission, subscribe } = usePushNotifications(api) useEffect(() => { if (baseUrlRef.current === baseUrl) { @@ -104,6 +107,35 @@ export function App() { queryClient.clear() }, [baseUrl, queryClient]) + useEffect(() => { + if (!api || !token) { + pushPromptedRef.current = false + return + } + if (isTelegramApp() || !isPushSupported) { + return + } + if (pushPromptedRef.current) { + return + } + pushPromptedRef.current = true + + const run = async () => { + if (pushPermission === 'granted') { + await subscribe() + return + } + if (pushPermission === 'default') { + const granted = await requestPermission() + if (granted) { + await subscribe() + } + } + } + + void run() + }, [api, isPushSupported, pushPermission, requestPermission, subscribe, token]) + const handleSseConnect = useCallback(() => { // Increment token to track this specific connection const token = ++syncTokenRef.current diff --git a/web/src/api/client.ts b/web/src/api/client.ts index c115cd47..85df5000 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -6,6 +6,9 @@ import type { MachinePathsExistsResponse, MachinesResponse, MessagesResponse, + PushSubscriptionPayload, + PushUnsubscribePayload, + PushVapidPublicKeyResponse, SlashCommandsResponse, SpawnResponse, SessionResponse, @@ -149,6 +152,24 @@ export class ApiClient { return await this.request('/api/sessions') } + async getPushVapidPublicKey(): Promise { + return await this.request('/api/push/vapid-public-key') + } + + async subscribePushNotifications(payload: PushSubscriptionPayload): Promise { + await this.request('/api/push/subscribe', { + method: 'POST', + body: JSON.stringify(payload) + }) + } + + async unsubscribePushNotifications(payload: PushUnsubscribePayload): Promise { + await this.request('/api/push/subscribe', { + method: 'DELETE', + body: JSON.stringify(payload) + }) + } + async getSession(sessionId: string): Promise { return await this.request(`/api/sessions/${encodeURIComponent(sessionId)}`) } diff --git a/web/src/hooks/usePushNotifications.ts b/web/src/hooks/usePushNotifications.ts new file mode 100644 index 00000000..786ecf4b --- /dev/null +++ b/web/src/hooks/usePushNotifications.ts @@ -0,0 +1,139 @@ +import { useCallback, useEffect, useState } from 'react' +import type { ApiClient } from '@/api/client' + +function isPushSupported(): boolean { + return typeof window !== 'undefined' + && 'serviceWorker' in navigator + && 'PushManager' in window + && 'Notification' in window +} + +function base64UrlToUint8Array(base64Url: string): Uint8Array { + const padding = '='.repeat((4 - (base64Url.length % 4)) % 4) + const base64 = (base64Url + padding) + .replace(/-/g, '+') + .replace(/_/g, '/') + const raw = atob(base64) + const output = new Uint8Array(raw.length) + for (let i = 0; i < raw.length; i += 1) { + output[i] = raw.charCodeAt(i) + } + return output +} + +export function usePushNotifications(api: ApiClient | null) { + const [isSupported, setIsSupported] = useState(false) + const [permission, setPermission] = useState('default') + const [isSubscribed, setIsSubscribed] = useState(false) + + const refreshSubscription = useCallback(async () => { + if (!isPushSupported()) { + setIsSupported(false) + setIsSubscribed(false) + return + } + + setIsSupported(true) + setPermission(Notification.permission) + + if (Notification.permission !== 'granted') { + setIsSubscribed(false) + return + } + + const registration = await navigator.serviceWorker.ready + const subscription = await registration.pushManager.getSubscription() + setIsSubscribed(Boolean(subscription)) + }, []) + + useEffect(() => { + void refreshSubscription() + }, [refreshSubscription]) + + const requestPermission = useCallback(async (): Promise => { + if (!isPushSupported()) { + return false + } + + const result = await Notification.requestPermission() + setPermission(result) + if (result !== 'granted') { + setIsSubscribed(false) + } + return result === 'granted' + }, []) + + const subscribe = useCallback(async (): Promise => { + if (!api || !isPushSupported()) { + return false + } + + if (Notification.permission !== 'granted') { + setPermission(Notification.permission) + return false + } + + try { + const registration = await navigator.serviceWorker.ready + const existing = await registration.pushManager.getSubscription() + const { publicKey } = await api.getPushVapidPublicKey() + const applicationServerKey = base64UrlToUint8Array(publicKey).buffer as ArrayBuffer + const subscription = existing ?? await registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey + }) + + const json = subscription.toJSON() + const keys = json.keys + if (!json.endpoint || !keys?.p256dh || !keys.auth) { + return false + } + + await api.subscribePushNotifications({ + endpoint: json.endpoint, + keys: { + p256dh: keys.p256dh, + auth: keys.auth + } + }) + setIsSubscribed(true) + return true + } catch (error) { + console.error('[PushNotifications] Failed to subscribe:', error) + return false + } + }, [api]) + + const unsubscribe = useCallback(async (): Promise => { + if (!api || !isPushSupported()) { + return false + } + + try { + const registration = await navigator.serviceWorker.ready + const subscription = await registration.pushManager.getSubscription() + if (!subscription) { + setIsSubscribed(false) + return true + } + + const endpoint = subscription.endpoint + const success = await subscription.unsubscribe() + await api.unsubscribePushNotifications({ endpoint }) + setIsSubscribed(false) + return success + } catch (error) { + console.error('[PushNotifications] Failed to unsubscribe:', error) + return false + } + }, [api]) + + return { + isSupported, + permission, + isSubscribed, + requestPermission, + subscribe, + unsubscribe + } +} diff --git a/web/src/sw.ts b/web/src/sw.ts new file mode 100644 index 00000000..ebe55dc0 --- /dev/null +++ b/web/src/sw.ts @@ -0,0 +1,123 @@ +/// +import { precacheAndRoute } from 'workbox-precaching' +import { registerRoute } from 'workbox-routing' +import { CacheFirst, NetworkFirst } from 'workbox-strategies' +import { ExpirationPlugin } from 'workbox-expiration' + +declare const self: ServiceWorkerGlobalScope & { + __WB_MANIFEST: Array +} + +type PushPayload = { + title: string + body?: string + icon?: string + badge?: string + tag?: string + data?: { + type?: string + sessionId?: string + url?: string + } +} + +precacheAndRoute(self.__WB_MANIFEST) + +registerRoute( + ({ url }) => url.pathname === '/api/sessions', + new NetworkFirst({ + cacheName: 'api-sessions', + networkTimeoutSeconds: 10, + plugins: [ + new ExpirationPlugin({ + maxEntries: 10, + maxAgeSeconds: 60 * 5 + }) + ] + }) +) + +registerRoute( + ({ url }) => /^\/api\/sessions\/[^/]+$/.test(url.pathname), + new NetworkFirst({ + cacheName: 'api-session-detail', + networkTimeoutSeconds: 10, + plugins: [ + new ExpirationPlugin({ + maxEntries: 20, + maxAgeSeconds: 60 * 5 + }) + ] + }) +) + +registerRoute( + ({ url }) => url.pathname === '/api/machines', + new NetworkFirst({ + cacheName: 'api-machines', + networkTimeoutSeconds: 10, + plugins: [ + new ExpirationPlugin({ + maxEntries: 5, + maxAgeSeconds: 60 * 10 + }) + ] + }) +) + +registerRoute( + /^https:\/\/cdn\.socket\.io\/.*/, + new CacheFirst({ + cacheName: 'cdn-socketio', + plugins: [ + new ExpirationPlugin({ + maxEntries: 5, + maxAgeSeconds: 60 * 60 * 24 * 30 + }) + ] + }) +) + +registerRoute( + /^https:\/\/telegram\.org\/.*/, + new CacheFirst({ + cacheName: 'cdn-telegram', + plugins: [ + new ExpirationPlugin({ + maxEntries: 5, + maxAgeSeconds: 60 * 60 * 24 * 7 + }) + ] + }) +) + +self.addEventListener('push', (event) => { + const payload = event.data?.json() as PushPayload | undefined + if (!payload) { + return + } + + const title = payload.title || 'HAPI' + const body = payload.body ?? '' + const icon = payload.icon ?? '/pwa-192x192.png' + const badge = payload.badge ?? '/pwa-64x64.png' + const data = payload.data + const tag = payload.tag + + event.waitUntil( + self.registration.showNotification(title, { + body, + icon, + badge, + data, + tag + }) + ) +}) + +self.addEventListener('notificationclick', (event) => { + event.notification.close() + const data = event.notification.data as { url?: string } | undefined + const url = data?.url ?? '/' + event.waitUntil(self.clients.openWindow(url)) +}) diff --git a/web/src/types/api.ts b/web/src/types/api.ts index d26f2bb5..85e03087 100644 --- a/web/src/types/api.ts +++ b/web/src/types/api.ts @@ -197,6 +197,24 @@ export type SlashCommandsResponse = { error?: string } +export type PushSubscriptionKeys = { + p256dh: string + auth: string +} + +export type PushSubscriptionPayload = { + endpoint: string + keys: PushSubscriptionKeys +} + +export type PushUnsubscribePayload = { + endpoint: string +} + +export type PushVapidPublicKeyResponse = { + publicKey: string +} + export type SyncEvent = | { type: 'session-added'; sessionId: string; data?: unknown; namespace?: string } | { type: 'session-updated'; sessionId: string; data?: unknown; namespace?: string } diff --git a/web/vite.config.ts b/web/vite.config.ts index 024245c3..62a02b56 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -25,6 +25,9 @@ export default defineConfig({ VitePWA({ registerType: 'autoUpdate', includeAssets: ['favicon.ico', 'apple-touch-icon-180x180.png', 'mask-icon.svg'], + strategies: 'injectManifest', + srcDir: 'src', + filename: 'sw.ts', manifest: { name: 'HAPI', short_name: 'HAPI', @@ -59,68 +62,8 @@ export default defineConfig({ } ] }, - workbox: { - globPatterns: ['**/*.{js,css,html,ico,png,svg,woff,woff2}'], - runtimeCaching: [ - { - urlPattern: /^\/api\/sessions$/, - handler: 'NetworkFirst', - options: { - cacheName: 'api-sessions', - expiration: { - maxEntries: 10, - maxAgeSeconds: 60 * 5 - }, - networkTimeoutSeconds: 10 - } - }, - { - urlPattern: /^\/api\/sessions\/[^/]+$/, - handler: 'NetworkFirst', - options: { - cacheName: 'api-session-detail', - expiration: { - maxEntries: 20, - maxAgeSeconds: 60 * 5 - }, - networkTimeoutSeconds: 10 - } - }, - { - urlPattern: /^\/api\/machines$/, - handler: 'NetworkFirst', - options: { - cacheName: 'api-machines', - expiration: { - maxEntries: 5, - maxAgeSeconds: 60 * 10 - }, - networkTimeoutSeconds: 10 - } - }, - { - urlPattern: /^https:\/\/cdn\.socket\.io\/.*/, - handler: 'CacheFirst', - options: { - cacheName: 'cdn-socketio', - expiration: { - maxEntries: 5, - maxAgeSeconds: 60 * 60 * 24 * 30 - } - } - }, - { - urlPattern: /^https:\/\/telegram\.org\/.*/, - handler: 'CacheFirst', - options: { - cacheName: 'cdn-telegram', - expiration: { - maxEntries: 5, - maxAgeSeconds: 60 * 60 * 24 * 7 - } - } - } - ] + injectManifest: { + globPatterns: ['**/*.{js,css,html,ico,png,svg,woff,woff2}'] }, devOptions: { enabled: true,