diff --git a/src/gateway/protocol/schema/push.ts b/src/gateway/protocol/schema/push.ts index 7305c268268..c4276166445 100644 --- a/src/gateway/protocol/schema/push.ts +++ b/src/gateway/protocol/schema/push.ts @@ -31,8 +31,8 @@ export const PushTestResultSchema = Type.Object( const WebPushKeysSchema = Type.Object( { - p256dh: NonEmptyString, - auth: NonEmptyString, + p256dh: Type.String({ minLength: 1, maxLength: 512 }), + auth: Type.String({ minLength: 1, maxLength: 512 }), }, { additionalProperties: false }, ); @@ -41,7 +41,7 @@ export const WebPushVapidPublicKeyParamsSchema = Type.Object({}, { additionalPro export const WebPushSubscribeParamsSchema = Type.Object( { - endpoint: Type.String({ minLength: 1, maxLength: 2048 }), + endpoint: Type.String({ minLength: 1, maxLength: 2048, pattern: "^https://" }), keys: WebPushKeysSchema, }, { additionalProperties: false }, @@ -49,7 +49,7 @@ export const WebPushSubscribeParamsSchema = Type.Object( export const WebPushUnsubscribeParamsSchema = Type.Object( { - endpoint: Type.String({ minLength: 1, maxLength: 2048 }), + endpoint: Type.String({ minLength: 1, maxLength: 2048, pattern: "^https://" }), }, { additionalProperties: false }, ); diff --git a/src/infra/push-web.ts b/src/infra/push-web.ts index be4000faa06..00bbfc87ab9 100644 --- a/src/infra/push-web.ts +++ b/src/infra/push-web.ts @@ -151,10 +151,10 @@ export async function registerWebPushSubscription( const { endpoint, keys, baseDir } = params; if (!isValidEndpoint(endpoint)) { - throw new Error("invalid push subscription endpoint"); + throw new Error("Invalid push subscription endpoint: must be an HTTPS URL under 2048 chars"); } if (!isValidKey(keys.p256dh) || !isValidKey(keys.auth)) { - throw new Error("invalid push subscription keys"); + throw new Error("Invalid push subscription keys: must be non-empty strings under 512 chars"); } return await withLock(async () => { @@ -244,8 +244,6 @@ export async function sendWebPushNotification( ): Promise { const keys = vapidKeys ?? (await resolveVapidKeys()); - webPush.setVapidDetails(keys.subject, keys.publicKey, keys.privateKey); - const pushSubscription = { endpoint: subscription.endpoint, keys: { @@ -289,11 +287,15 @@ export async function broadcastWebPush( } const vapidKeys = await resolveVapidKeys(baseDir); + + // Set VAPID details once before fanning out concurrent sends. + webPush.setVapidDetails(vapidKeys.subject, vapidKeys.publicKey, vapidKeys.privateKey); + const results = await Promise.allSettled( subscriptions.map((sub) => sendWebPushNotification(sub, payload, vapidKeys)), ); - return results.map((r, i) => + const mapped = results.map((r, i) => r.status === "fulfilled" ? r.value : { @@ -302,4 +304,18 @@ export async function broadcastWebPush( error: r.reason instanceof Error ? r.reason.message : "unknown error", }, ); + + // Clean up expired subscriptions (HTTP 410 Gone) per Web Push spec. + const expiredEndpoints = mapped + .map((result, i) => ({ result, sub: subscriptions[i] })) + .filter(({ result }) => !result.ok && result.statusCode === 410) + .map(({ sub }) => sub.endpoint); + + if (expiredEndpoints.length > 0) { + await Promise.allSettled( + expiredEndpoints.map((endpoint) => clearWebPushSubscriptionByEndpoint(endpoint, baseDir)), + ); + } + + return mapped; } diff --git a/ui/public/sw.js b/ui/public/sw.js index 95eacd28553..6796eb8080d 100644 --- a/ui/public/sw.js +++ b/ui/public/sw.js @@ -30,15 +30,22 @@ self.addEventListener("fetch", (event) => { return; } - // Network-first for HTML / API; cache-first for hashed assets. + // Skip API requests — they should never be cached. + if (url.pathname.startsWith("/api/") || url.pathname.startsWith("/rpc")) { + return; + } + + // Cache-first for hashed assets; network-first for HTML/other. if (url.pathname.includes("/assets/")) { event.respondWith( caches.match(event.request).then( (cached) => cached || fetch(event.request).then((response) => { - const clone = response.clone(); - void caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone)); + if (response.ok) { + const clone = response.clone(); + void caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone)); + } return response; }), ), @@ -47,8 +54,10 @@ self.addEventListener("fetch", (event) => { event.respondWith( fetch(event.request) .then((response) => { - const clone = response.clone(); - void caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone)); + if (response.ok) { + const clone = response.clone(); + void caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone)); + } return response; }) .catch(() => caches.match(event.request)), diff --git a/ui/src/main.ts b/ui/src/main.ts index 70179c8d3f4..bd7f5d8f408 100644 --- a/ui/src/main.ts +++ b/ui/src/main.ts @@ -1,6 +1,13 @@ import "./styles.css"; import "./ui/app.ts"; -if ("serviceWorker" in navigator) { +if (import.meta.env?.PROD && "serviceWorker" in navigator) { void navigator.serviceWorker.register("./sw.js"); +} else if (!import.meta.env?.PROD && "serviceWorker" in navigator) { + // Unregister any leftover dev SW to avoid stale cache issues. + void navigator.serviceWorker.getRegistrations().then((registrations) => { + for (const r of registrations) { + void r.unregister(); + } + }); } diff --git a/ui/src/ui/app-gateway.ts b/ui/src/ui/app-gateway.ts index 4455a523bb4..852d28b5518 100644 --- a/ui/src/ui/app-gateway.ts +++ b/ui/src/ui/app-gateway.ts @@ -339,6 +339,8 @@ export function connectGateway(host: GatewayHost, options?: ConnectGatewayOption void loadNodes(host as unknown as NodesState, { quiet: true }); void loadDevices(host as unknown as DevicesState, { quiet: true }); void refreshActiveTab(host as unknown as Parameters[0]); + // Re-run push reconciliation now that the gateway client is available. + void (host as unknown as OpenClawApp).reconcileWebPushState(); }, onClose: ({ code, reason, error }) => { if (host.client !== client) { diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts index b711f0733ca..1323a8f5c31 100644 --- a/ui/src/ui/app.ts +++ b/ui/src/ui/app.ts @@ -963,31 +963,36 @@ export class OpenClawApp extends LitElement { try { const { getExistingSubscription } = await import("./push-subscription.ts"); const existing = await getExistingSubscription(); - if (existing && this.client) { - // Re-register with the gateway to reconcile local/server state. - // Handles the case where the gateway lost the subscription (e.g. - // state-dir reset) but the browser still has one locally. - const subJson = existing.toJSON(); - if (subJson.endpoint && subJson.keys?.p256dh && subJson.keys?.auth) { - try { - await this.client.request("push.web.subscribe", { - endpoint: subJson.endpoint, - keys: { p256dh: subJson.keys.p256dh, auth: subJson.keys.auth }, - }); - } catch { - // Best-effort — don't block init if gateway is unreachable. - } - } - this.webPushSubscribed = true; - } else { - this.webPushSubscribed = existing !== null; - } + this.webPushSubscribed = existing !== null; } catch { // ignore — just means we can't check } } } + /** Re-register local push subscription with the gateway after connect. */ + async reconcileWebPushState() { + if (!this.webPushSubscribed || !this.client) { + return; + } + try { + const { getExistingSubscription } = await import("./push-subscription.ts"); + const existing = await getExistingSubscription(); + if (!existing) { + return; + } + const subJson = existing.toJSON(); + if (subJson.endpoint && subJson.keys?.p256dh && subJson.keys?.auth) { + await this.client.request("push.web.subscribe", { + endpoint: subJson.endpoint, + keys: { p256dh: subJson.keys.p256dh, auth: subJson.keys.auth }, + }); + } + } catch { + // Best-effort — don't block if gateway is unreachable. + } + } + async handleWebPushSubscribe() { if (!this.client || this.webPushLoading) { return; diff --git a/ui/src/ui/push-subscription.ts b/ui/src/ui/push-subscription.ts index aa5b56b4ec9..f7df74b8351 100644 --- a/ui/src/ui/push-subscription.ts +++ b/ui/src/ui/push-subscription.ts @@ -7,29 +7,6 @@ export type WebPushState = { loading: boolean; }; -const DEFAULT_STATE: WebPushState = { - supported: false, - permission: "unsupported", - subscribed: false, - loading: false, -}; - -export function detectWebPushState(): WebPushState { - const supported = - "serviceWorker" in navigator && "PushManager" in window && "Notification" in window; - - if (!supported) { - return DEFAULT_STATE; - } - - return { - supported: true, - permission: Notification.permission, - subscribed: false, - loading: false, - }; -} - /** Timeout (ms) for service-worker readiness. */ const SW_READY_TIMEOUT = 10_000; @@ -75,7 +52,8 @@ export async function getExistingSubscription(): Promise { const registration = await swReady(); const subscription = await registration.pushManager.getSubscription(); if (subscription) { - // Notify gateway first. - await client.request("push.web.unsubscribe", { - endpoint: subscription.endpoint, - }); - // Then unsubscribe locally. + // Notify gateway (best-effort — always unsubscribe locally afterward). + try { + await client.request("push.web.unsubscribe", { + endpoint: subscription.endpoint, + }); + } catch { + // Gateway may be unreachable; still unsubscribe locally. + } await subscription.unsubscribe(); } }