@broberg/pwa 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -59,6 +59,23 @@ skeleton: it renders `role="status"` + `aria-live="polite"`, carries the stable
59
59
  testids `pwa-update-confirm` / `pwa-update-dismiss` / `pwa-update-close`, and
60
60
  ships **no** colours or design-system classes.
61
61
 
62
+ ## Preact usage
63
+
64
+ Same API for the Stack B (Bun / Hono / Preact) apps — just the `/preact` subpath
65
+ (`preact` is the optional peer instead of `react`):
66
+
67
+ ```tsx
68
+ import { usePwaUpdate, PwaUpdateBanner } from "@broberg/pwa/preact";
69
+
70
+ export function PwaUpdater() {
71
+ const { updateReady, applyUpdate } = usePwaUpdate({ disabled: isNative || isDev });
72
+ return <PwaUpdateBanner updateReady={updateReady} onUpdate={applyUpdate} className="my-banner" />;
73
+ }
74
+ ```
75
+
76
+ `usePwaUpdate()` and `<PwaUpdateBanner>` behave exactly as their React
77
+ counterparts.
78
+
62
79
  ## Framework-agnostic core
63
80
 
64
81
  No React? Drive the controller directly (Preact, Svelte, vanilla):
@@ -0,0 +1,189 @@
1
+ 'use strict';
2
+
3
+ var hooks = require('preact/hooks');
4
+ var jsxRuntime = require('preact/jsx-runtime');
5
+
6
+ // src/preact.tsx
7
+
8
+ // src/message.ts
9
+ var SKIP_WAITING = "SKIP_WAITING";
10
+ var SKIP_WAITING_MESSAGE = { type: SKIP_WAITING };
11
+
12
+ // src/index.ts
13
+ var DEFAULT_POLL_MS = 60 * 60 * 1e3;
14
+ function createPwaUpdater(options = {}) {
15
+ const {
16
+ swUrl = "/sw.js",
17
+ register = true,
18
+ pollIntervalMs = DEFAULT_POLL_MS,
19
+ updateOnFocus = true,
20
+ reloadOnControllerChange = true,
21
+ disabled = false
22
+ } = options;
23
+ const listeners = /* @__PURE__ */ new Set();
24
+ let updateReady = false;
25
+ let waitingWorker = null;
26
+ const getState = () => ({ updateReady });
27
+ const emit = () => {
28
+ const state = getState();
29
+ for (const listener of listeners) listener(state);
30
+ };
31
+ const subscribe = (listener) => {
32
+ listeners.add(listener);
33
+ return () => listeners.delete(listener);
34
+ };
35
+ const supported = typeof navigator !== "undefined" && "serviceWorker" in navigator;
36
+ if (disabled || !supported) {
37
+ return {
38
+ subscribe,
39
+ getState,
40
+ applyUpdate() {
41
+ },
42
+ destroy() {
43
+ listeners.clear();
44
+ }
45
+ };
46
+ }
47
+ const container = navigator.serviceWorker;
48
+ let pollId = null;
49
+ let removeFocusListeners = null;
50
+ let reloading = false;
51
+ let destroyed = false;
52
+ const markReady = (worker) => {
53
+ waitingWorker = worker;
54
+ if (!updateReady) {
55
+ updateReady = true;
56
+ emit();
57
+ }
58
+ };
59
+ const onControllerChange = () => {
60
+ if (!reloadOnControllerChange || reloading) return;
61
+ reloading = true;
62
+ if (typeof location !== "undefined") location.reload();
63
+ };
64
+ container.addEventListener("controllerchange", onControllerChange);
65
+ const watchInstalling = (registration) => {
66
+ const installing = registration.installing;
67
+ if (!installing) return;
68
+ installing.addEventListener("statechange", () => {
69
+ if (installing.state === "installed" && container.controller) {
70
+ markReady(installing);
71
+ }
72
+ });
73
+ };
74
+ const wireFocusChecks = (registration) => {
75
+ if (!updateOnFocus) return;
76
+ const check = () => {
77
+ registration.update().catch(() => {
78
+ });
79
+ };
80
+ const onFocus = () => check();
81
+ const onVisibility = () => {
82
+ if (typeof document !== "undefined" && document.visibilityState === "visible") {
83
+ check();
84
+ }
85
+ };
86
+ if (typeof window !== "undefined") window.addEventListener("focus", onFocus);
87
+ if (typeof document !== "undefined") {
88
+ document.addEventListener("visibilitychange", onVisibility);
89
+ }
90
+ removeFocusListeners = () => {
91
+ if (typeof window !== "undefined") window.removeEventListener("focus", onFocus);
92
+ if (typeof document !== "undefined") {
93
+ document.removeEventListener("visibilitychange", onVisibility);
94
+ }
95
+ };
96
+ };
97
+ const registrationPromise = register ? container.register(swUrl) : container.ready;
98
+ registrationPromise.then((registration) => {
99
+ if (destroyed) return;
100
+ if (registration.waiting) markReady(registration.waiting);
101
+ registration.addEventListener("updatefound", () => watchInstalling(registration));
102
+ if (pollIntervalMs > 0) {
103
+ pollId = setInterval(() => {
104
+ registration.update().catch(() => {
105
+ });
106
+ }, pollIntervalMs);
107
+ }
108
+ wireFocusChecks(registration);
109
+ }).catch(() => {
110
+ });
111
+ return {
112
+ subscribe,
113
+ getState,
114
+ applyUpdate() {
115
+ if (waitingWorker) waitingWorker.postMessage(SKIP_WAITING_MESSAGE);
116
+ },
117
+ destroy() {
118
+ destroyed = true;
119
+ if (pollId !== null) clearInterval(pollId);
120
+ removeFocusListeners?.();
121
+ container.removeEventListener("controllerchange", onControllerChange);
122
+ listeners.clear();
123
+ }
124
+ };
125
+ }
126
+ function usePwaUpdate(options = {}) {
127
+ const { swUrl, pollIntervalMs, reloadOnControllerChange, disabled } = options;
128
+ const [updateReady, setUpdateReady] = hooks.useState(false);
129
+ const updaterRef = hooks.useRef(null);
130
+ hooks.useEffect(() => {
131
+ const updater = createPwaUpdater({
132
+ swUrl,
133
+ pollIntervalMs,
134
+ reloadOnControllerChange,
135
+ disabled
136
+ });
137
+ updaterRef.current = updater;
138
+ setUpdateReady(updater.getState().updateReady);
139
+ const unsubscribe = updater.subscribe((state) => setUpdateReady(state.updateReady));
140
+ return () => {
141
+ unsubscribe();
142
+ updater.destroy();
143
+ updaterRef.current = null;
144
+ };
145
+ }, [swUrl, pollIntervalMs, reloadOnControllerChange, disabled]);
146
+ return {
147
+ updateReady,
148
+ applyUpdate: () => updaterRef.current?.applyUpdate()
149
+ };
150
+ }
151
+ var DEFAULT_LABELS = {
152
+ title: "New version available",
153
+ body: "Reload to get the latest version.",
154
+ update: "Update",
155
+ dismiss: "Later",
156
+ close: "Close"
157
+ };
158
+ function PwaUpdateBanner({
159
+ updateReady,
160
+ onUpdate,
161
+ onDismiss,
162
+ labels,
163
+ className
164
+ }) {
165
+ if (!updateReady) return null;
166
+ const l = { ...DEFAULT_LABELS, ...labels };
167
+ const dismiss = () => onDismiss?.();
168
+ return /* @__PURE__ */ jsxRuntime.jsxs("div", { role: "status", "aria-live": "polite", "data-testid": "pwa-update-banner", className, children: [
169
+ /* @__PURE__ */ jsxRuntime.jsx("p", { "data-testid": "pwa-update-title", children: l.title }),
170
+ l.body ? /* @__PURE__ */ jsxRuntime.jsx("p", { "data-testid": "pwa-update-body", children: l.body }) : null,
171
+ /* @__PURE__ */ jsxRuntime.jsx("button", { type: "button", "data-testid": "pwa-update-confirm", onClick: onUpdate, children: l.update }),
172
+ /* @__PURE__ */ jsxRuntime.jsx("button", { type: "button", "data-testid": "pwa-update-dismiss", onClick: dismiss, children: l.dismiss }),
173
+ /* @__PURE__ */ jsxRuntime.jsx(
174
+ "button",
175
+ {
176
+ type: "button",
177
+ "data-testid": "pwa-update-close",
178
+ "aria-label": l.close,
179
+ onClick: dismiss,
180
+ children: "\xD7"
181
+ }
182
+ )
183
+ ] });
184
+ }
185
+
186
+ exports.PwaUpdateBanner = PwaUpdateBanner;
187
+ exports.usePwaUpdate = usePwaUpdate;
188
+ //# sourceMappingURL=preact.cjs.map
189
+ //# sourceMappingURL=preact.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/message.ts","../src/index.ts","../src/preact.tsx"],"names":["useState","useRef","useEffect","jsxs","jsx"],"mappings":"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","file":"preact.cjs","sourcesContent":["/**\n * The single source of the client↔service-worker \"activate the waiting worker\"\n * message. Both the client controller (`applyUpdate`) and the SW helper\n * (`listenForSkipWaiting`) import it so the contract can never drift.\n */\nexport const SKIP_WAITING = \"SKIP_WAITING\" as const;\n\nexport const SKIP_WAITING_MESSAGE = { type: SKIP_WAITING } as const;\n\nexport type SkipWaitingMessage = typeof SKIP_WAITING_MESSAGE;\n","import { SKIP_WAITING_MESSAGE } from \"./message.js\";\n\nexport { SKIP_WAITING, SKIP_WAITING_MESSAGE } from \"./message.js\";\nexport type { SkipWaitingMessage } from \"./message.js\";\n\nexport interface PwaUpdaterOptions {\n /** Service-worker script URL to register. Default `/sw.js`. */\n swUrl?: string;\n /**\n * Register `swUrl` ourselves (manual `sw.js` style). Set `false` when the\n * app already registers the SW (e.g. Serwist's `registerSW` / `@serwist/next`)\n * — the updater then attaches to the existing registration via\n * `navigator.serviceWorker.ready` instead of registering again. Default `true`.\n */\n register?: boolean;\n /** How often to poll `registration.update()` (ms). `0` disables. Default 60 min. */\n pollIntervalMs?: number;\n /**\n * Also check for an update when the tab regains focus / becomes visible.\n * A constantly-focused tab never fires `visibilitychange`, so `focus` is the\n * one that catches \"user came back to the app\". Default `true`.\n */\n updateOnFocus?: boolean;\n /** Reload the page once the new worker takes control. Default `true`. */\n reloadOnControllerChange?: boolean;\n /**\n * Consumer guard. When `true` the updater is an inert no-op (registers\n * nothing). Pass your own policy, e.g. `disabled: isNativeCapacitor || isDev`\n * — the package never hardcodes an environment or a `.native` check.\n */\n disabled?: boolean;\n}\n\nexport interface PwaUpdaterState {\n /** A new service worker is installed and waiting to activate. */\n updateReady: boolean;\n}\n\nexport interface PwaUpdater {\n /** Subscribe to state changes. Returns an unsubscribe fn. */\n subscribe(listener: (state: PwaUpdaterState) => void): () => void;\n getState(): PwaUpdaterState;\n /** Tell the waiting worker to activate (posts SKIP_WAITING). No-op if none waits. */\n applyUpdate(): void;\n /** Stop polling + remove all listeners. */\n destroy(): void;\n}\n\nconst DEFAULT_POLL_MS = 60 * 60 * 1000;\n\n/**\n * Framework- and bundler-agnostic controller for the PWA \"new version\n * available\" lifecycle: register (or attach) → detect a waiting worker\n * (suppressing the first install) → let the consumer activate it → reload on\n * takeover, re-checking on interval + focus/visibility.\n *\n * Distilled from the pattern hand-rolled across the fleet (fds, cardmem,\n * pitch-vault). Works with any service worker (Serwist, Workbox, hand-rolled)\n * as long as the SW answers a `SKIP_WAITING` message — see `@broberg/pwa/sw`.\n */\nexport function createPwaUpdater(options: PwaUpdaterOptions = {}): PwaUpdater {\n const {\n swUrl = \"/sw.js\",\n register = true,\n pollIntervalMs = DEFAULT_POLL_MS,\n updateOnFocus = true,\n reloadOnControllerChange = true,\n disabled = false,\n } = options;\n\n const listeners = new Set<(state: PwaUpdaterState) => void>();\n let updateReady = false;\n let waitingWorker: ServiceWorker | null = null;\n\n const getState = (): PwaUpdaterState => ({ updateReady });\n const emit = (): void => {\n const state = getState();\n for (const listener of listeners) listener(state);\n };\n const subscribe = (listener: (state: PwaUpdaterState) => void): (() => void) => {\n listeners.add(listener);\n return () => listeners.delete(listener);\n };\n\n const supported =\n typeof navigator !== \"undefined\" && \"serviceWorker\" in navigator;\n\n // Inert no-op: disabled by the consumer, or no service-worker support at all.\n if (disabled || !supported) {\n return {\n subscribe,\n getState,\n applyUpdate() {},\n destroy() {\n listeners.clear();\n },\n };\n }\n\n const container = navigator.serviceWorker;\n let pollId: ReturnType<typeof setInterval> | null = null;\n let removeFocusListeners: (() => void) | null = null;\n let reloading = false;\n let destroyed = false;\n\n const markReady = (worker: ServiceWorker): void => {\n waitingWorker = worker;\n if (!updateReady) {\n updateReady = true;\n emit();\n }\n };\n\n const onControllerChange = (): void => {\n if (!reloadOnControllerChange || reloading) return;\n reloading = true; // guard against a reload-loop\n if (typeof location !== \"undefined\") location.reload();\n };\n container.addEventListener(\"controllerchange\", onControllerChange);\n\n const watchInstalling = (registration: ServiceWorkerRegistration): void => {\n const installing = registration.installing;\n if (!installing) return;\n installing.addEventListener(\"statechange\", () => {\n // A new worker reaching `installed` WHILE a controller already exists is\n // an update. Reaching `installed` with no controller is the FIRST install\n // — nothing to update, so we stay quiet.\n if (installing.state === \"installed\" && container.controller) {\n markReady(installing);\n }\n });\n };\n\n const wireFocusChecks = (registration: ServiceWorkerRegistration): void => {\n if (!updateOnFocus) return;\n const check = (): void => {\n registration.update().catch(() => {});\n };\n const onFocus = (): void => check();\n const onVisibility = (): void => {\n if (typeof document !== \"undefined\" && document.visibilityState === \"visible\") {\n check();\n }\n };\n if (typeof window !== \"undefined\") window.addEventListener(\"focus\", onFocus);\n if (typeof document !== \"undefined\") {\n document.addEventListener(\"visibilitychange\", onVisibility);\n }\n removeFocusListeners = () => {\n if (typeof window !== \"undefined\") window.removeEventListener(\"focus\", onFocus);\n if (typeof document !== \"undefined\") {\n document.removeEventListener(\"visibilitychange\", onVisibility);\n }\n };\n };\n\n // Manual style registers swUrl; serwist/registerSW style attaches to the\n // registration the app already made via `.ready`.\n const registrationPromise = register ? container.register(swUrl) : container.ready;\n\n registrationPromise\n .then((registration) => {\n if (destroyed) return;\n if (registration.waiting) markReady(registration.waiting);\n registration.addEventListener(\"updatefound\", () => watchInstalling(registration));\n if (pollIntervalMs > 0) {\n pollId = setInterval(() => {\n registration.update().catch(() => {\n // ignore transient update-check network errors\n });\n }, pollIntervalMs);\n }\n wireFocusChecks(registration);\n })\n .catch(() => {\n // registration failed — stay inert rather than throw into the app\n });\n\n return {\n subscribe,\n getState,\n applyUpdate() {\n if (waitingWorker) waitingWorker.postMessage(SKIP_WAITING_MESSAGE);\n },\n destroy() {\n destroyed = true;\n if (pollId !== null) clearInterval(pollId);\n removeFocusListeners?.();\n container.removeEventListener(\"controllerchange\", onControllerChange);\n listeners.clear();\n },\n };\n}\n","/** @jsxImportSource preact */\n\nimport { useEffect, useRef, useState } from \"preact/hooks\";\nimport { createPwaUpdater, type PwaUpdater, type PwaUpdaterOptions } from \"./index.js\";\n\n/**\n * Preact binding for {@link createPwaUpdater} — API-identical to the React\n * adapter, for the Stack B (Bun/Hono/Preact/Vite) apps. Returns whether an\n * update is ready plus the `applyUpdate` action; the controller is created\n * once and torn down on unmount.\n */\nexport function usePwaUpdate(options: PwaUpdaterOptions = {}): {\n updateReady: boolean;\n applyUpdate: () => void;\n} {\n const { swUrl, pollIntervalMs, reloadOnControllerChange, disabled } = options;\n const [updateReady, setUpdateReady] = useState(false);\n const updaterRef = useRef<PwaUpdater | null>(null);\n\n useEffect(() => {\n const updater = createPwaUpdater({\n swUrl,\n pollIntervalMs,\n reloadOnControllerChange,\n disabled,\n });\n updaterRef.current = updater;\n setUpdateReady(updater.getState().updateReady);\n const unsubscribe = updater.subscribe((state) => setUpdateReady(state.updateReady));\n return () => {\n unsubscribe();\n updater.destroy();\n updaterRef.current = null;\n };\n }, [swUrl, pollIntervalMs, reloadOnControllerChange, disabled]);\n\n return {\n updateReady,\n applyUpdate: () => updaterRef.current?.applyUpdate(),\n };\n}\n\nexport interface PwaUpdateBannerLabels {\n title?: string;\n body?: string;\n update?: string;\n dismiss?: string;\n close?: string;\n}\n\nconst DEFAULT_LABELS: Required<PwaUpdateBannerLabels> = {\n title: \"New version available\",\n body: \"Reload to get the latest version.\",\n update: \"Update\",\n dismiss: \"Later\",\n close: \"Close\",\n};\n\nexport interface PwaUpdateBannerProps {\n /** From `usePwaUpdate().updateReady`. Nothing renders when false. */\n updateReady: boolean;\n /** From `usePwaUpdate().applyUpdate`. */\n onUpdate: () => void;\n /** Optional dismiss handler (wired to both \"Later\" and the close ✕). */\n onDismiss?: () => void;\n /** Override any of the built-in strings (i18n). */\n labels?: PwaUpdateBannerLabels;\n /** Your class(es) — the skeleton carries NO colours or design-system styling. */\n className?: string;\n}\n\n/**\n * Unstyled, accessible \"new version available\" banner skeleton for Preact.\n * Structure + a11y + stable testids only; you own the look via `className`\n * and your design tokens. Prefer {@link usePwaUpdate} directly if you'd rather\n * build the UI yourself.\n */\nexport function PwaUpdateBanner({\n updateReady,\n onUpdate,\n onDismiss,\n labels,\n className,\n}: PwaUpdateBannerProps) {\n if (!updateReady) return null;\n const l = { ...DEFAULT_LABELS, ...labels };\n const dismiss = () => onDismiss?.();\n return (\n <div role=\"status\" aria-live=\"polite\" data-testid=\"pwa-update-banner\" className={className}>\n <p data-testid=\"pwa-update-title\">{l.title}</p>\n {l.body ? <p data-testid=\"pwa-update-body\">{l.body}</p> : null}\n <button type=\"button\" data-testid=\"pwa-update-confirm\" onClick={onUpdate}>\n {l.update}\n </button>\n <button type=\"button\" data-testid=\"pwa-update-dismiss\" onClick={dismiss}>\n {l.dismiss}\n </button>\n <button\n type=\"button\"\n data-testid=\"pwa-update-close\"\n aria-label={l.close}\n onClick={dismiss}\n >\n {\"×\"}\n </button>\n </div>\n );\n}\n"]}
@@ -0,0 +1,68 @@
1
+ import * as preact from 'preact';
2
+
3
+ interface PwaUpdaterOptions {
4
+ /** Service-worker script URL to register. Default `/sw.js`. */
5
+ swUrl?: string;
6
+ /**
7
+ * Register `swUrl` ourselves (manual `sw.js` style). Set `false` when the
8
+ * app already registers the SW (e.g. Serwist's `registerSW` / `@serwist/next`)
9
+ * — the updater then attaches to the existing registration via
10
+ * `navigator.serviceWorker.ready` instead of registering again. Default `true`.
11
+ */
12
+ register?: boolean;
13
+ /** How often to poll `registration.update()` (ms). `0` disables. Default 60 min. */
14
+ pollIntervalMs?: number;
15
+ /**
16
+ * Also check for an update when the tab regains focus / becomes visible.
17
+ * A constantly-focused tab never fires `visibilitychange`, so `focus` is the
18
+ * one that catches "user came back to the app". Default `true`.
19
+ */
20
+ updateOnFocus?: boolean;
21
+ /** Reload the page once the new worker takes control. Default `true`. */
22
+ reloadOnControllerChange?: boolean;
23
+ /**
24
+ * Consumer guard. When `true` the updater is an inert no-op (registers
25
+ * nothing). Pass your own policy, e.g. `disabled: isNativeCapacitor || isDev`
26
+ * — the package never hardcodes an environment or a `.native` check.
27
+ */
28
+ disabled?: boolean;
29
+ }
30
+
31
+ /**
32
+ * Preact binding for {@link createPwaUpdater} — API-identical to the React
33
+ * adapter, for the Stack B (Bun/Hono/Preact/Vite) apps. Returns whether an
34
+ * update is ready plus the `applyUpdate` action; the controller is created
35
+ * once and torn down on unmount.
36
+ */
37
+ declare function usePwaUpdate(options?: PwaUpdaterOptions): {
38
+ updateReady: boolean;
39
+ applyUpdate: () => void;
40
+ };
41
+ interface PwaUpdateBannerLabels {
42
+ title?: string;
43
+ body?: string;
44
+ update?: string;
45
+ dismiss?: string;
46
+ close?: string;
47
+ }
48
+ interface PwaUpdateBannerProps {
49
+ /** From `usePwaUpdate().updateReady`. Nothing renders when false. */
50
+ updateReady: boolean;
51
+ /** From `usePwaUpdate().applyUpdate`. */
52
+ onUpdate: () => void;
53
+ /** Optional dismiss handler (wired to both "Later" and the close ✕). */
54
+ onDismiss?: () => void;
55
+ /** Override any of the built-in strings (i18n). */
56
+ labels?: PwaUpdateBannerLabels;
57
+ /** Your class(es) — the skeleton carries NO colours or design-system styling. */
58
+ className?: string;
59
+ }
60
+ /**
61
+ * Unstyled, accessible "new version available" banner skeleton for Preact.
62
+ * Structure + a11y + stable testids only; you own the look via `className`
63
+ * and your design tokens. Prefer {@link usePwaUpdate} directly if you'd rather
64
+ * build the UI yourself.
65
+ */
66
+ declare function PwaUpdateBanner({ updateReady, onUpdate, onDismiss, labels, className, }: PwaUpdateBannerProps): preact.JSX.Element | null;
67
+
68
+ export { PwaUpdateBanner, type PwaUpdateBannerLabels, type PwaUpdateBannerProps, usePwaUpdate };
@@ -0,0 +1,68 @@
1
+ import * as preact from 'preact';
2
+
3
+ interface PwaUpdaterOptions {
4
+ /** Service-worker script URL to register. Default `/sw.js`. */
5
+ swUrl?: string;
6
+ /**
7
+ * Register `swUrl` ourselves (manual `sw.js` style). Set `false` when the
8
+ * app already registers the SW (e.g. Serwist's `registerSW` / `@serwist/next`)
9
+ * — the updater then attaches to the existing registration via
10
+ * `navigator.serviceWorker.ready` instead of registering again. Default `true`.
11
+ */
12
+ register?: boolean;
13
+ /** How often to poll `registration.update()` (ms). `0` disables. Default 60 min. */
14
+ pollIntervalMs?: number;
15
+ /**
16
+ * Also check for an update when the tab regains focus / becomes visible.
17
+ * A constantly-focused tab never fires `visibilitychange`, so `focus` is the
18
+ * one that catches "user came back to the app". Default `true`.
19
+ */
20
+ updateOnFocus?: boolean;
21
+ /** Reload the page once the new worker takes control. Default `true`. */
22
+ reloadOnControllerChange?: boolean;
23
+ /**
24
+ * Consumer guard. When `true` the updater is an inert no-op (registers
25
+ * nothing). Pass your own policy, e.g. `disabled: isNativeCapacitor || isDev`
26
+ * — the package never hardcodes an environment or a `.native` check.
27
+ */
28
+ disabled?: boolean;
29
+ }
30
+
31
+ /**
32
+ * Preact binding for {@link createPwaUpdater} — API-identical to the React
33
+ * adapter, for the Stack B (Bun/Hono/Preact/Vite) apps. Returns whether an
34
+ * update is ready plus the `applyUpdate` action; the controller is created
35
+ * once and torn down on unmount.
36
+ */
37
+ declare function usePwaUpdate(options?: PwaUpdaterOptions): {
38
+ updateReady: boolean;
39
+ applyUpdate: () => void;
40
+ };
41
+ interface PwaUpdateBannerLabels {
42
+ title?: string;
43
+ body?: string;
44
+ update?: string;
45
+ dismiss?: string;
46
+ close?: string;
47
+ }
48
+ interface PwaUpdateBannerProps {
49
+ /** From `usePwaUpdate().updateReady`. Nothing renders when false. */
50
+ updateReady: boolean;
51
+ /** From `usePwaUpdate().applyUpdate`. */
52
+ onUpdate: () => void;
53
+ /** Optional dismiss handler (wired to both "Later" and the close ✕). */
54
+ onDismiss?: () => void;
55
+ /** Override any of the built-in strings (i18n). */
56
+ labels?: PwaUpdateBannerLabels;
57
+ /** Your class(es) — the skeleton carries NO colours or design-system styling. */
58
+ className?: string;
59
+ }
60
+ /**
61
+ * Unstyled, accessible "new version available" banner skeleton for Preact.
62
+ * Structure + a11y + stable testids only; you own the look via `className`
63
+ * and your design tokens. Prefer {@link usePwaUpdate} directly if you'd rather
64
+ * build the UI yourself.
65
+ */
66
+ declare function PwaUpdateBanner({ updateReady, onUpdate, onDismiss, labels, className, }: PwaUpdateBannerProps): preact.JSX.Element | null;
67
+
68
+ export { PwaUpdateBanner, type PwaUpdateBannerLabels, type PwaUpdateBannerProps, usePwaUpdate };
package/dist/preact.js ADDED
@@ -0,0 +1,186 @@
1
+ import { useState, useRef, useEffect } from 'preact/hooks';
2
+ import { jsxs, jsx } from 'preact/jsx-runtime';
3
+
4
+ // src/preact.tsx
5
+
6
+ // src/message.ts
7
+ var SKIP_WAITING = "SKIP_WAITING";
8
+ var SKIP_WAITING_MESSAGE = { type: SKIP_WAITING };
9
+
10
+ // src/index.ts
11
+ var DEFAULT_POLL_MS = 60 * 60 * 1e3;
12
+ function createPwaUpdater(options = {}) {
13
+ const {
14
+ swUrl = "/sw.js",
15
+ register = true,
16
+ pollIntervalMs = DEFAULT_POLL_MS,
17
+ updateOnFocus = true,
18
+ reloadOnControllerChange = true,
19
+ disabled = false
20
+ } = options;
21
+ const listeners = /* @__PURE__ */ new Set();
22
+ let updateReady = false;
23
+ let waitingWorker = null;
24
+ const getState = () => ({ updateReady });
25
+ const emit = () => {
26
+ const state = getState();
27
+ for (const listener of listeners) listener(state);
28
+ };
29
+ const subscribe = (listener) => {
30
+ listeners.add(listener);
31
+ return () => listeners.delete(listener);
32
+ };
33
+ const supported = typeof navigator !== "undefined" && "serviceWorker" in navigator;
34
+ if (disabled || !supported) {
35
+ return {
36
+ subscribe,
37
+ getState,
38
+ applyUpdate() {
39
+ },
40
+ destroy() {
41
+ listeners.clear();
42
+ }
43
+ };
44
+ }
45
+ const container = navigator.serviceWorker;
46
+ let pollId = null;
47
+ let removeFocusListeners = null;
48
+ let reloading = false;
49
+ let destroyed = false;
50
+ const markReady = (worker) => {
51
+ waitingWorker = worker;
52
+ if (!updateReady) {
53
+ updateReady = true;
54
+ emit();
55
+ }
56
+ };
57
+ const onControllerChange = () => {
58
+ if (!reloadOnControllerChange || reloading) return;
59
+ reloading = true;
60
+ if (typeof location !== "undefined") location.reload();
61
+ };
62
+ container.addEventListener("controllerchange", onControllerChange);
63
+ const watchInstalling = (registration) => {
64
+ const installing = registration.installing;
65
+ if (!installing) return;
66
+ installing.addEventListener("statechange", () => {
67
+ if (installing.state === "installed" && container.controller) {
68
+ markReady(installing);
69
+ }
70
+ });
71
+ };
72
+ const wireFocusChecks = (registration) => {
73
+ if (!updateOnFocus) return;
74
+ const check = () => {
75
+ registration.update().catch(() => {
76
+ });
77
+ };
78
+ const onFocus = () => check();
79
+ const onVisibility = () => {
80
+ if (typeof document !== "undefined" && document.visibilityState === "visible") {
81
+ check();
82
+ }
83
+ };
84
+ if (typeof window !== "undefined") window.addEventListener("focus", onFocus);
85
+ if (typeof document !== "undefined") {
86
+ document.addEventListener("visibilitychange", onVisibility);
87
+ }
88
+ removeFocusListeners = () => {
89
+ if (typeof window !== "undefined") window.removeEventListener("focus", onFocus);
90
+ if (typeof document !== "undefined") {
91
+ document.removeEventListener("visibilitychange", onVisibility);
92
+ }
93
+ };
94
+ };
95
+ const registrationPromise = register ? container.register(swUrl) : container.ready;
96
+ registrationPromise.then((registration) => {
97
+ if (destroyed) return;
98
+ if (registration.waiting) markReady(registration.waiting);
99
+ registration.addEventListener("updatefound", () => watchInstalling(registration));
100
+ if (pollIntervalMs > 0) {
101
+ pollId = setInterval(() => {
102
+ registration.update().catch(() => {
103
+ });
104
+ }, pollIntervalMs);
105
+ }
106
+ wireFocusChecks(registration);
107
+ }).catch(() => {
108
+ });
109
+ return {
110
+ subscribe,
111
+ getState,
112
+ applyUpdate() {
113
+ if (waitingWorker) waitingWorker.postMessage(SKIP_WAITING_MESSAGE);
114
+ },
115
+ destroy() {
116
+ destroyed = true;
117
+ if (pollId !== null) clearInterval(pollId);
118
+ removeFocusListeners?.();
119
+ container.removeEventListener("controllerchange", onControllerChange);
120
+ listeners.clear();
121
+ }
122
+ };
123
+ }
124
+ function usePwaUpdate(options = {}) {
125
+ const { swUrl, pollIntervalMs, reloadOnControllerChange, disabled } = options;
126
+ const [updateReady, setUpdateReady] = useState(false);
127
+ const updaterRef = useRef(null);
128
+ useEffect(() => {
129
+ const updater = createPwaUpdater({
130
+ swUrl,
131
+ pollIntervalMs,
132
+ reloadOnControllerChange,
133
+ disabled
134
+ });
135
+ updaterRef.current = updater;
136
+ setUpdateReady(updater.getState().updateReady);
137
+ const unsubscribe = updater.subscribe((state) => setUpdateReady(state.updateReady));
138
+ return () => {
139
+ unsubscribe();
140
+ updater.destroy();
141
+ updaterRef.current = null;
142
+ };
143
+ }, [swUrl, pollIntervalMs, reloadOnControllerChange, disabled]);
144
+ return {
145
+ updateReady,
146
+ applyUpdate: () => updaterRef.current?.applyUpdate()
147
+ };
148
+ }
149
+ var DEFAULT_LABELS = {
150
+ title: "New version available",
151
+ body: "Reload to get the latest version.",
152
+ update: "Update",
153
+ dismiss: "Later",
154
+ close: "Close"
155
+ };
156
+ function PwaUpdateBanner({
157
+ updateReady,
158
+ onUpdate,
159
+ onDismiss,
160
+ labels,
161
+ className
162
+ }) {
163
+ if (!updateReady) return null;
164
+ const l = { ...DEFAULT_LABELS, ...labels };
165
+ const dismiss = () => onDismiss?.();
166
+ return /* @__PURE__ */ jsxs("div", { role: "status", "aria-live": "polite", "data-testid": "pwa-update-banner", className, children: [
167
+ /* @__PURE__ */ jsx("p", { "data-testid": "pwa-update-title", children: l.title }),
168
+ l.body ? /* @__PURE__ */ jsx("p", { "data-testid": "pwa-update-body", children: l.body }) : null,
169
+ /* @__PURE__ */ jsx("button", { type: "button", "data-testid": "pwa-update-confirm", onClick: onUpdate, children: l.update }),
170
+ /* @__PURE__ */ jsx("button", { type: "button", "data-testid": "pwa-update-dismiss", onClick: dismiss, children: l.dismiss }),
171
+ /* @__PURE__ */ jsx(
172
+ "button",
173
+ {
174
+ type: "button",
175
+ "data-testid": "pwa-update-close",
176
+ "aria-label": l.close,
177
+ onClick: dismiss,
178
+ children: "\xD7"
179
+ }
180
+ )
181
+ ] });
182
+ }
183
+
184
+ export { PwaUpdateBanner, usePwaUpdate };
185
+ //# sourceMappingURL=preact.js.map
186
+ //# sourceMappingURL=preact.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/message.ts","../src/index.ts","../src/preact.tsx"],"names":[],"mappings":"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","file":"preact.js","sourcesContent":["/**\n * The single source of the client↔service-worker \"activate the waiting worker\"\n * message. Both the client controller (`applyUpdate`) and the SW helper\n * (`listenForSkipWaiting`) import it so the contract can never drift.\n */\nexport const SKIP_WAITING = \"SKIP_WAITING\" as const;\n\nexport const SKIP_WAITING_MESSAGE = { type: SKIP_WAITING } as const;\n\nexport type SkipWaitingMessage = typeof SKIP_WAITING_MESSAGE;\n","import { SKIP_WAITING_MESSAGE } from \"./message.js\";\n\nexport { SKIP_WAITING, SKIP_WAITING_MESSAGE } from \"./message.js\";\nexport type { SkipWaitingMessage } from \"./message.js\";\n\nexport interface PwaUpdaterOptions {\n /** Service-worker script URL to register. Default `/sw.js`. */\n swUrl?: string;\n /**\n * Register `swUrl` ourselves (manual `sw.js` style). Set `false` when the\n * app already registers the SW (e.g. Serwist's `registerSW` / `@serwist/next`)\n * — the updater then attaches to the existing registration via\n * `navigator.serviceWorker.ready` instead of registering again. Default `true`.\n */\n register?: boolean;\n /** How often to poll `registration.update()` (ms). `0` disables. Default 60 min. */\n pollIntervalMs?: number;\n /**\n * Also check for an update when the tab regains focus / becomes visible.\n * A constantly-focused tab never fires `visibilitychange`, so `focus` is the\n * one that catches \"user came back to the app\". Default `true`.\n */\n updateOnFocus?: boolean;\n /** Reload the page once the new worker takes control. Default `true`. */\n reloadOnControllerChange?: boolean;\n /**\n * Consumer guard. When `true` the updater is an inert no-op (registers\n * nothing). Pass your own policy, e.g. `disabled: isNativeCapacitor || isDev`\n * — the package never hardcodes an environment or a `.native` check.\n */\n disabled?: boolean;\n}\n\nexport interface PwaUpdaterState {\n /** A new service worker is installed and waiting to activate. */\n updateReady: boolean;\n}\n\nexport interface PwaUpdater {\n /** Subscribe to state changes. Returns an unsubscribe fn. */\n subscribe(listener: (state: PwaUpdaterState) => void): () => void;\n getState(): PwaUpdaterState;\n /** Tell the waiting worker to activate (posts SKIP_WAITING). No-op if none waits. */\n applyUpdate(): void;\n /** Stop polling + remove all listeners. */\n destroy(): void;\n}\n\nconst DEFAULT_POLL_MS = 60 * 60 * 1000;\n\n/**\n * Framework- and bundler-agnostic controller for the PWA \"new version\n * available\" lifecycle: register (or attach) → detect a waiting worker\n * (suppressing the first install) → let the consumer activate it → reload on\n * takeover, re-checking on interval + focus/visibility.\n *\n * Distilled from the pattern hand-rolled across the fleet (fds, cardmem,\n * pitch-vault). Works with any service worker (Serwist, Workbox, hand-rolled)\n * as long as the SW answers a `SKIP_WAITING` message — see `@broberg/pwa/sw`.\n */\nexport function createPwaUpdater(options: PwaUpdaterOptions = {}): PwaUpdater {\n const {\n swUrl = \"/sw.js\",\n register = true,\n pollIntervalMs = DEFAULT_POLL_MS,\n updateOnFocus = true,\n reloadOnControllerChange = true,\n disabled = false,\n } = options;\n\n const listeners = new Set<(state: PwaUpdaterState) => void>();\n let updateReady = false;\n let waitingWorker: ServiceWorker | null = null;\n\n const getState = (): PwaUpdaterState => ({ updateReady });\n const emit = (): void => {\n const state = getState();\n for (const listener of listeners) listener(state);\n };\n const subscribe = (listener: (state: PwaUpdaterState) => void): (() => void) => {\n listeners.add(listener);\n return () => listeners.delete(listener);\n };\n\n const supported =\n typeof navigator !== \"undefined\" && \"serviceWorker\" in navigator;\n\n // Inert no-op: disabled by the consumer, or no service-worker support at all.\n if (disabled || !supported) {\n return {\n subscribe,\n getState,\n applyUpdate() {},\n destroy() {\n listeners.clear();\n },\n };\n }\n\n const container = navigator.serviceWorker;\n let pollId: ReturnType<typeof setInterval> | null = null;\n let removeFocusListeners: (() => void) | null = null;\n let reloading = false;\n let destroyed = false;\n\n const markReady = (worker: ServiceWorker): void => {\n waitingWorker = worker;\n if (!updateReady) {\n updateReady = true;\n emit();\n }\n };\n\n const onControllerChange = (): void => {\n if (!reloadOnControllerChange || reloading) return;\n reloading = true; // guard against a reload-loop\n if (typeof location !== \"undefined\") location.reload();\n };\n container.addEventListener(\"controllerchange\", onControllerChange);\n\n const watchInstalling = (registration: ServiceWorkerRegistration): void => {\n const installing = registration.installing;\n if (!installing) return;\n installing.addEventListener(\"statechange\", () => {\n // A new worker reaching `installed` WHILE a controller already exists is\n // an update. Reaching `installed` with no controller is the FIRST install\n // — nothing to update, so we stay quiet.\n if (installing.state === \"installed\" && container.controller) {\n markReady(installing);\n }\n });\n };\n\n const wireFocusChecks = (registration: ServiceWorkerRegistration): void => {\n if (!updateOnFocus) return;\n const check = (): void => {\n registration.update().catch(() => {});\n };\n const onFocus = (): void => check();\n const onVisibility = (): void => {\n if (typeof document !== \"undefined\" && document.visibilityState === \"visible\") {\n check();\n }\n };\n if (typeof window !== \"undefined\") window.addEventListener(\"focus\", onFocus);\n if (typeof document !== \"undefined\") {\n document.addEventListener(\"visibilitychange\", onVisibility);\n }\n removeFocusListeners = () => {\n if (typeof window !== \"undefined\") window.removeEventListener(\"focus\", onFocus);\n if (typeof document !== \"undefined\") {\n document.removeEventListener(\"visibilitychange\", onVisibility);\n }\n };\n };\n\n // Manual style registers swUrl; serwist/registerSW style attaches to the\n // registration the app already made via `.ready`.\n const registrationPromise = register ? container.register(swUrl) : container.ready;\n\n registrationPromise\n .then((registration) => {\n if (destroyed) return;\n if (registration.waiting) markReady(registration.waiting);\n registration.addEventListener(\"updatefound\", () => watchInstalling(registration));\n if (pollIntervalMs > 0) {\n pollId = setInterval(() => {\n registration.update().catch(() => {\n // ignore transient update-check network errors\n });\n }, pollIntervalMs);\n }\n wireFocusChecks(registration);\n })\n .catch(() => {\n // registration failed — stay inert rather than throw into the app\n });\n\n return {\n subscribe,\n getState,\n applyUpdate() {\n if (waitingWorker) waitingWorker.postMessage(SKIP_WAITING_MESSAGE);\n },\n destroy() {\n destroyed = true;\n if (pollId !== null) clearInterval(pollId);\n removeFocusListeners?.();\n container.removeEventListener(\"controllerchange\", onControllerChange);\n listeners.clear();\n },\n };\n}\n","/** @jsxImportSource preact */\n\nimport { useEffect, useRef, useState } from \"preact/hooks\";\nimport { createPwaUpdater, type PwaUpdater, type PwaUpdaterOptions } from \"./index.js\";\n\n/**\n * Preact binding for {@link createPwaUpdater} — API-identical to the React\n * adapter, for the Stack B (Bun/Hono/Preact/Vite) apps. Returns whether an\n * update is ready plus the `applyUpdate` action; the controller is created\n * once and torn down on unmount.\n */\nexport function usePwaUpdate(options: PwaUpdaterOptions = {}): {\n updateReady: boolean;\n applyUpdate: () => void;\n} {\n const { swUrl, pollIntervalMs, reloadOnControllerChange, disabled } = options;\n const [updateReady, setUpdateReady] = useState(false);\n const updaterRef = useRef<PwaUpdater | null>(null);\n\n useEffect(() => {\n const updater = createPwaUpdater({\n swUrl,\n pollIntervalMs,\n reloadOnControllerChange,\n disabled,\n });\n updaterRef.current = updater;\n setUpdateReady(updater.getState().updateReady);\n const unsubscribe = updater.subscribe((state) => setUpdateReady(state.updateReady));\n return () => {\n unsubscribe();\n updater.destroy();\n updaterRef.current = null;\n };\n }, [swUrl, pollIntervalMs, reloadOnControllerChange, disabled]);\n\n return {\n updateReady,\n applyUpdate: () => updaterRef.current?.applyUpdate(),\n };\n}\n\nexport interface PwaUpdateBannerLabels {\n title?: string;\n body?: string;\n update?: string;\n dismiss?: string;\n close?: string;\n}\n\nconst DEFAULT_LABELS: Required<PwaUpdateBannerLabels> = {\n title: \"New version available\",\n body: \"Reload to get the latest version.\",\n update: \"Update\",\n dismiss: \"Later\",\n close: \"Close\",\n};\n\nexport interface PwaUpdateBannerProps {\n /** From `usePwaUpdate().updateReady`. Nothing renders when false. */\n updateReady: boolean;\n /** From `usePwaUpdate().applyUpdate`. */\n onUpdate: () => void;\n /** Optional dismiss handler (wired to both \"Later\" and the close ✕). */\n onDismiss?: () => void;\n /** Override any of the built-in strings (i18n). */\n labels?: PwaUpdateBannerLabels;\n /** Your class(es) — the skeleton carries NO colours or design-system styling. */\n className?: string;\n}\n\n/**\n * Unstyled, accessible \"new version available\" banner skeleton for Preact.\n * Structure + a11y + stable testids only; you own the look via `className`\n * and your design tokens. Prefer {@link usePwaUpdate} directly if you'd rather\n * build the UI yourself.\n */\nexport function PwaUpdateBanner({\n updateReady,\n onUpdate,\n onDismiss,\n labels,\n className,\n}: PwaUpdateBannerProps) {\n if (!updateReady) return null;\n const l = { ...DEFAULT_LABELS, ...labels };\n const dismiss = () => onDismiss?.();\n return (\n <div role=\"status\" aria-live=\"polite\" data-testid=\"pwa-update-banner\" className={className}>\n <p data-testid=\"pwa-update-title\">{l.title}</p>\n {l.body ? <p data-testid=\"pwa-update-body\">{l.body}</p> : null}\n <button type=\"button\" data-testid=\"pwa-update-confirm\" onClick={onUpdate}>\n {l.update}\n </button>\n <button type=\"button\" data-testid=\"pwa-update-dismiss\" onClick={dismiss}>\n {l.dismiss}\n </button>\n <button\n type=\"button\"\n data-testid=\"pwa-update-close\"\n aria-label={l.close}\n onClick={dismiss}\n >\n {\"×\"}\n </button>\n </div>\n );\n}\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@broberg/pwa",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Fleet PWA primitive: a framework-agnostic service-worker update-detection controller (new-version-available lifecycle) + a React hook and unstyled banner skeleton + a service-worker skip-waiting helper. Zero runtime deps; ship-dark.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -27,6 +27,11 @@
27
27
  "types": "./dist/sw.d.ts",
