@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 +32 -0
- package/core.d.ts +30 -0
- package/core.js +75 -0
- package/package.json +54 -0
- package/react.d.ts +10 -0
- package/react.js +61 -0
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
|
+
}
|