diff --git a/src/infra/push-web.test.ts b/src/infra/push-web.test.ts index e043a04869f..e0e0337e1a4 100644 --- a/src/infra/push-web.test.ts +++ b/src/infra/push-web.test.ts @@ -23,10 +23,10 @@ vi.mock("../config/paths.js", () => ({ // Stub web-push so we don't make real HTTP requests. vi.mock("web-push", () => ({ default: { - generateVAPIDKeys: () => ({ + generateVAPIDKeys: vi.fn(() => ({ publicKey: "test-public-key-base64url", privateKey: "test-private-key-base64url", - }), + })), setVapidDetails: vi.fn(), sendNotification: vi.fn().mockResolvedValue({ statusCode: 201 }), }, @@ -52,6 +52,7 @@ describe("resolveVapidKeys", () => { const keys2 = await resolveVapidKeys(tmpDir); expect(keys2.publicKey).toBe(keys.publicKey); expect(keys2.privateKey).toBe(keys.privateKey); + expect(vi.mocked(webPush.generateVAPIDKeys)).toHaveBeenCalledTimes(1); }); it("prefers env vars over persisted keys", async () => { @@ -67,6 +68,7 @@ describe("resolveVapidKeys", () => { expect(keys.publicKey).toBe("env-public"); expect(keys.privateKey).toBe("env-private"); expect(keys.subject).toBe("mailto:env@test.com"); + expect(vi.mocked(webPush.generateVAPIDKeys)).toHaveBeenCalledTimes(1); } finally { delete process.env.OPENCLAW_VAPID_PUBLIC_KEY; delete process.env.OPENCLAW_VAPID_PRIVATE_KEY; diff --git a/src/infra/push-web.ts b/src/infra/push-web.ts index 135f575d434..786a9b7d294 100644 --- a/src/infra/push-web.ts +++ b/src/infra/push-web.ts @@ -1,6 +1,5 @@ import { createHash, randomUUID } from "node:crypto"; import path from "node:path"; -import webPush from "web-push"; import { resolveStateDir } from "../config/paths.js"; import { createAsyncLock, readJsonFile, writeJsonAtomic } from "./json-files.js"; @@ -41,6 +40,18 @@ const DEFAULT_VAPID_SUBJECT = "mailto:openclaw@localhost"; const withLock = createAsyncLock(); +type WebPushRuntime = typeof import("web-push"); +type WebPushRuntimeModule = WebPushRuntime & { default?: WebPushRuntime }; + +let webPushRuntimePromise: Promise | undefined; + +async function loadWebPushRuntime(): Promise { + webPushRuntimePromise ??= import("web-push").then( + (mod: WebPushRuntimeModule) => mod.default ?? mod, + ); + return await webPushRuntimePromise; +} + // --- Helpers --- function resolveWebPushStatePath(baseDir?: string): string { @@ -115,6 +126,7 @@ export async function resolveVapidKeys(baseDir?: string): Promise }; } + const webPush = await loadWebPushRuntime(); const keys = webPush.generateVAPIDKeys(); const pair: VapidKeyPair = { publicKey: keys.publicKey, @@ -238,7 +250,7 @@ export type WebPushPayload = { url?: string; }; -function applyVapidDetails(keys: VapidKeyPair): void { +function applyVapidDetails(webPush: WebPushRuntime, keys: VapidKeyPair): void { webPush.setVapidDetails(keys.subject, keys.publicKey, keys.privateKey); } @@ -248,12 +260,14 @@ export async function sendWebPushNotification( vapidKeys?: VapidKeyPair, ): Promise { const keys = vapidKeys ?? (await resolveVapidKeys()); - applyVapidDetails(keys); + const webPush = await loadWebPushRuntime(); + applyVapidDetails(webPush, keys); - return sendPreparedWebPushNotification(subscription, payload); + return sendPreparedWebPushNotification(webPush, subscription, payload); } async function sendPreparedWebPushNotification( + webPush: WebPushRuntime, subscription: WebPushSubscription, payload: WebPushPayload, ): Promise { @@ -300,12 +314,13 @@ export async function broadcastWebPush( } const vapidKeys = await resolveVapidKeys(baseDir); + const webPush = await loadWebPushRuntime(); // Set VAPID details once before fanning out concurrent sends. - applyVapidDetails(vapidKeys); + applyVapidDetails(webPush, vapidKeys); const results = await Promise.allSettled( - subscriptions.map((sub) => sendPreparedWebPushNotification(sub, payload)), + subscriptions.map((sub) => sendPreparedWebPushNotification(webPush, sub, payload)), ); const mapped = results.map((r, i) =>