@doscientos/pwa 0.1.2

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 ADDED
@@ -0,0 +1,32 @@
1
+ # @doscientos/pwa
2
+
3
+ Reusable PWA installation primitives. It shares safe browser detection,
4
+ installation state and service-worker registration without imposing a UI,
5
+ manifest or caching strategy on applications.
6
+
7
+ ## Exports
8
+
9
+ - `@doscientos/pwa/core`: browser-safe detection, dismissal storage and
10
+ `registerPwaServiceWorker`.
11
+ - `@doscientos/pwa/react`: `usePwaInstallPrompt`, a headless React hook.
12
+
13
+ Each app owns its manifest, icons, `public/sw.js`, cache name and cache policy.
14
+ Never cache API, authentication or user-specific responses in a shared default.
15
+
16
+ ## React example
17
+
18
+ Use `usePwaInstallPrompt({ storageKey: 'product:pwa-dismissed' })` and render
19
+ your own design-system UI from its `visible`, `isIos`, `pending`, `install` and
20
+ `dismiss` values. Register the worker once from the app shell with
21
+ `registerPwaServiceWorker()`.
22
+
23
+ ## Publishing
24
+
25
+ Run `npm run check` and `npm pack --dry-run` before publishing. Every regular
26
+ commit to `main` publishes the next patch version and creates its tag
27
+ automatically. To publish a minor or major, edit the `version` in
28
+ `package.json` before pushing; the workflow publishes that exact version and
29
+ continues patch releases from it. Configure npm Trusted Publishing for
30
+ `doscientos-es/pwa`, workflow
31
+ `.github/workflows/publish.yml`, and GitHub environment `npm-production`; no
32
+ registry token is stored in the repository.
package/core.d.ts ADDED
@@ -0,0 +1,30 @@
1
+ export type PwaInstallAvailability = {
2
+ isDismissed: boolean
3
+ isIos: boolean
4
+ isStandalone: boolean
5
+ canPrompt: boolean
6
+ }
7
+
8
+ export type PwaEnvironment = Pick<PwaInstallAvailability, 'isIos' | 'isStandalone'>
9
+
10
+ export type PwaInstallPromptEvent = Event & {
11
+ prompt: () => Promise<void>
12
+ userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>
13
+ }
14
+
15
+ export declare function detectPwaEnvironment(options?: {
16
+ windowRef?: Window
17
+ navigatorRef?: Navigator
18
+ }): PwaEnvironment
19
+ export declare function shouldOfferPwaInstallation(availability: PwaInstallAvailability): boolean
20
+ export declare function readPwaDismissal(storageKey: string, storage?: Storage): boolean
21
+ export declare function persistPwaDismissal(storageKey: string, storage?: Storage): void
22
+ export declare function registerPwaServiceWorker(options?: {
23
+ scriptUrl?: string
24
+ updateViaCache?: ServiceWorkerUpdateViaCache
25
+ onError?: (error: unknown) => void
26
+ onRegistered?: (registration: ServiceWorkerRegistration) => void
27
+ windowRef?: Window
28
+ navigatorRef?: Navigator
29
+ documentRef?: Document
30
+ }): () => void
package/core.js ADDED
@@ -0,0 +1,75 @@
1
+ const noOperation = () => undefined
2
+
3
+ /** Returns browser capabilities without accessing globals during SSR. */
4
+ export function detectPwaEnvironment({
5
+ windowRef = globalThis.window,
6
+ navigatorRef = globalThis.navigator,
7
+ } = {}) {
8
+ if (!windowRef || !navigatorRef) return { isIos: false, isStandalone: false }
9
+
10
+ const isStandalone =
11
+ windowRef.matchMedia?.('(display-mode: standalone)').matches === true ||
12
+ navigatorRef.standalone === true
13
+ const isIos =
14
+ /iPad|iPhone|iPod/.test(navigatorRef.userAgent ?? '') ||
15
+ (navigatorRef.platform === 'MacIntel' && (navigatorRef.maxTouchPoints ?? 0) > 1)
16
+
17
+ return { isIos, isStandalone }
18
+ }
19
+
20
+ export function shouldOfferPwaInstallation({ isDismissed, isIos, isStandalone, canPrompt }) {
21
+ return !isStandalone && !isDismissed && (isIos || canPrompt)
22
+ }
23
+
24
+ export function readPwaDismissal(storageKey, storage = globalThis.localStorage) {
25
+ try {
26
+ return storage?.getItem(storageKey) === '1'
27
+ } catch {
28
+ return false
29
+ }
30
+ }
31
+
32
+ export function persistPwaDismissal(storageKey, storage = globalThis.localStorage) {
33
+ try {
34
+ storage?.setItem(storageKey, '1')
35
+ } catch {
36
+ // A prompt remains dismissible when storage is unavailable.
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Registers an app-owned worker after load. Cache strategy remains in the app's
42
+ * service worker and is never inferred or shared by this package.
43
+ */
44
+ export function registerPwaServiceWorker({
45
+ scriptUrl = '/sw.js',
46
+ updateViaCache = 'none',
47
+ onError = noOperation,
48
+ onRegistered = noOperation,
49
+ windowRef = globalThis.window,
50
+ navigatorRef = globalThis.navigator,
51
+ documentRef = globalThis.document,
52
+ } = {}) {
53
+ const serviceWorker = navigatorRef?.serviceWorker
54
+ if (!windowRef || !documentRef || !serviceWorker?.register) return noOperation
55
+
56
+ let disposed = false
57
+ const register = () => {
58
+ void serviceWorker
59
+ .register(scriptUrl, { updateViaCache })
60
+ .then((registration) => {
61
+ if (!disposed) onRegistered(registration)
62
+ })
63
+ .catch((error) => {
64
+ if (!disposed) onError(error)
65
+ })
66
+ }
67
+
68
+ if (documentRef.readyState === 'complete') register()
69
+ else windowRef.addEventListener('load', register, { once: true })
70
+
71
+ return () => {
72
+ disposed = true
73
+ windowRef.removeEventListener('load', register)
74
+ }
75
+ }
package/package.json ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "@doscientos/pwa",
3
+ "version": "0.1.2",
4
+ "private": false,
5
+ "description": "Safe, framework-aware PWA installation primitives for Doscientos applications.",
6
+ "license": "MIT",
7
+ "author": "Doscientos <dev@doscientos.es>",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "https://github.com/doscientos-es/pwa.git"
11
+ },
12
+ "homepage": "https://github.com/doscientos-es/pwa#readme",
13
+ "bugs": {
14
+ "url": "https://github.com/doscientos-es/pwa/issues"
15
+ },
16
+ "files": [
17
+ "*.d.ts",
18
+ "*.js",
19
+ "README.md"
20
+ ],
21
+ "type": "module",
22
+ "exports": {
23
+ "./core": {
24
+ "types": "./core.d.ts",
25
+ "import": "./core.js"
26
+ },
27
+ "./react": {
28
+ "types": "./react.d.ts",
29
+ "import": "./react.js"
30
+ }
31
+ },
32
+ "publishConfig": {
33
+ "access": "public"
34
+ },
35
+ "scripts": {
36
+ "check": "node --test test/core.test.mjs",
37
+ "prepublishOnly": "npm run check",
38
+ "version:major": "npm version major --no-git-tag-version",
39
+ "version:minor": "npm version minor --no-git-tag-version",
40
+ "version:patch": "npm version patch --no-git-tag-version"
41
+ },
42
+ "peerDependencies": {
43
+ "react": ">=18.0.0 <20"
44
+ },
45
+ "peerDependenciesMeta": {
46
+ "react": {
47
+ "optional": true
48
+ }
49
+ },
50
+ "engines": {
51
+ "node": ">=22.18.0"
52
+ },
53
+ "packageManager": "pnpm@9.15.2"
54
+ }
package/react.d.ts ADDED
@@ -0,0 +1,10 @@
1
+ export type PwaInstallChoice = { outcome: 'accepted' | 'dismissed' }
2
+
3
+ export declare function usePwaInstallPrompt(options: { storageKey: string }): {
4
+ isIos: boolean
5
+ isStandalone: boolean
6
+ visible: boolean
7
+ pending: boolean
8
+ dismiss: () => void
9
+ install: () => Promise<PwaInstallChoice | null>
10
+ }
package/react.js ADDED
@@ -0,0 +1,61 @@
1
+ import { useCallback, useEffect, useMemo, useState } from 'react'
2
+
3
+ import {
4
+ detectPwaEnvironment,
5
+ persistPwaDismissal,
6
+ readPwaDismissal,
7
+ shouldOfferPwaInstallation,
8
+ } from './core.js'
9
+
10
+ /** React adapter for the native install event; it intentionally renders no UI. */
11
+ export function usePwaInstallPrompt({ storageKey } = {}) {
12
+ if (!storageKey) throw new Error('usePwaInstallPrompt requires a storageKey')
13
+
14
+ const [installEvent, setInstallEvent] = useState(null)
15
+ const [dismissed, setDismissed] = useState(true)
16
+ const [environment, setEnvironment] = useState({ isIos: false, isStandalone: true })
17
+ const [pending, setPending] = useState(false)
18
+
19
+ useEffect(() => {
20
+ setEnvironment(detectPwaEnvironment())
21
+ setDismissed(readPwaDismissal(storageKey))
22
+ const onBeforeInstallPrompt = (event) => {
23
+ event.preventDefault()
24
+ setInstallEvent(event)
25
+ }
26
+ window.addEventListener('beforeinstallprompt', onBeforeInstallPrompt)
27
+ return () => window.removeEventListener('beforeinstallprompt', onBeforeInstallPrompt)
28
+ }, [storageKey])
29
+
30
+ const dismiss = useCallback(() => {
31
+ persistPwaDismissal(storageKey)
32
+ setDismissed(true)
33
+ }, [storageKey])
34
+
35
+ const install = useCallback(async () => {
36
+ if (!installEvent) return null
37
+ setPending(true)
38
+ try {
39
+ await installEvent.prompt()
40
+ const choice = await installEvent.userChoice
41
+ if (choice.outcome === 'accepted')
42
+ setEnvironment((current) => ({ ...current, isStandalone: true }))
43
+ setInstallEvent(null)
44
+ return choice
45
+ } finally {
46
+ setPending(false)
47
+ }
48
+ }, [installEvent])
49
+
50
+ const visible = useMemo(
51
+ () =>
52
+ shouldOfferPwaInstallation({
53
+ ...environment,
54
+ isDismissed: dismissed,
55
+ canPrompt: installEvent !== null,
56
+ }),
57
+ [dismissed, environment, installEvent],
58
+ )
59
+
60
+ return { ...environment, dismiss, install, pending, visible }
61
+ }