28
28
  "import": "./dist/sw.js",
29
29
  "require": "./dist/sw.cjs"
30
+ },
31
+ "./preact": {
32
+ "types": "./dist/preact.d.ts",
33
+ "import": "./dist/preact.js",
34
+ "require": "./dist/preact.cjs"
30
35
  }
31
36
  },
32
37
  "scripts": {
@@ -35,17 +40,23 @@
35
40
  "typecheck": "tsc --noEmit"
36
41
  },
37
42
  "peerDependencies": {
38
- "react": ">=18.0.0"
43
+ "react": ">=18.0.0",
44
+ "preact": ">=10.0.0"
39
45
  },
40
46
  "peerDependenciesMeta": {
41
47
  "react": {
42
48
  "optional": true
49
+ },
50
+ "preact": {
51
+ "optional": true
43
52
  }
44
53
  },
45
54
  "devDependencies": {
55
+ "@testing-library/preact": "^3.2.3",
46
56
  "@testing-library/react": "^16.0.0",
47
57
  "@types/react": "^19.0.0",
48
58
  "happy-dom": "^15.0.0",
59
+ "preact": "^10.23.0",
49
60
  "react": "^19.0.0",
50
61
  "react-dom": "^19.0.0",
51
62
  "tsup": "^8.3.0",
@@ -65,6 +76,7 @@
65
76
  "serwist",
66
77
  "workbox",
67
78
  "react",
79
+ "preact",
68
80
  "broberg"
69
81
  ],
70
82
  "repository": {