@12-apps/pwa

中文文档
2.6.2发布于 2026/09/05MITES Module构建来源已验证近 30 天下载 3.3万

Making a web app installable, and telling people it is: the beforeinstallprompt state machine + iOS share-sheet detection + overridable pt-BR copy (.), the install invite (./react), the PER-HOST manifest endpoint and the packaged network-first service wor

原始使用文档
文档目录10 节
  1. Usage
  2. What makes an app installABLE (12-23)
  3. Getting the reload back
  4. Import the gesture from its own path, not the barrel
  5. reloadApp() is not quite location.reload()
  6. What the gesture will not take
  7. Two platforms that share nothing
  8. The iOS instruction is where the design work is
  9. Diagnostics
  10. Tests

@12-apps/pwa

Making a web app installable, and telling people it is.

Captures beforeinstallprompt without ever firing it, recognises the iOS browsers where installation is a manual share-sheet action, and ships an invite that leads with the reason rather than the instruction. Host-agnostic: every string comes from an overridable pt-BR messages layer.

Usage

import { InstallInvite } from "@12-apps/pwa/react";
import { reportWarning } from "@12-apps/observability-frontend";

<InstallInvite
  what={store.displayName}
  enabled={isStoreOwnDomain}
  onDiagnostic={reportWarning}
/>
entrywhat it is
@12-apps/pwauseInstallPrompt, isIosInstallable, isStandalone, isHandheld, the messages layer, registerServiceWorker / postToServiceWorker, and the reload half — needsPullToRefresh, isInstalledHandheld, reloadApp, createPullTracker
@12-apps/pwa/reactInstallInvite, ShareIcon, PullToRefresh
@12-apps/pwa/servercreateApiPwa({ resolveApp }) — the per-host manifest endpoint and the packaged service worker; also buildWebAppManifest and pwaServiceWorkerSource
@12-apps/pwa/honopwaRouter({ resolveApp }), mounted at the origin root. hono is an OPTIONAL peer

The root entry is framework-free, so a host that only needs "can this be installed" does not pull React in through a barrel it did not ask for.

ADOPTING.md is the adoption contract — the config table and the five wiring rules that bite.

What makes an app installABLE (12-23)

The invite above only asks. Two things have to exist first, and both ship here:

  1. A manifest, as an ENDPOINT — never a static file. A PWA's identity IS its origin, and a bundle has exactly one index.html for every tenant it serves, so a static manifest.webmanifest cannot vary by the store the visitor is looking at. resolveApp returning null is a 404, and that 404 is the whole installability gate.
  2. A REGISTERED service worker, on every visit. registerServiceWorker() belongs at app boot, not behind a settings screen: the origin host registered its worker only from enableWebPush(), so a visitor who never opened notification preferences had no worker — and the browser never offered to install the store.
const pwa = pwaRouter({ resolveApp: ({ host }) => appForHost(host) });
app.route('/', pwa.router);           // the manifest, at the ORIGIN ROOT
registerServiceWorker();              // in the SPA's entry, once

The packaged worker is network-first for documents, and that is not a preference. Every hashed filename in a bundled SPA dies with the deploy that produced it, and a history fallback answers a vanished chunk with index.html — which fails on the MIME type, reaches React.lazy, and renders the page blank. A naive cache-first worker pins that old shell and makes the blank page PERMANENT; on an installed app "force-refresh" is advice the user cannot follow.

Getting the reload back

Installing the app takes the address bar away, and with it the reload button — which is fine right up to the moment the app is wedged: a shell from an old deploy, a session that expired without the page noticing, a screen whose one fetch failed. This package already says what that costs, in rule 5 of ADOPTING.md: on an installed app "force-refresh" is advice the user cannot follow. The worker fixes the half that can be fixed without the user. PullToRefresh is the other half.

import { PullToRefresh } from "@12-apps/pwa/react";
import { PULL_TO_REFRESH_MESSAGES } from "@12-apps/pwa";

<PullToRefresh messages={PULL_TO_REFRESH_MESSAGES["pt-BR"]} onDiagnostic={reportWarning}>
  <App />
</PullToRefresh>

Wrapping the whole app costs nothing where it is not needed, because there it mounts nothing at all. needsPullToRefresh() is the gate, and it is true on exactly one combination:

wherehas a reload?why
any browser tabyesaddress bar, plus the browser's own overscroll refresh
Chromium standalone (Android)yesit keeps that overscroll refresh when installed — unless the app itself opted out with overscroll-behavior-y: contain | none
desktop standaloneyesno address bar, but Ctrl+R and a context menu
iOS home screennono chrome, and the pull gesture that reloads in Safari does not reload a standalone web app. The workaround in the wild is delete the icon and add it again

So by default the gesture ships to iOS and nowhere else — not because it would be unsafe elsewhere, but because a platform gesture that already works has native feel and haptics and cannot be lost to a JavaScript failure.

To use it on Android too, pass platform={isInstalledHandheld}. That is a supported choice, not a workaround, and the handover is clean rather than doubled: mounting sets overscroll-behavior-y: contain, which is exactly the property that switches Chromium's own overscroll refresh off. One pull, one gesture, and it is yours — same indicator and same threshold on both platforms. It also fails in the safe direction: if the bundle never runs, the property is never set and Chromium's gesture is still there.

Reach for it when you want one gesture to design and support across every phone, or for an Android browser whose installed apps do not keep the native one. Note what you give up: on Android you are replacing a gesture the platform maintains with one you maintain.

isInstalledHandheld deliberately excludes a DESKTOP installed app — no address bar, but Ctrl+R, a context menu, and no touchscreen to pull on. An end-to-end test driving a desktop browser passes () => true.

Import the gesture from its own path, not the barrel

A host mounts PullToRefresh at its app ROOT, so whatever that import reaches is on its critical path. @12-apps/pwa/react re-exports InstallInvite, and @12-apps/pwa re-exports the invite's message packs and the beforeinstallprompt state machine — so importing the gesture from either barrel puts the invite in the entry's module graph, and a bundler then folds the invite's small chunk down into the entry.

That is measured, not theoretical. In a host that mounts the gesture at its root, the install invite moved out of a lazy chunk and into the entry chunk: +9.3 KiB raw / +2.4 KiB brotli of first-paint code that only ever runs on a checkout confirmation screen.

// on the critical path — reaches the gesture and nothing else
import { PullToRefresh } from "@12-apps/pwa/react/pull-to-refresh";
import { PULL_TO_REFRESH_MESSAGES } from "@12-apps/pwa/pull-to-refresh-locales";
import { isInstalledHandheld } from "@12-apps/pwa/reload";

The barrels are still correct for a host importing the invite anyway, or one that does not gate on bundle size. Three splits make the narrow path possible and none of them change a public name: platform.ts holds the three predicates with no React, pull-to-refresh.{pt-BR,en-US}.ts hold the gesture's copy away from the invite's, and every original module re-exports what moved.

reloadApp() is not quite location.reload()

The open document is controlled by whichever worker was active when it loaded, and the browser's own update check races the navigation rather than preceding it — so a reload issued the moment a deploy lands can still be served by the outgoing worker, handing back the very shell the user was trying to escape. reloadApp() asks for the update first and waits for it, bounded (2s by default) because the person who just pulled down is usually the person whose network is having a bad day. Every failure path still reloads: a reload the user asked for is never something the app declines to do.

What the gesture will not take

Three things it must not break, each prevented by construction rather than by a check somebody has to remember:

  • Scrolling. The first 12px of travel are watched and not consumed — preventDefault() is called only once the tracker claims the gesture, which it never does for a finger moving up or sideways.
  • Anything in a portal. The listeners are on the component's own subtree, so a MUI Drawer or Dialog — rendered into document.body, a sibling of everything inside — cannot reach them.
  • Nested scrollers. A list already scrolled down keeps its own pull. A region that wants its vertical drags for something else says so in the markup: data-pull-refresh="off".

The wrapper is display: contents, so it changes no layout: children stay their parent's own flex or grid items. offsetTop and zIndex place the indicator above the host's fixed chrome — exposed rather than hard-coded, because the app that adopted the install invite had to override its fixed placement with !important.

Two platforms that share nothing

Chromium fires beforeinstallprompt, which is captured and held — never fired automatically. A browser lets a page ask once, and asking on a first visit, before the visitor knows what the app is, spends that one chance on a near-certain "no" the browser then remembers for a long time. The button is the visitor's.

