@broberg/pwa

中文文档
0.9.0发布于 2026/10/27MITTypeScriptESM + CJS零依赖构建来源已验证近 30 天下载 3729

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.

原始使用文档
文档目录19 节
  1. 0.9.0 — a stale page reloads itself fresh, once: staleShell…
  2. 0.8.0 — native icon sets from the same SVG (F021.12)
  3. 0.7.0 — four fixes reported by the fleet (F021.11)
  4. 0.6.0 — a classic service worker can use it too: pwa-sw.glo…
  5. 0.5.0 — PNG icons, so «Add to Home Screen» on an iPhone sho…
  6. 0.4.1 — «Update now» now always updates (F054.9)
  7. 0.4.0 — READ, don't remember (F054.8)
  8. Upgrading does NOT fix this on its own if you have your own…
  9. If you need something stricter than 30 minutes
  10. The update lifecycle
  11. React usage
  12. Preact usage
  13. Framework-agnostic core
  14. createPwaUpdater(options)
  15. Service-worker side
  16. Install setup — manifest, icons, meta ( @broberg/pwa/manife…
  17. PNG icons — pwa-icons and @broberg/pwa/raster (0.5.0, F021.…
  18. Gotchas (baked into this package so you don't rediscover th…
  19. License

@broberg/pwa

The fleet's PWA primitive. Today it ships the piece four repos hand-rolled independently — the "a new version is available, tap to reload" lifecycle — as one small, framework- and bundler-agnostic package instead of a fifth copy.

npm i @broberg/pwa

0.9.0 — a stale page reloads itself fresh, once: staleShellGuardScript() (F021.13)

After a deploy, an installed app can start on its stored copy of the page. That copy points at /assets/ files the deploy removed, they 404, and the window stays white. A plain reload can get the same stored page back. This guard is helpdesk's (F047.5/F047.6, in production), ported as is:

import { staleShellGuardScript } from "@broberg/pwa/manifest";

// Vite: first thing in <head>, before your app script
transformIndexHtml: () => [{ tag: "script", children: staleShellGuardScript(), injectTo: "head-prepend" }],
// Next: <Script id="stale-shell" strategy="beforeInteractive">{staleShellGuardScript()}</Script>

When one of the page's OWN same-origin /assets/ scripts or stylesheets fails to load, it navigates to the same URL with ?_v=<time> (an address the browser store has never seen), at most once per 30 s, and removes _v from the address bar on the fresh page. Images, foreign scripts and files outside /assets/ never trigger it. staleShellGuardScript({ storageKey }) sets your own sessionStorage key.

On Fly Live, @broberg/deploy-core ≥ 0.8.2 also serves old hashed assets from the kept deploys (F033.20), so the 404 does not happen in the first place. This guard covers every other host.

0.8.0 — native icon sets from the same SVG (F021.12)

For Capacitor (iOS/Android) and Tauri apps, so none of them keeps its own icon script:

pwa-icons --svg assets/icon.svg --native native-icons --background "#0B7A57"

writes

pathwhat
ios/AppIcon.appiconset/AppIcon-1024.png + Contents.jsonXcode 14+ single-size app icon. No alpha channel — the App Store rejects an icon that has one, so the PNG is re-encoded as RGB, not merely made opaque.
android/mipmap-anydpi-v26/ic_launcher.xml, ic_launcher_round.xmladaptive icon → @color/ic_launcher_background + @mipmap/ic_launcher_foreground
android/mipmap-<dpi>/ic_launcher_foreground.png108dp canvas per density (108–432 px); the motif sits inside the inner 66dp, the only part every launcher mask keeps
android/mipmap-<dpi>/ic_launcher.png, ic_launcher_round.pnglegacy launcher icons, 48–192 px
android/values/ic_launcher_background.xmlthe background colour
desktop/icon.ico, desktop/icon.icnsWindows / macOS (PNG payloads)

--background is required: a white symbol on a transparent canvas renders as an empty icon. Copy the folders into your Xcode/Gradle project yourself — the package does not edit your native project. Programmatic: nativeIconSet(svg, { background, platforms? }) from @broberg/pwa/raster. The web output (rasterizeIconSet, pwa-icons without --native) is unchanged.

0.7.0 — four fixes reported by the fleet (F021.11)

  • No banner on a first visit. A brand-new worker is briefly registration.waiting on EVERY install (the spec's Install algorithm), and the updater checked at exactly that moment — so a first visit showed «Ny version klar» (vn-leker). An update now needs an ACTIVE worker to replace. A real update always has one, also on a page with no controller, so no genuine banner is lost.
  • No English body line by default. labels.body is empty unless you set it; the title says it. (A banner with translated title/buttons used to show «Reload to get the latest version.» underneath — jam.)
  • The iPhone home-screen icon. rasterizeIconSet() now also returns rootFiles: the 180 PNG at /apple-touch-icon.png and /apple-touch-icon-precomposed.png. iOS asks the web ROOT for these on its own; without them it fell back to a letter. Write them to your web root (the pwa-icons bin does, into the parent of --out or --root). pwaMetaTags now puts sizes="180x180" on the apple-touch link.
  • Only PNG in the manifest. Put rasterizeIconSet().icons — and nothing else — in the manifest icons[]. The SVG set from buildIconSet() is source material for the rasterizer; SVG entries in icons[] gave a letter on iOS 26 (jam), and every fleet app that works has PNG only. Pass rasterizeIconSet().appleTouchIcon to pwaMetaTags({ appleTouchIcon }).
  • Use pwaMetaTags for the head, not only your framework's metadata: it writes apple-mobile-web-app-capable, which Next 16's appleWebApp.capable does not (it writes mobile-web-app-capable only).

0.6.0 — a classic service worker can use it too: pwa-sw.global.js (F021.10)

A service worker in public/ is a static file and cannot import — so @broberg/pwa/sw (an ES module) was out of reach. Now:

cp node_modules/@broberg/pwa/dist/pwa-sw.global.js public/   # e.g. in "prebuild"
// public/sw.js
importScripts('/sw.global.js');      // @broberg/webpush, if you use it
importScripts('/pwa-sw.global.js');  // «Opdatér nu» works — the listener is attached for you

Do not call listenForSkipWaiting again in that worker (listeners add up). self.BrobergPwa = { listenForSkipWaiting, SKIP_WAITING } is there if you need the constant. The file is named pwa-sw.global.js so it can sit beside webpush's sw.global.js without overwriting it. Exported as @broberg/pwa/sw.global. Additive — nothing else changed.

0.5.0 — PNG icons, so «Add to Home Screen» on an iPhone shows the logo (F021.9)

New pwa-icons bin + @broberg/pwa/raster — see «PNG icons» below. Additive: nothing existing changed output, and the core entries are still dependency-free.

0.4.1 — «Update now» now always updates (F054.9)

Take 0.4.1 if you show an update button. In 0.4.0 a press could do nothing, forever: on a page opened WITHOUT an active service worker (a shift-reload, or the first page after registration) while a new worker was waiting, the guard that stops a first install from reloading a new visitor also swallowed the takeover the press caused. No reload, and every later press went to a worker that was already active. Reported by the owner after 30 presses in helpdesk.

Now: after applyUpdate() the next takeover always reloads; a press whose worker already activated reloads directly; and an SW without clientsClaim reloads when the worker reaches activated. One reload, never two. reloadOnControllerChange: false still means you reload yourself. No API change; a first install still never reloads on its own.

0.4.0 — READ, don't remember (F054.8)

Take 0.4.0. Before it, updateReady could only ever go up, and only from an event: one read of registration.waiting at attach, plus updatefound. The interval and focus checks called registration.update() and never looked at registration.waiting at all. So the banner got exactly one chance to be seen — dismiss it once, or miss the seconds it was up, and nothing in the package could raise it again for that tab. The client then sat on the old bundle indefinitely.

It fails in the reassuring direction: deploy green, server on the new build, feature live — and the only symptom available to anyone is a person saying a feature is missing. That is how cardmem found it, in their own copy of this pattern.

What changes for you:

0.3.x0.4.0
updateReadyrises once, everre-derived from registration.waiting on every interval / focus / visibility tick
subscribe()emits at most onceemits whenever the answer moves, in both directions
"Later"your problemsnooze() — 30 min by default, persisted, then it comes back

updateReady CAN NOW GO FALSE. If your code assumes it only ever rises — a useState you never reset, a banner you unmount by hand — read that line before upgrading. Everything else is additive.

const { updateReady, applyUpdate, snooze } = usePwaUpdate();
// "Update" → applyUpdate()     "Later" → snooze(), NOT a local setState(false)

The pattern to look for in your own code, named by cardmem when they read this report against their adoption — it is not hypothetical, it is what a consumer writes when the flag can only ever rise:

// ⚠️ the 0.3.x shape: a local mirror, reset only on dismiss
const [needRefresh, setNeedRefresh] = useState(false);
useEffect(() => { if (updateReady) setNeedRefresh(true); }, [updateReady]);
const onDismiss = () => setNeedRefresh(false);   // ← the only way down

That mirror cannot fall when updateReady does, and dismissing it is permanent — which is the original defect, reproduced one layer up. Drop the mirror and render from updateReady directly; wire "Later" to snooze().

So an upgrade here is a small migration, not a free version bump. It is the one place 0.4.0 asks anything of you.

Upgrading does NOT fix this on its own if you have your own dismissal

fd-sundhed found this by reading their own installed code rather than this report, and it is the half that would otherwise produce a green claim and an unchanged user. Their banner carries a second dismissal on top of the hook:

const [dismissed] = useState(() => sessionStorage.getItem(KEY) === "1");
if (!updateReady || dismissed) return null;   // "Later" hides it for the whole tab

The defect is doubled — ours in the hook, theirs in the component — and only ours goes away with a pnpm up. Upgrade, report it fixed, and the person still never sees the banner again.

So before you upgrade, grep your own component for a second way the banner can be hidden: a sessionStorage/localStorage flag, a dismissed state, a useRef that latches. Whatever holds it down has to become the snooze, or the snooze is decorative.

If you need something stricter than 30 minutes

snoozeMs is a plain number, so a large one is available and this package will not stop you. The honest statement is therefore narrower than "a mute is impossible": there is no OPTION named mute, and the default comes back.

If the policy you actually want is "gone for the rest of this tab", express it as scope rather than duration — snoozeStorage: sessionStorage with a long snoozeMs. It dies with the tab instead of pretending to be permanent, so a person who closes and reopens the app is asked again, and nobody has to reason about a timestamp in the year 2255.

snooze() is deliberately not a mute, and there is no option to make it one: a banner that can be silenced forever is the defect above, made official. The snooze is persisted (localStorage by default, snoozeStorage: null for memory-only) so the reload the banner is asking for cannot defeat it.

  • @broberg/pwa — createPwaUpdater(), a zero-dependency controller for the service-worker update lifecycle (works with Serwist, Workbox or a hand-rolled SW).
  • @broberg/pwa/react — usePwaUpdate() hook + an unstyled <PwaUpdateBanner> skeleton (you style it with your own tokens).
  • @broberg/pwa/sw — listenForSkipWaiting(), the service-worker side of the handshake.

Web push notifications are a separate concern — use @broberg/webpush. This package never touches push.

The update lifecycle

A service worker that finds a new version installs it and then waits so it doesn't yank the page out from under the user. @broberg/pwa detects that waiting worker, lets the user choose when to take it, then reloads once it's active:

new deploy → SW installs → SW waiting → banner → user taps Update
  → applyUpdate() posts SKIP_WAITING → SW skipWaiting() → controllerchange → reload

React usage

"use client";
import { usePwaUpdate, PwaUpdateBanner } from "@broberg/pwa/react";

export function PwaUpdater() {
  // Guards are YOUR policy — pass `disabled` for native shells / dev.
  const isNative = document.documentElement.classList.contains("native");
  const { updateReady, applyUpdate } = usePwaUpdate({
    disabled: isNative || process.env.NODE_ENV !== "production",
  });

  return (
    <PwaUpdateBanner
      updateReady={updateReady}
      onUpdate={applyUpdate}
      onDismiss={() => {/* hide for this session; it returns on next load */}}
      className="my-banner"                       // ← you own the styling
      labels={{ title: "Ny version klar", update: "Opdatér", dismiss: "Senere" }}
    />
  );
}

Prefer to build your own UI? Use the hook alone — usePwaUpdate() gives you { updateReady, applyUpdate } and nothing else. The banner is only a convenience skeleton: it renders role="status" + aria-live="polite", carries the stable testids pwa-update-confirm / pwa-update-dismiss / pwa-update-close, and ships no colours or design-system classes.

Preact usage

Same API for the Stack B (Bun / Hono / Preact) apps — just the /preact subpath (preact is the optional peer instead of react):

import { usePwaUpdate, PwaUpdateBanner } from "@broberg/pwa/preact";

export function PwaUpdater() {
  const { updateReady, applyUpdate } = usePwaUpdate({ disabled: isNative || isDev });
  return <PwaUpdateBanner updateReady={updateReady} onUpdate={applyUpdate} className="my-banner" />;
}

usePwaUpdate() and <PwaUpdateBanner> behave exactly as their React counterparts.

Framework-agnostic core

No React? Drive the controller directly (Preact, Svelte, vanilla):

import { createPwaUpdater } from "@broberg/pwa";

const updater = createPwaUpdater({ swUrl: "/sw.js", disabled: isNative });
updater.subscribe(({ updateReady }) => renderBanner(updateReady));
// on the user's "Update" click:
updater.applyUpdate();
// later: updater.destroy();

createPwaUpdater(options)

optiondefaultmeaning
swUrl/sw.jsservice-worker script to register
pollIntervalMs3_600_000how often to check for a new SW; 0 disables
reloadOnControllerChangetruereload the page once the new SW takes control
disabledfalseinert no-op — pass your own guard (native shell, dev)

Returns { subscribe, getState, applyUpdate, destroy }. With no service-worker support (or disabled), it's an inert no-op that never throws.

Service-worker side

Answer the client's activation request in your service worker:

// sw.ts
import { listenForSkipWaiting } from "@broberg/pwa/sw";
listenForSkipWaiting(); // defaults to the worker's own global scope

With Serwist/Workbox, keep activation user-gated:

new Serwist({ /* … */, skipWaiting: false, clientsClaim: true });

Install setup — manifest, icons, meta (@broberg/pwa/manifest, v0.2.0)

The other half of a PWA: the manifest.webmanifest, the icon set, and the apple-touch <meta> tags — all as pure, zero-dep factories so you stop hand-rolling app/manifest.ts, a gen-pwa-icons.cjs script, and a wall of <meta> tags. Icons are emitted as self-contained SVG; for the PNGs an iPhone needs, see «PNG icons» below (0.5.0).

import { defineManifest, serializeManifest, buildIconSet, pwaMetaTags } from "@broberg/pwa/manifest";

// 1. Icons from a monogram (or pass `svg: "<svg…>"` for real artwork).
const { files, icons } = buildIconSet({ monogram: "AK", background: "#141969", color: "#fff" });
//    files → [{ path:"/icons/icon-180.svg", content:"<svg…>", … }, …]  (write to public/)
//    icons → manifest icons[] incl. a padded maskable-512

// 2. Manifest — required members defaulted; `extra` merges last.
const manifest = defineManifest({ name: "Aalborg Klinik", shortName: "AK", themeColor: "#141969", icons });
writeFile("public/manifest.webmanifest", serializeManifest(manifest));

// 3. Head tags — typed descriptors you render in Next metadata / a Hono head / plain HTML.
pwaMetaTags({ themeColor: "#141969", title: "AK" });
// → [{tag:"link",attrs:{rel:"manifest",href:"/manifest.webmanifest"}}, {tag:"meta",attrs:{name:"theme-color",…}}, …]
  • buildIconSet emits apple-touch (180), 192, 512 + a maskable-512 (10 % safe-zone inset) by default; override sizes, basePath, maskable. 180 goes in the apple-touch <link>, not the manifest icons[].
  • Everything is a pure return value — you own the filesystem write and the head render. Runs in a build script, a Route Handler, or the browser.
  • iOS needs PNG. Safari does not use an SVG apple-touch-icon: with only SVG icons, «Add to Home Screen» on an iPhone shows a screenshot of the page instead of the logo. (This README said the opposite until 0.5.0 — it was wrong.)

PNG icons — pwa-icons and @broberg/pwa/raster (0.5.0, F021.9)

One command, run at build time; no sharp script per app:

npm i -D @resvg/resvg-js            # the renderer — an optional peer, prebuilt binaries
npx pwa-icons --svg logo.svg --background "#141969" --out public/icons
#   or: --monogram AK --color "#fff"     (also --base-path /icons)

It writes icon-180/192/512.{svg,png} + icon-maskable-512.{svg,png} and prints the manifest icons[] (PNG) and appleTouchIcon. Point the head tags at the PNG:

pwaMetaTags({ themeColor: "#141969", appleTouchIcon: "/icons/icon-180.png" });

Or in code:

import { buildIconSet } from "@broberg/pwa/manifest";
import { rasterizeIconSet } from "@broberg/pwa/raster";
const { files, icons, appleTouchIcon } = await rasterizeIconSet(buildIconSet({ svg, background: "#141969" }));
// files → [{ path: "/icons/icon-180.png", content: Uint8Array, … }] — you write them

@broberg/pwa and /manifest stay dependency-free; only /raster and the bin need @resvg/resvg-js, and without it they fail with the install command.

Gotchas (baked into this package so you don't rediscover them)

  • First install is suppressed. A worker reaching installed with no existing controller is the first install — there's nothing to update, so no banner.
  • No reload-loop. controllerchange reloads exactly once.
  • Serwist + Next.js needs the webpack builder — next build with Turbopack breaks the serwist SW build. Use next build (webpack).
  • Guards belong to you. This package doesn't sniff NODE_ENV or a .native class — pass disabled so the policy lives in one place in your app.

License

MIT · part of the @broberg/* shared inventory.

版本与发布记录

@broberg/pwa 共发布 13 个版本,首次发布于 2026/07/06;过去 12 个月发布了 13 个版本。最新版本附带 npm provenance 构建来源证明,可追溯到公开的 CI 构建记录。

版本发布日期距今
0.9.0latest2026/10/2712 天前
0.8.02026/10/2514 天前
0.7.02026/10/2217 天前
0.6.02026/10/1129 天前
0.5.02026/10/1029 天前
0.4.12026/10/011 个月前

关于 @broberg/pwa 的常见问题

@broberg/pwa 是什么?

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.

如何安装 @broberg/pwa?

在项目目录运行 npm install @broberg/pwa,或使用 pnpm add / yarn add / bun add。它还提供命令行工具 pwa-icons,可以用 npx pwa-icons 直接运行。

@broberg/pwa 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@broberg/pwa 支持 ESM 还是 CommonJS?

同时提供 ES Module 与 CommonJS 入口,import 和 require() 都可以使用,打包工具会自动选择合适的版本。

@broberg/pwa 可以商用吗?

@broberg/pwa 使用 MIT 许可。宽松许可:允许商业使用、修改和再分发,通常只需在分发时保留原版权与许可声明。以上为概括,不构成法律意见,请以许可原文为准。

@broberg/pwa 的下载量有多少?

根据 npm 官方下载统计,@broberg/pwa 在 2026/10/09 – 2026/11/07 这 30 天内被下载 3,729 次,日均约 124 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@broberg/pwa 还在维护吗?

最新版本 0.9.0 发布于 2026/10/27(12 天前);至今共发布 13 个版本,其中过去 12 个月发布了 13 个;首次发布于 2026/07/06。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。