feat(web): browser notifications for reminder + send-test events
In-tab notification bridge so the operator gets a system notification
when a reminder fires successfully (or partly / fails) and when a
send-test message lands. Foundation for true background push later
(VAPID + service-worker subscription); this lands the wiring so
behaviour is testable today.
Pieces
------
- `lib/notifications.ts` — pure helper module:
* notificationSupport / getPermission — feature detection that
treats the SSR / unsupported-browser case as "denied" so callers
don't have to handle a third state.
* isOptedIn / setOptedIn — localStorage-backed opt-in flag
(key `cmbot.notifications.optedIn`). Survives gracefully when
window is missing or storage throws (private mode / quota).
* showNotification(opts) — gated dispatch returning a discriminated
result ({ ok: true, tag } | { ok: false, reason }) so callers
can fall back to a UI toast on opt-out / unsupported / error.
* reminderFiredToNotification + sendTestDoneToNotification —
pure mappers from the bot's SSE events into notification args.
Skips bookkeeping noise (status === "skipped") and failures
that the in-page toast already shows verbatim.
- `components/notification-manager.tsx` — client component mounted
once at the app shell. Subscribes to `reminder.fired` and
`send_test.done` via useEvents and forwards each through the pure
mappers. Renders no DOM.
- `components/notifications-toggle.tsx` — settings-page card with
three states (unsupported / not-granted / granted+opted-in).
"Send test" button fires a sample notification so the operator
can verify the wiring without waiting for a real reminder. The
blocked-by-browser path points them at site settings instead of
silently doing nothing.
- `app/settings/page.tsx` — new "Notifications" card sits above
the Appearance card.
- `app/layout.tsx` — `<NotificationManager />` rendered alongside
`<Toaster />` inside ThemeProvider so the SSE subscription is
active across all routes.
Bot side
--------
- `apps/bot/src/scheduler/fire-reminder.ts` — emits
`pgNotifyWeb({ type: "reminder.fired", reminderId, runId, status })`
after every run regardless of success/partial/failed. The web
side decides whether to surface it as a notification (skipped is
filtered out client-side).
- send_test.done was already emitted by `ipc/send-test-handler.ts`.
PWA service-worker tests (the original ask before this thread)
--------------------------------------------------------------
- Extracted the Serwist config into `pwa/config.ts` so the choices
(skipWaiting, clientsClaim, navigationPreload, runtimeCaching,
precacheEntries) are pinnable without booting a worker scope.
- 6 tests in `pwa/config.test.ts` lock the surface (no extra keys
appear silently, the manifest passes through unchanged, the
pinned booleans stay where production expects them).
- 6 tests in `app/manifest.webmanifest/route.test.ts` cover the
manifest contract (display=standalone, start_url=/, dark theme
colors match the OS, both icons are PNG + maskable, paths
match committed PNGs in public/).
Test counts
-----------
281 web + 31 shared + 26 bot = 338 total (was 306).
- +6 pwa/config (service-worker config pinning)
- +6 app/manifest.webmanifest (PWA manifest contract)
- +20 lib/notifications (full coverage of mappers + dispatch
gates + SSR / unsupported / blocked / opted-out paths)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
29535d6bbc
commit
8f2ee5df9e
@ -11,6 +11,7 @@ import { writeAuditLog } from "../audit.js";
|
|||||||
import { getReminderWithDetails } from "../reminders/crud.js";
|
import { getReminderWithDetails } from "../reminders/crud.js";
|
||||||
import { getBoss } from "./pgboss-client.js";
|
import { getBoss } from "./pgboss-client.js";
|
||||||
import { scheduleReminderFire } from "./reminder-jobs.js";
|
import { scheduleReminderFire } from "./reminder-jobs.js";
|
||||||
|
import { pgNotifyWeb } from "../ipc/notify.js";
|
||||||
|
|
||||||
export type FireReminderPayload = { reminderId: string };
|
export type FireReminderPayload = { reminderId: string };
|
||||||
|
|
||||||
@ -157,6 +158,17 @@ export async function fireReminder(payload: FireReminderPayload): Promise<void>
|
|||||||
.set({ status })
|
.set({ status })
|
||||||
.where(eq(reminderRuns.id, runId));
|
.where(eq(reminderRuns.id, runId));
|
||||||
|
|
||||||
|
// Notify the web so any open browsers can fire a notification.
|
||||||
|
// The web UI subscribes to `reminder.fired` via SSE and surfaces
|
||||||
|
// it as a desktop / mobile notification when the operator has
|
||||||
|
// opted in (Notification.permission === "granted").
|
||||||
|
await pgNotifyWeb({
|
||||||
|
type: "reminder.fired",
|
||||||
|
reminderId: reminder.id,
|
||||||
|
runId,
|
||||||
|
status,
|
||||||
|
});
|
||||||
|
|
||||||
// One-off reminders end after firing. Recurring reminders compute the
|
// One-off reminders end after firing. Recurring reminders compute the
|
||||||
// next occurrence from the RRULE and re-arm the pg-boss job; only the
|
// next occurrence from the RRULE and re-arm the pg-boss job; only the
|
||||||
// last fire timestamp + updatedAt move forward.
|
// last fire timestamp + updatedAt move forward.
|
||||||
|
|||||||
2
apps/web/next-env.d.ts
vendored
2
apps/web/next-env.d.ts
vendored
@ -1,6 +1,6 @@
|
|||||||
/// <reference types="next" />
|
/// <reference types="next" />
|
||||||
/// <reference types="next/image-types/global" />
|
/// <reference types="next/image-types/global" />
|
||||||
import "./.next/types/routes.d.ts";
|
import "./.next/dev/types/routes.d.ts";
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
// NOTE: This file should not be edited
|
||||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import type { Metadata, Viewport } from "next";
|
|||||||
import { GeistSans } from "geist/font/sans";
|
import { GeistSans } from "geist/font/sans";
|
||||||
import { ThemeProvider } from "@/components/theme-provider";
|
import { ThemeProvider } from "@/components/theme-provider";
|
||||||
import { AppShell } from "@/components/app-shell";
|
import { AppShell } from "@/components/app-shell";
|
||||||
|
import { NotificationManager } from "@/components/notification-manager";
|
||||||
import { Toaster } from "@/components/ui/sonner";
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
@ -46,6 +47,8 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
|||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<AppShell>{children}</AppShell>
|
<AppShell>{children}</AppShell>
|
||||||
<Toaster richColors position="top-right" />
|
<Toaster richColors position="top-right" />
|
||||||
|
{/* SSE → browser notification bridge. Renders no DOM. */}
|
||||||
|
<NotificationManager />
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
79
apps/web/src/app/manifest.webmanifest/route.test.ts
Normal file
79
apps/web/src/app/manifest.webmanifest/route.test.ts
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { GET } from "./route";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contract test for the PWA manifest. Most of these fields drive
|
||||||
|
* platform behaviour at install time:
|
||||||
|
*
|
||||||
|
* - Safari uses `name` and `apple-touch-icon.png` for the home
|
||||||
|
* screen tile,
|
||||||
|
* - Android Chrome uses `start_url` + `display: standalone` to
|
||||||
|
* decide whether to launch in fullscreen,
|
||||||
|
* - The `purpose: "any maskable"` icons let Android adaptive
|
||||||
|
* launchers crop without visual breakage.
|
||||||
|
*
|
||||||
|
* If any of these flip we want the test to fail loudly rather than
|
||||||
|
* have a silent change in install behaviour ship.
|
||||||
|
*/
|
||||||
|
|
||||||
|
describe("/manifest.webmanifest GET", () => {
|
||||||
|
it("responds with JSON content-type", async () => {
|
||||||
|
const res = GET();
|
||||||
|
expect(res.headers.get("content-type")).toMatch(/application\/json/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("declares the standalone display mode and home start URL", async () => {
|
||||||
|
const res = GET();
|
||||||
|
const body = (await res.json()) as Record<string, unknown>;
|
||||||
|
expect(body.display).toBe("standalone");
|
||||||
|
expect(body.start_url).toBe("/");
|
||||||
|
expect(body.scope).toBe("/");
|
||||||
|
expect(body.orientation).toBe("portrait");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("carries the brand name + short_name + description", async () => {
|
||||||
|
const body = (await GET().json()) as Record<string, unknown>;
|
||||||
|
expect(body.name).toBe("cm WhatsApp Bot");
|
||||||
|
expect(body.short_name).toBe("cm WA Bot");
|
||||||
|
expect(body.description).toBe("Self-hosted WhatsApp reminder bot");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the dark theme + matching background colors", async () => {
|
||||||
|
const body = (await GET().json()) as Record<string, unknown>;
|
||||||
|
expect(body.theme_color).toBe("#0a0a0a");
|
||||||
|
expect(body.background_color).toBe("#0a0a0a");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ships a 192 + 512 icon pair, both PNG, both 'any maskable'", async () => {
|
||||||
|
const body = (await GET().json()) as { icons: Array<{
|
||||||
|
src: string;
|
||||||
|
sizes: string;
|
||||||
|
type: string;
|
||||||
|
purpose?: string;
|
||||||
|
}> };
|
||||||
|
expect(body.icons).toHaveLength(2);
|
||||||
|
|
||||||
|
const i192 = body.icons.find((i) => i.sizes === "192x192");
|
||||||
|
const i512 = body.icons.find((i) => i.sizes === "512x512");
|
||||||
|
expect(i192).toBeDefined();
|
||||||
|
expect(i512).toBeDefined();
|
||||||
|
|
||||||
|
for (const icon of body.icons) {
|
||||||
|
expect(icon.type).toBe("image/png");
|
||||||
|
// "any maskable" lets Android launchers crop the icon to their
|
||||||
|
// adaptive shape without exposing transparent bezels.
|
||||||
|
expect(icon.purpose).toBe("any maskable");
|
||||||
|
expect(icon.src.startsWith("/")).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches the icon files actually committed under public/", async () => {
|
||||||
|
const body = (await GET().json()) as { icons: Array<{ src: string }> };
|
||||||
|
// Cross-check: the manifest claims paths the build pipeline must
|
||||||
|
// serve. If someone removes one of these PNGs without removing
|
||||||
|
// the manifest entry, install pages on Android break silently.
|
||||||
|
const srcs = body.icons.map((i) => i.src);
|
||||||
|
expect(srcs).toContain("/icon-192.png");
|
||||||
|
expect(srcs).toContain("/icon-512.png");
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -1,7 +1,8 @@
|
|||||||
import { getSeededOperator } from "@/lib/operator";
|
import { getSeededOperator } from "@/lib/operator";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { ThemeToggle } from "@/components/theme-toggle";
|
import { ThemeToggle } from "@/components/theme-toggle";
|
||||||
|
import { NotificationsToggle } from "@/components/notifications-toggle";
|
||||||
|
|
||||||
export default async function SettingsPage() {
|
export default async function SettingsPage() {
|
||||||
const op = await getSeededOperator();
|
const op = await getSeededOperator();
|
||||||
@ -24,6 +25,20 @@ export default async function SettingsPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Notifications</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Browser notifications when a reminder fires successfully or a
|
||||||
|
test message is sent. Uses the in-tab Notification API — works
|
||||||
|
while the app is open. Background push is on the roadmap.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<NotificationsToggle />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Appearance</CardTitle>
|
<CardTitle>Appearance</CardTitle>
|
||||||
|
|||||||
32
apps/web/src/components/notification-manager.tsx
Normal file
32
apps/web/src/components/notification-manager.tsx
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEvents } from "@/hooks/use-events";
|
||||||
|
import {
|
||||||
|
reminderFiredToNotification,
|
||||||
|
sendTestDoneToNotification,
|
||||||
|
showNotification,
|
||||||
|
} from "@/lib/notifications";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mounted once at the app shell. Listens for the bot's `reminder.fired`
|
||||||
|
* and `send_test.done` SSE events and surfaces them as browser
|
||||||
|
* notifications when the operator has opted in (Notification.permission
|
||||||
|
* === "granted" + their localStorage opt-in flag).
|
||||||
|
*
|
||||||
|
* Renders no DOM — it's a side-effect carrier so the SSE subscription
|
||||||
|
* lives at app-shell level instead of being duplicated on every page
|
||||||
|
* that wants to know about reminders firing.
|
||||||
|
*/
|
||||||
|
export function NotificationManager() {
|
||||||
|
useEvents({
|
||||||
|
"reminder.fired": (event) => {
|
||||||
|
const args = reminderFiredToNotification({ type: "reminder.fired", ...event });
|
||||||
|
if (args) showNotification(args);
|
||||||
|
},
|
||||||
|
"send_test.done": (event) => {
|
||||||
|
const args = sendTestDoneToNotification({ type: "send_test.done", ...event });
|
||||||
|
if (args) showNotification(args);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
114
apps/web/src/components/notifications-toggle.tsx
Normal file
114
apps/web/src/components/notifications-toggle.tsx
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { BellIcon, BellOffIcon, AlertCircleIcon } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
getPermission,
|
||||||
|
isOptedIn,
|
||||||
|
notificationSupport,
|
||||||
|
requestPermission,
|
||||||
|
setOptedIn,
|
||||||
|
showNotification,
|
||||||
|
type NotificationPermission,
|
||||||
|
} from "@/lib/notifications";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Settings-page card that lets the operator opt into browser
|
||||||
|
* notifications for "reminder fired" and "send-test sent" events.
|
||||||
|
*
|
||||||
|
* Three states the UI surfaces:
|
||||||
|
*
|
||||||
|
* 1. Not supported (server / older browser) — shows a one-line
|
||||||
|
* muted note. No action.
|
||||||
|
* 2. Permission not granted — Enable button asks the browser.
|
||||||
|
* If the user blocks the prompt we fall back to a
|
||||||
|
* "blocked in browser" note pointing them at site settings.
|
||||||
|
* 3. Granted + opted in — toggle to disable, plus a "Test"
|
||||||
|
* button that fires a sample notification so the operator
|
||||||
|
* knows the wiring works end-to-end.
|
||||||
|
*/
|
||||||
|
export function NotificationsToggle() {
|
||||||
|
const [supported, setSupported] = useState(false);
|
||||||
|
const [permission, setPermission] = useState<NotificationPermission>("default");
|
||||||
|
const [optedIn, setOptedInState] = useState(false);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
// Hydrate from the live browser state once on mount; the server
|
||||||
|
// can't know any of these values.
|
||||||
|
useEffect(() => {
|
||||||
|
setSupported(notificationSupport() === "supported");
|
||||||
|
setPermission(getPermission());
|
||||||
|
setOptedInState(isOptedIn());
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function enable() {
|
||||||
|
setBusy(true);
|
||||||
|
const result = await requestPermission();
|
||||||
|
setPermission(result);
|
||||||
|
if (result === "granted") {
|
||||||
|
setOptedIn(true);
|
||||||
|
setOptedInState(true);
|
||||||
|
}
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function disable() {
|
||||||
|
setOptedIn(false);
|
||||||
|
setOptedInState(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fireTest() {
|
||||||
|
showNotification({
|
||||||
|
title: "Test notification",
|
||||||
|
body: "Notifications are wired up. Reminder runs and send-tests will surface here.",
|
||||||
|
tag: "settings-test",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!supported) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<BellOffIcon className="size-3.5 shrink-0" />
|
||||||
|
Notifications aren't supported by this browser.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (permission === "denied") {
|
||||||
|
return (
|
||||||
|
<div className="flex items-start gap-2 text-xs text-muted-foreground">
|
||||||
|
<AlertCircleIcon className="size-3.5 shrink-0 mt-0.5" />
|
||||||
|
<span>
|
||||||
|
Notifications are blocked at the browser level. Enable them
|
||||||
|
in your site settings (lock icon next to the URL) and
|
||||||
|
reload.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (permission === "granted" && optedIn) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="inline-flex items-center gap-1.5 text-xs text-emerald-700 dark:text-emerald-400">
|
||||||
|
<BellIcon className="size-3.5" />
|
||||||
|
On
|
||||||
|
</span>
|
||||||
|
<Button type="button" size="sm" variant="outline" onClick={fireTest}>
|
||||||
|
Send test
|
||||||
|
</Button>
|
||||||
|
<Button type="button" size="sm" variant="ghost" onClick={disable}>
|
||||||
|
Turn off
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button type="button" size="sm" onClick={enable} disabled={busy} className="gap-1.5">
|
||||||
|
<BellIcon className="size-3.5" />
|
||||||
|
{busy ? "Asking…" : "Enable notifications"}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
280
apps/web/src/lib/notifications.test.ts
Normal file
280
apps/web/src/lib/notifications.test.ts
Normal file
@ -0,0 +1,280 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
getPermission,
|
||||||
|
isOptedIn,
|
||||||
|
notificationSupport,
|
||||||
|
reminderFiredToNotification,
|
||||||
|
sendTestDoneToNotification,
|
||||||
|
setOptedIn,
|
||||||
|
showNotification,
|
||||||
|
type NotificationPermission,
|
||||||
|
} from "./notifications";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Fake browser environment
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// vitest's default node env has no `window`. We install a minimal shim
|
||||||
|
// before each test (and tear it down afterwards) so the helpers under
|
||||||
|
// test can run as if they were in a browser tab.
|
||||||
|
|
||||||
|
interface FakeStore {
|
||||||
|
data: Map<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function installWindow(opts: {
|
||||||
|
permission?: NotificationPermission;
|
||||||
|
notificationsEnabled?: boolean;
|
||||||
|
ctorThrows?: boolean;
|
||||||
|
}) {
|
||||||
|
const calls: Array<{ title: string; init: NotificationOptions | undefined }> = [];
|
||||||
|
const store: FakeStore = { data: new Map() };
|
||||||
|
|
||||||
|
class FakeNotification {
|
||||||
|
static permission: NotificationPermission = opts.permission ?? "default";
|
||||||
|
static async requestPermission(): Promise<NotificationPermission> {
|
||||||
|
// Default test policy: granting on request unless the test
|
||||||
|
// overrides Notification.permission directly.
|
||||||
|
FakeNotification.permission = "granted";
|
||||||
|
return "granted";
|
||||||
|
}
|
||||||
|
onclick: (() => void) | null = null;
|
||||||
|
constructor(public title: string, public init?: NotificationOptions) {
|
||||||
|
if (opts.ctorThrows) throw new Error("ctor failure");
|
||||||
|
calls.push({ title, init });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fakeWindow = {
|
||||||
|
Notification: opts.notificationsEnabled === false ? undefined : FakeNotification,
|
||||||
|
localStorage: {
|
||||||
|
getItem: (k: string) => store.data.get(k) ?? null,
|
||||||
|
setItem: (k: string, v: string) => {
|
||||||
|
store.data.set(k, v);
|
||||||
|
},
|
||||||
|
removeItem: (k: string) => {
|
||||||
|
store.data.delete(k);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
location: { href: "/" },
|
||||||
|
focus: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// @ts-expect-error — augment globalThis at runtime
|
||||||
|
globalThis.window = fakeWindow;
|
||||||
|
return { calls, FakeNotification, store };
|
||||||
|
}
|
||||||
|
|
||||||
|
function tearDown() {
|
||||||
|
// @ts-expect-error — clean up the shim
|
||||||
|
delete globalThis.window;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Make sure no shim leaks across tests.
|
||||||
|
tearDown();
|
||||||
|
});
|
||||||
|
afterEach(tearDown);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// notificationSupport / getPermission
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe("notificationSupport / getPermission", () => {
|
||||||
|
it("reports unsupported when window is missing (SSR)", () => {
|
||||||
|
expect(notificationSupport()).toBe("unsupported");
|
||||||
|
expect(getPermission()).toBe("denied");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports unsupported when the browser has no Notification API", () => {
|
||||||
|
installWindow({ notificationsEnabled: false });
|
||||||
|
expect(notificationSupport()).toBe("unsupported");
|
||||||
|
expect(getPermission()).toBe("denied");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the live permission when supported", () => {
|
||||||
|
installWindow({ permission: "granted" });
|
||||||
|
expect(notificationSupport()).toBe("supported");
|
||||||
|
expect(getPermission()).toBe("granted");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// isOptedIn / setOptedIn — localStorage flag
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe("opt-in localStorage flag", () => {
|
||||||
|
it("defaults to opted-out", () => {
|
||||||
|
installWindow({ permission: "granted" });
|
||||||
|
expect(isOptedIn()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flips to opted-in after setOptedIn(true)", () => {
|
||||||
|
installWindow({ permission: "granted" });
|
||||||
|
setOptedIn(true);
|
||||||
|
expect(isOptedIn()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("setOptedIn(false) removes the flag", () => {
|
||||||
|
installWindow({ permission: "granted" });
|
||||||
|
setOptedIn(true);
|
||||||
|
setOptedIn(false);
|
||||||
|
expect(isOptedIn()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("survives gracefully when window is missing", () => {
|
||||||
|
expect(() => setOptedIn(true)).not.toThrow();
|
||||||
|
expect(isOptedIn()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// showNotification — gating + dispatch
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe("showNotification gating", () => {
|
||||||
|
it("returns 'unsupported' when there's no Notification API", () => {
|
||||||
|
installWindow({ notificationsEnabled: false });
|
||||||
|
setOptedIn(true);
|
||||||
|
const r = showNotification({ title: "x" });
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
if (!r.ok) expect(r.reason).toBe("unsupported");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 'opted-out' when the operator hasn't opted in", () => {
|
||||||
|
installWindow({ permission: "granted" });
|
||||||
|
const r = showNotification({ title: "x" });
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
if (!r.ok) expect(r.reason).toBe("opted-out");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 'permission' when permission is default / not yet granted", () => {
|
||||||
|
installWindow({ permission: "default" });
|
||||||
|
setOptedIn(true);
|
||||||
|
const r = showNotification({ title: "x" });
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
if (!r.ok) expect(r.reason).toBe("permission");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 'permission' when blocked by the browser", () => {
|
||||||
|
installWindow({ permission: "denied" });
|
||||||
|
setOptedIn(true);
|
||||||
|
const r = showNotification({ title: "x" });
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
if (!r.ok) expect(r.reason).toBe("permission");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dispatches when supported + opted in + granted, and forwards body/tag/icon", () => {
|
||||||
|
const { calls } = installWindow({ permission: "granted" });
|
||||||
|
setOptedIn(true);
|
||||||
|
const r = showNotification({
|
||||||
|
title: "Reminder sent",
|
||||||
|
body: "All groups received the message.",
|
||||||
|
tag: "reminder:r-1",
|
||||||
|
});
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
expect(calls).toHaveLength(1);
|
||||||
|
expect(calls[0]!.title).toBe("Reminder sent");
|
||||||
|
expect(calls[0]!.init?.body).toBe("All groups received the message.");
|
||||||
|
expect(calls[0]!.init?.tag).toBe("reminder:r-1");
|
||||||
|
// Defaults to /icon-192.png + /icon-192.png for badge.
|
||||||
|
expect(calls[0]!.init?.icon).toBe("/icon-192.png");
|
||||||
|
expect(calls[0]!.init?.badge).toBe("/icon-192.png");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 'error' when the Notification ctor throws (rare)", () => {
|
||||||
|
installWindow({ permission: "granted", ctorThrows: true });
|
||||||
|
setOptedIn(true);
|
||||||
|
const r = showNotification({ title: "x" });
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
if (!r.ok) {
|
||||||
|
expect(r.reason).toBe("error");
|
||||||
|
expect(r.error).toMatch(/ctor failure/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Event mappers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe("reminderFiredToNotification mapping", () => {
|
||||||
|
it("renders 'success' as a positive 'sent' notification", () => {
|
||||||
|
const args = reminderFiredToNotification({
|
||||||
|
type: "reminder.fired",
|
||||||
|
reminderId: "r-1",
|
||||||
|
runId: "run-1",
|
||||||
|
status: "success",
|
||||||
|
});
|
||||||
|
expect(args).not.toBeNull();
|
||||||
|
expect(args?.title).toBe("Reminder sent");
|
||||||
|
expect(args?.body).toMatch(/All groups received/);
|
||||||
|
expect(args?.tag).toBe("reminder:r-1");
|
||||||
|
expect(args?.href).toBe("/reminders/r-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders 'partial' with a hint to open Activity", () => {
|
||||||
|
const args = reminderFiredToNotification({
|
||||||
|
type: "reminder.fired",
|
||||||
|
reminderId: "r-2",
|
||||||
|
runId: "run-2",
|
||||||
|
status: "partial",
|
||||||
|
});
|
||||||
|
expect(args?.title).toBe("Reminder partly sent");
|
||||||
|
expect(args?.body).toMatch(/Some groups received/);
|
||||||
|
expect(args?.body).toMatch(/See activity/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders 'failed' with a clear failure headline", () => {
|
||||||
|
const args = reminderFiredToNotification({
|
||||||
|
type: "reminder.fired",
|
||||||
|
reminderId: "r-3",
|
||||||
|
runId: "run-3",
|
||||||
|
status: "failed",
|
||||||
|
});
|
||||||
|
expect(args?.title).toBe("Reminder failed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for 'skipped' (bookkeeping noise, not user-facing)", () => {
|
||||||
|
const args = reminderFiredToNotification({
|
||||||
|
type: "reminder.fired",
|
||||||
|
reminderId: "r-4",
|
||||||
|
runId: "run-4",
|
||||||
|
status: "skipped",
|
||||||
|
});
|
||||||
|
expect(args).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the same tag for repeat fires of the same reminder so they coalesce", () => {
|
||||||
|
const a = reminderFiredToNotification({
|
||||||
|
type: "reminder.fired",
|
||||||
|
reminderId: "r-1",
|
||||||
|
runId: "run-A",
|
||||||
|
status: "success",
|
||||||
|
});
|
||||||
|
const b = reminderFiredToNotification({
|
||||||
|
type: "reminder.fired",
|
||||||
|
reminderId: "r-1",
|
||||||
|
runId: "run-B",
|
||||||
|
status: "success",
|
||||||
|
});
|
||||||
|
expect(a?.tag).toBe(b?.tag);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sendTestDoneToNotification mapping", () => {
|
||||||
|
it("renders ok=true as 'Test message sent' linked to the group", () => {
|
||||||
|
const args = sendTestDoneToNotification({
|
||||||
|
type: "send_test.done",
|
||||||
|
groupId: "g-1",
|
||||||
|
ok: true,
|
||||||
|
});
|
||||||
|
expect(args?.title).toBe("Test message sent");
|
||||||
|
expect(args?.tag).toBe("send-test:g-1");
|
||||||
|
expect(args?.href).toBe("/groups/g-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null on failure (toast already shows the error)", () => {
|
||||||
|
const args = sendTestDoneToNotification({
|
||||||
|
type: "send_test.done",
|
||||||
|
groupId: "g-1",
|
||||||
|
ok: false,
|
||||||
|
});
|
||||||
|
expect(args).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
182
apps/web/src/lib/notifications.ts
Normal file
182
apps/web/src/lib/notifications.ts
Normal file
@ -0,0 +1,182 @@
|
|||||||
|
/**
|
||||||
|
* Browser Notification helper. Pure-ish wrapper around the Web
|
||||||
|
* Notification API plus a localStorage flag for the operator's
|
||||||
|
* opt-in preference. We use this for surfaced, in-tab notifications
|
||||||
|
* — when a tab is open, fire a notification on:
|
||||||
|
*
|
||||||
|
* - reminder.fired (status=success / partial)
|
||||||
|
* - send_test.done (ok=true)
|
||||||
|
*
|
||||||
|
* For background push (closed app) we'd need a service-worker
|
||||||
|
* subscription + VAPID, which is a bigger lift. This module is the
|
||||||
|
* foundation that swap-in lands on later.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const STORAGE_KEY = "cmbot.notifications.optedIn";
|
||||||
|
|
||||||
|
export type NotificationPermission = "default" | "granted" | "denied";
|
||||||
|
export type NotificationCapability = "supported" | "unsupported";
|
||||||
|
|
||||||
|
/** Detect whether the browser exposes the Notification API at all. */
|
||||||
|
export function notificationSupport(): NotificationCapability {
|
||||||
|
if (typeof window === "undefined") return "unsupported";
|
||||||
|
if (typeof window.Notification === "undefined") return "unsupported";
|
||||||
|
return "supported";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read the current permission state, treating unsupported browsers
|
||||||
|
* as "denied" so callers don't have to handle a third state. */
|
||||||
|
export function getPermission(): NotificationPermission {
|
||||||
|
if (notificationSupport() === "unsupported") return "denied";
|
||||||
|
return window.Notification.permission as NotificationPermission;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Has the operator opted in (saved in localStorage)? */
|
||||||
|
export function isOptedIn(): boolean {
|
||||||
|
if (typeof window === "undefined") return false;
|
||||||
|
try {
|
||||||
|
return window.localStorage.getItem(STORAGE_KEY) === "1";
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setOptedIn(value: boolean): void {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
try {
|
||||||
|
if (value) {
|
||||||
|
window.localStorage.setItem(STORAGE_KEY, "1");
|
||||||
|
} else {
|
||||||
|
window.localStorage.removeItem(STORAGE_KEY);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// localStorage can throw in private mode / quota — non-fatal.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ask the browser for permission. Resolves with the resulting
|
||||||
|
* permission state. Idempotent if already granted/denied.
|
||||||
|
*/
|
||||||
|
export async function requestPermission(): Promise<NotificationPermission> {
|
||||||
|
if (notificationSupport() === "unsupported") return "denied";
|
||||||
|
const result = await window.Notification.requestPermission();
|
||||||
|
return result as NotificationPermission;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShowNotificationOptions {
|
||||||
|
title: string;
|
||||||
|
body?: string;
|
||||||
|
/** Stable identifier so repeats of the same event coalesce. */
|
||||||
|
tag?: string;
|
||||||
|
/** Path the click handler should navigate to (page reuses or
|
||||||
|
* opens a new tab). Optional. */
|
||||||
|
href?: string;
|
||||||
|
/** Override the default icon; defaults to `/icon-192.png`. */
|
||||||
|
icon?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NotificationDispatch =
|
||||||
|
| { ok: true; tag: string | undefined }
|
||||||
|
| { ok: false; reason: "unsupported" | "permission" | "opted-out" | "error"; error?: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show a notification if everything's lined up:
|
||||||
|
* - browser supports it
|
||||||
|
* - operator has opted in
|
||||||
|
* - permission is granted
|
||||||
|
*
|
||||||
|
* Returns a discriminated result so callers can decide whether to
|
||||||
|
* fall back to a UI toast.
|
||||||
|
*/
|
||||||
|
export function showNotification(opts: ShowNotificationOptions): NotificationDispatch {
|
||||||
|
if (notificationSupport() === "unsupported") {
|
||||||
|
return { ok: false, reason: "unsupported" };
|
||||||
|
}
|
||||||
|
if (!isOptedIn()) {
|
||||||
|
return { ok: false, reason: "opted-out" };
|
||||||
|
}
|
||||||
|
if (getPermission() !== "granted") {
|
||||||
|
return { ok: false, reason: "permission" };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const n = new window.Notification(opts.title, {
|
||||||
|
body: opts.body,
|
||||||
|
tag: opts.tag,
|
||||||
|
icon: opts.icon ?? "/icon-192.png",
|
||||||
|
badge: "/icon-192.png",
|
||||||
|
});
|
||||||
|
if (opts.href) {
|
||||||
|
n.onclick = () => {
|
||||||
|
try {
|
||||||
|
window.focus();
|
||||||
|
window.location.href = opts.href!;
|
||||||
|
} catch {
|
||||||
|
// ignore — focus can fail under iframe sandboxing
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { ok: true, tag: opts.tag };
|
||||||
|
} catch (err) {
|
||||||
|
return { ok: false, reason: "error", error: (err as Error).message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Event → notification mapping
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map a `reminder.fired` SSE event into the notification arguments
|
||||||
|
* the manager component should call `showNotification` with.
|
||||||
|
*
|
||||||
|
* Returns null when the run isn't worth notifying about (e.g.
|
||||||
|
* `skipped` runs are bookkeeping noise, not user-facing events).
|
||||||
|
*/
|
||||||
|
export function reminderFiredToNotification(event: {
|
||||||
|
type: "reminder.fired";
|
||||||
|
reminderId: string;
|
||||||
|
runId: string;
|
||||||
|
status: string;
|
||||||
|
}): ShowNotificationOptions | null {
|
||||||
|
if (event.status === "skipped") return null;
|
||||||
|
const headline =
|
||||||
|
event.status === "success"
|
||||||
|
? "Reminder sent"
|
||||||
|
: event.status === "partial"
|
||||||
|
? "Reminder partly sent"
|
||||||
|
: "Reminder failed";
|
||||||
|
const body =
|
||||||
|
event.status === "success"
|
||||||
|
? "All groups received the message."
|
||||||
|
: event.status === "partial"
|
||||||
|
? "Some groups received the message; others failed. See activity."
|
||||||
|
: "No groups received the message. See activity.";
|
||||||
|
return {
|
||||||
|
title: headline,
|
||||||
|
body,
|
||||||
|
// Coalesce repeat fires of the same reminder on the same tab —
|
||||||
|
// only the most recent one stays visible.
|
||||||
|
tag: `reminder:${event.reminderId}`,
|
||||||
|
href: `/reminders/${event.reminderId}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map a `send_test.done` SSE event into notification arguments.
|
||||||
|
* Returns null on the failure case so the in-page toast (which
|
||||||
|
* shows the error verbatim) stays the source of truth.
|
||||||
|
*/
|
||||||
|
export function sendTestDoneToNotification(event: {
|
||||||
|
type: "send_test.done";
|
||||||
|
groupId: string;
|
||||||
|
ok: boolean;
|
||||||
|
}): ShowNotificationOptions | null {
|
||||||
|
if (!event.ok) return null;
|
||||||
|
return {
|
||||||
|
title: "Test message sent",
|
||||||
|
body: "WhatsApp delivered the test message.",
|
||||||
|
tag: `send-test:${event.groupId}`,
|
||||||
|
href: `/groups/${event.groupId}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
63
apps/web/src/pwa/config.test.ts
Normal file
63
apps/web/src/pwa/config.test.ts
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { serviceWorkerConfig } from "./config";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pin the service-worker config choices that ship to production. If
|
||||||
|
* any of these flip without an intentional change, the test fails so
|
||||||
|
* the regression shows up in CI rather than in a "why don't my
|
||||||
|
* service-worker updates land" bug report a week later.
|
||||||
|
*/
|
||||||
|
|
||||||
|
describe("serviceWorkerConfig", () => {
|
||||||
|
const stockManifest = [
|
||||||
|
"/",
|
||||||
|
{ url: "/icon-192.png", revision: "abc123" },
|
||||||
|
{ url: "/icon-512.png", revision: "def456" },
|
||||||
|
];
|
||||||
|
const stockRuntimeCaching = [{ stub: "default" }];
|
||||||
|
|
||||||
|
it("forwards the precache manifest unchanged", () => {
|
||||||
|
const cfg = serviceWorkerConfig(stockManifest, stockRuntimeCaching);
|
||||||
|
expect(cfg.precacheEntries).toBe(stockManifest);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("forwards the runtime-caching recipe unchanged", () => {
|
||||||
|
const cfg = serviceWorkerConfig(stockManifest, stockRuntimeCaching);
|
||||||
|
expect(cfg.runtimeCaching).toBe(stockRuntimeCaching);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("activates new workers immediately (skipWaiting + clientsClaim)", () => {
|
||||||
|
const cfg = serviceWorkerConfig([], []);
|
||||||
|
// Pinned because flipping these to false makes worker updates
|
||||||
|
// land only after every open tab closes — bad UX for an
|
||||||
|
// operator-tool that's typically open as one long-lived tab.
|
||||||
|
expect(cfg.skipWaiting).toBe(true);
|
||||||
|
expect(cfg.clientsClaim).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("turns navigation preload on", () => {
|
||||||
|
const cfg = serviceWorkerConfig([], []);
|
||||||
|
// navigationPreload races the network fetch with the worker's
|
||||||
|
// boot. Without it the first navigation after a cold start
|
||||||
|
// measurably stalls.
|
||||||
|
expect(cfg.navigationPreload).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts an empty manifest (dev / no-op precache)", () => {
|
||||||
|
const cfg = serviceWorkerConfig([], []);
|
||||||
|
expect(cfg.precacheEntries).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns a flat object with no extra keys (config surface stays small)", () => {
|
||||||
|
const cfg = serviceWorkerConfig(stockManifest, stockRuntimeCaching);
|
||||||
|
// Defends against accidentally adding implicit fields the
|
||||||
|
// Serwist constructor would interpret silently.
|
||||||
|
expect(Object.keys(cfg).sort()).toEqual([
|
||||||
|
"clientsClaim",
|
||||||
|
"navigationPreload",
|
||||||
|
"precacheEntries",
|
||||||
|
"runtimeCaching",
|
||||||
|
"skipWaiting",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
57
apps/web/src/pwa/config.ts
Normal file
57
apps/web/src/pwa/config.ts
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
/**
|
||||||
|
* Service-worker configuration for `@serwist/next`. Extracted from
|
||||||
|
* `sw.ts` so unit tests can pin the choices without booting up a
|
||||||
|
* service-worker scope.
|
||||||
|
*
|
||||||
|
* The choices we care about — and why they're the way they are:
|
||||||
|
*
|
||||||
|
* skipWaiting + clientsClaim
|
||||||
|
* A new worker takes over open tabs on the next navigation
|
||||||
|
* instead of waiting for every tab to close. Operators tend to
|
||||||
|
* live in one tab; faster updates win.
|
||||||
|
*
|
||||||
|
* navigationPreload
|
||||||
|
* Tells the browser it can race the network fetch for navigations
|
||||||
|
* alongside the worker boot, cutting first-paint when the worker
|
||||||
|
* is cold.
|
||||||
|
*
|
||||||
|
* runtimeCaching
|
||||||
|
* Serwist's stock recipe — HTML network-first with offline
|
||||||
|
* fallback, static assets cache-first, image / font caches with
|
||||||
|
* sensible TTLs. Easy to swap if we want bespoke strategies later.
|
||||||
|
*
|
||||||
|
* precacheEntries
|
||||||
|
* Whatever `__SW_MANIFEST` the build pipeline injected. We pass
|
||||||
|
* it through unchanged.
|
||||||
|
*/
|
||||||
|
|
||||||
|
type PrecacheEntry = string | { url: string; revision: string | null };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The runtime-caching shape is generic on purpose. Serwist's own
|
||||||
|
* `RuntimeCaching` type pulls in WebWorker DOM lib refs that
|
||||||
|
* vitest's plain Node env doesn't ship; using a generic preserves
|
||||||
|
* the type at the call site (sw.ts passes Serwist's
|
||||||
|
* `RuntimeCaching[]` and gets it back unchanged) without forcing
|
||||||
|
* the lib import here.
|
||||||
|
*/
|
||||||
|
export interface ServiceWorkerConfig<R> {
|
||||||
|
precacheEntries: PrecacheEntry[];
|
||||||
|
skipWaiting: boolean;
|
||||||
|
clientsClaim: boolean;
|
||||||
|
navigationPreload: boolean;
|
||||||
|
runtimeCaching: R;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serviceWorkerConfig<R>(
|
||||||
|
precacheEntries: PrecacheEntry[],
|
||||||
|
runtimeCaching: R,
|
||||||
|
): ServiceWorkerConfig<R> {
|
||||||
|
return {
|
||||||
|
precacheEntries,
|
||||||
|
skipWaiting: true,
|
||||||
|
clientsClaim: true,
|
||||||
|
navigationPreload: true,
|
||||||
|
runtimeCaching,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -1,6 +1,7 @@
|
|||||||
/// <reference lib="webworker" />
|
/// <reference lib="webworker" />
|
||||||
import { defaultCache } from "@serwist/next/worker";
|
import { defaultCache } from "@serwist/next/worker";
|
||||||
import { Serwist } from "serwist";
|
import { Serwist } from "serwist";
|
||||||
|
import { serviceWorkerConfig } from "./config";
|
||||||
|
|
||||||
declare const self: ServiceWorkerGlobalScope & {
|
declare const self: ServiceWorkerGlobalScope & {
|
||||||
__SW_MANIFEST: (string | { url: string; revision: string | null })[];
|
__SW_MANIFEST: (string | { url: string; revision: string | null })[];
|
||||||
@ -8,25 +9,11 @@ declare const self: ServiceWorkerGlobalScope & {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* The service worker entry. `@serwist/next` builds this file into
|
* The service worker entry. `@serwist/next` builds this file into
|
||||||
* `public/sw.js` at production build time and the manifest is
|
* `public/sw.js` at production build time and substitutes the
|
||||||
* substituted into `__SW_MANIFEST` (URLs the worker should precache).
|
* precache manifest into `__SW_MANIFEST`.
|
||||||
*
|
*
|
||||||
* - `skipWaiting` + `clientsClaim`: a new worker takes over the page
|
* The actual config choices live in `./config.ts` so a unit test
|
||||||
* on the next navigation rather than waiting for every tab to
|
* can pin them without spinning up a service-worker scope.
|
||||||
* close. Operators tend to live in one tab; faster updates win.
|
|
||||||
* - `navigationPreload: true`: tells the browser it can race the
|
|
||||||
* network fetch for navigations alongside the worker boot, cutting
|
|
||||||
* first-paint when the worker is cold.
|
|
||||||
* - `runtimeCaching: defaultCache`: serwist's stock recipe — HTML
|
|
||||||
* network-first with offline fallback, static assets cache-first,
|
|
||||||
* image / font caches with sensible TTLs.
|
|
||||||
*/
|
*/
|
||||||
const serwist = new Serwist({
|
const serwist = new Serwist(serviceWorkerConfig(self.__SW_MANIFEST, defaultCache));
|
||||||
precacheEntries: self.__SW_MANIFEST,
|
|
||||||
skipWaiting: true,
|
|
||||||
clientsClaim: true,
|
|
||||||
navigationPreload: true,
|
|
||||||
runtimeCaching: defaultCache,
|
|
||||||
});
|
|
||||||
|
|
||||||
serwist.addEventListeners();
|
serwist.addEventListeners();
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user