Note it fires on desktop as readily as on Android, so the copy is chosen by (pointer: coarse) rather than assuming a phone.

iOS has no API at all, in any browser. Not provisionally: there is no beforeinstallprompt in Safari 26.0–26.6 or 27 beta, MDN's compat data records safari: false with safari_ios and webview_ios mirroring it — and webview_ios is what every iOS browser is, Chrome included — and WebKit's standards position on the Web Install API (navigator.install) is oppose. Design for the share sheet.

isIosInstallable() deliberately does not check which browser. Excluding CriOS/FxiOS/EdgiOS was right before March 2023 and a bug since: iOS 16.4 opened "Add to Home Screen" to any browser with the com.apple.developer.web-browser entitlement, so excluding them told a large share of iPhone users nothing at all.

The iOS instruction is where the design work is

A one-tap button converts on its own. A written instruction has to survive being read, understood, and then acted on against a control the person has never deliberately looked at — in the two seconds they spend glancing at a banner. Three things follow, each replacing something the obvious version got wrong:

  1. The reason leads. The first version opened with "add to your Home Screen" and demoted the payoff to grey caption text. Nobody adds a site to their Home Screen because they want to add a site to their Home Screen. On iOS the payoff is also literally gated — web push does not exist outside an installed app — so "get told when it is ready" is unavailable until they do it. That is the headline.
  2. The glyph, not the word. "Tap Share" asks for a translation from a word to a shape. ShareIcon is the same shape that is in the browser chrome, so there is nothing to translate. Drawn inline: the SF Symbol is Apple's, and an icon font would be a network round-trip in a component whose whole job is to be understood immediately.
  3. It points. Safari's share control is in the bottom bar, and a banner at the top of the page points at nothing. placement="anchored" (the default) fixes the card just above the control and aims a chevron at it. placement="inline" opts out when the host is already placing it low.

Be honest about the ceiling. None of this makes iOS install well — Apple gives no API and conversion is poor everywhere. This is a discovery affordance, not a funnel, and worth measuring rather than assuming.

Diagnostics

An invite that declines to appear fails silently: nothing throws, so a broken one looks exactly like a healthy page. onDiagnostic is told once per mount, with a payload that reads as a decision table:

fieldwhat false means
earlyScriptPresentthe document predates the inline capture — deploy
earlyEventHeldthe browser never judged the page installable
earlyEventFiredAt set but hasDeferred falsethe stash is not being adopted
installed / dismissed trueworking as designed, not a fault

Tests

pnpm --filter @12-apps/pwa test

版本与发布记录

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

版本发布日期距今
2.6.2latest2026/09/052 个月前
2.6.12026/09/042 个月前
2.6.02026/09/042 个月前
2.5.02026/09/042 个月前
2.4.02026/08/252 个月前
2.3.02026/08/252 个月前

关于 @12-apps/pwa 的常见问题

@12-apps/pwa 是什么?

Making a web app installable, and telling people it is: the beforeinstallprompt state machine + iOS share-sheet detection + overridable pt-BR copy (.), the install invite (./react), the PER-HOST manifest endpoint and the packaged network-first service wor

如何安装 @12-apps/pwa?

在项目目录运行 npm install @12-apps/pwa,或使用 pnpm add / yarn add / bun add。它还需要宿主项目提供 peer 依赖:react(>=19.0.0)、react-dom(>=19.0.0)。npm 7 及以上与 pnpm 8 及以上默认会自动安装必需的 peer 依赖,Yarn 需要手动安装。

@12-apps/pwa 支持 TypeScript 吗?

该包的 package.json 没有声明类型入口。需要类型提示时,可以在 npm 上查找社区维护的 @types/12-apps__pwa 是否存在。

@12-apps/pwa 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

@12-apps/pwa 需要什么版本的 Node.js?

package.json 的 engines 字段要求 Node.js >=22.0.0。版本不符时 npm 默认只给出警告,开启 engine-strict 后会拒绝安装。

@12-apps/pwa 可以商用吗?

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

@12-apps/pwa 的下载量有多少?

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

@12-apps/pwa 还在维护吗?

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