@notiblast/web 0.3.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/LICENSE +21 -0
- package/README.md +239 -0
- package/bin/notiblast.js +99 -0
- package/dist/index.d.ts +52 -0
- package/dist/index.js +59 -0
- package/package.json +60 -0
- package/service-worker/notiblast-sw.js +1 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Cilsy Fiolution
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
# @notiblast/web
|
|
2
|
+
|
|
3
|
+
<img src="https://app.notiblast.com/apple-touch-icon.png" alt="" width="72" height="72">
|
|
4
|
+
|
|
5
|
+
Official web push SDK for [NotiBlast](https://notiblast.com). Send notifications to your web
|
|
6
|
+
app's users — they arrive even when your site is closed.
|
|
7
|
+
|
|
8
|
+

|
|
9
|
+

|
|
10
|
+

|
|
11
|
+

|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
npm install @notiblast/web
|
|
19
|
+
npx notiblast init
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
`npx notiblast init` copies the service worker into your public folder (`public`, `static`, `www`,
|
|
23
|
+
`assets`). Use `--dir <folder>` if yours is named differently.
|
|
24
|
+
|
|
25
|
+
It must be reachable at `https://your-site.com/notiblast-sw.js` — **at the root**. A service worker
|
|
26
|
+
only controls the folder it sits in.
|
|
27
|
+
|
|
28
|
+
## Use
|
|
29
|
+
|
|
30
|
+
```ts
|
|
31
|
+
import { init, promptAndSubscribe, identify, permission } from '@notiblast/web'
|
|
32
|
+
|
|
33
|
+
// once, at startup
|
|
34
|
+
init({
|
|
35
|
+
appId: 'YOUR_APP_ID',
|
|
36
|
+
firebase: { apiKey: '…', projectId: '…', messagingSenderId: '…', appId: '…' },
|
|
37
|
+
vapidKey: 'YOUR_WEB_PUSH_CERTIFICATE',
|
|
38
|
+
externalUserId: user?.id, // leave undefined while nobody is logged in
|
|
39
|
+
prompt: {
|
|
40
|
+
message: 'Turn on notifications to hear about new cards as they happen.',
|
|
41
|
+
accept: 'Turn on',
|
|
42
|
+
cancel: 'Not now',
|
|
43
|
+
icon: 'https://cdn.your-site.com/icon.png',
|
|
44
|
+
},
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
// behind a button — never on page load
|
|
48
|
+
const { outcome, deviceId } = await promptAndSubscribe()
|
|
49
|
+
|
|
50
|
+
// when the user logs in or out
|
|
51
|
+
await identify(user.id)
|
|
52
|
+
await identify(null)
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Find `appId` in the [NotiBlast dashboard](https://app.notiblast.com) → Push Apps → app detail.
|
|
56
|
+
`firebase` and `vapidKey` come from your Firebase project: **Project settings → General → Your
|
|
57
|
+
apps → Web app** for the config, **Cloud Messaging → Web Push certificates** for the key. Both are
|
|
58
|
+
public by design — safe to ship in your bundle.
|
|
59
|
+
|
|
60
|
+
Example with React:
|
|
61
|
+
|
|
62
|
+
```tsx
|
|
63
|
+
import { useEffect, useState } from 'react'
|
|
64
|
+
import { permission, promptAndSubscribe, subscribe } from '@notiblast/web'
|
|
65
|
+
|
|
66
|
+
export function NotificationButton() {
|
|
67
|
+
const [status, setStatus] = useState(permission())
|
|
68
|
+
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
if (permission() !== 'default') void subscribe() // refresh the token, or report a revoked permission
|
|
71
|
+
}, [])
|
|
72
|
+
|
|
73
|
+
if (status !== 'default') return null
|
|
74
|
+
|
|
75
|
+
return (
|
|
76
|
+
<button
|
|
77
|
+
onClick={async () => {
|
|
78
|
+
const { outcome } = await promptAndSubscribe()
|
|
79
|
+
setStatus(outcome === 'dismissed' ? 'default' : outcome)
|
|
80
|
+
}}
|
|
81
|
+
>
|
|
82
|
+
Notify me about new cards
|
|
83
|
+
</button>
|
|
84
|
+
)
|
|
85
|
+
}
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Vue, Angular and Svelte work the same way — only where you call `init()` differs.
|
|
89
|
+
|
|
90
|
+
## The permission prompt
|
|
91
|
+
|
|
92
|
+
The browser's own dialog can only be shown **once per site, ever**. Ask the wrong person at the
|
|
93
|
+
wrong moment and you lose them permanently.
|
|
94
|
+
|
|
95
|
+
So `promptAndSubscribe()` shows a soft prompt of its own first — a bar that slides down from the
|
|
96
|
+
top — and only calls the browser when the visitor says yes:
|
|
97
|
+
|
|
98
|
+
```
|
|
99
|
+
┌──────────────────────────────────────────────────────┐
|
|
100
|
+
│ 🔔 Turn on notifications to hear about new │
|
|
101
|
+
│ cards as they happen. Not now [ Turn on ]│
|
|
102
|
+
└──────────────────────────────────────────────────────┘
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Someone who dismisses the soft prompt can be asked again another day. Someone who dismisses the
|
|
106
|
+
browser dialog cannot.
|
|
107
|
+
|
|
108
|
+
Configure it in `init({ prompt })`, or pass it per call:
|
|
109
|
+
|
|
110
|
+
```ts
|
|
111
|
+
const accepted = await showPrompt({ message: '…', accept: 'Turn on', cancel: 'Not now' })
|
|
112
|
+
if (accepted) await subscribe()
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
| Option | Default |
|
|
116
|
+
| --- | --- |
|
|
117
|
+
| `message` | "Stay in the loop — turn on notifications to get updates as they happen." |
|
|
118
|
+
| `accept` | "Allow" |
|
|
119
|
+
| `cancel` | "Not now" |
|
|
120
|
+
| `icon` | none — the icon is simply left out |
|
|
121
|
+
| `theme` | `'auto'` — follows the visitor's system. Force it with `'light'` or `'dark'` |
|
|
122
|
+
|
|
123
|
+
It renders in a **closed shadow root**, so your CSS cannot break it and its CSS cannot leak into
|
|
124
|
+
your page. It stacks on narrow screens and closes on Escape.
|
|
125
|
+
|
|
126
|
+
By default it follows the visitor's system theme. If your site is always light or always dark,
|
|
127
|
+
say so — otherwise the prompt will look out of place for half your visitors:
|
|
128
|
+
|
|
129
|
+
```ts
|
|
130
|
+
init({ appId: '…', prompt: { theme: 'light' } })
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
`promptAndSubscribe()` skips the soft prompt when permission is no longer `'default'` — already
|
|
134
|
+
granted needs no asking, and already denied cannot be undone by asking again.
|
|
135
|
+
|
|
136
|
+
⚠️ The `icon` URL is fetched by the browser and **fails silently**. If it returns HTML instead of
|
|
137
|
+
an image, you get a broken-image box. Make sure the `content-type` is `image/*`.
|
|
138
|
+
|
|
139
|
+
## API
|
|
140
|
+
|
|
141
|
+
```ts
|
|
142
|
+
init(options: {
|
|
143
|
+
appId
|
|
144
|
+
firebase // your Firebase web app config
|
|
145
|
+
vapidKey // Firebase → Cloud Messaging → Web Push certificates
|
|
146
|
+
apiUrl?
|
|
147
|
+
externalUserId? // undefined = keep the current link · null = unlink
|
|
148
|
+
serviceWorkerPath? // default '/notiblast-sw.js'
|
|
149
|
+
serviceWorkerScope? // default: the worker's own folder
|
|
150
|
+
prompt? // { message?, accept?, cancel?, icon?, theme? }
|
|
151
|
+
}): void
|
|
152
|
+
promptAndSubscribe(): Promise<{ outcome, deviceId, step?, reason? }>
|
|
153
|
+
subscribe(): Promise<{ outcome, deviceId, step?, reason? }>
|
|
154
|
+
showPrompt(options?): Promise<boolean>
|
|
155
|
+
identify(externalUserId: string | null): Promise<string | null>
|
|
156
|
+
unsubscribe(): Promise<void>
|
|
157
|
+
permission(): NotificationPermission | 'unsupported'
|
|
158
|
+
isSupported(): Promise<boolean>
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
| `outcome` | Meaning |
|
|
162
|
+
| --- | --- |
|
|
163
|
+
| `granted` | Subscribed. `deviceId` is set |
|
|
164
|
+
| `denied` | Declined — do not offer again |
|
|
165
|
+
| `dismissed` | Prompt closed without choosing. You may offer again later |
|
|
166
|
+
| `unsupported` | This browser has no push support |
|
|
167
|
+
| `error` | Something broke. `step` and `reason` say what |
|
|
168
|
+
|
|
169
|
+
`unsubscribe()` stops NotiBlast from targeting this browser. Calling `subscribe()` again turns it
|
|
170
|
+
back on — so after an unsubscribe, stop calling `subscribe()` on load until the visitor opts in again.
|
|
171
|
+
|
|
172
|
+
## Five things that fail silently
|
|
173
|
+
|
|
174
|
+
**Never ask on page load.** The browser lets you ask once, ever. Put `promptAndSubscribe()` behind
|
|
175
|
+
a button or a deliberate moment — never in a `useEffect` that runs on mount.
|
|
176
|
+
|
|
177
|
+
**Call `subscribe()` on every app load** once permission is no longer `'default'` — `subscribe()`,
|
|
178
|
+
not `promptAndSubscribe()`. It shows no prompt. When granted it refreshes the token; when denied it
|
|
179
|
+
tells NotiBlast the visitor revoked permission. Skip this and devices stop receiving one day, with
|
|
180
|
+
no error anywhere.
|
|
181
|
+
|
|
182
|
+
**`externalUserId: null` is not "nobody yet".** `null` unlinks the device from its user; leaving it
|
|
183
|
+
out keeps the existing link. Pass `null` on page load and every visitor is logged out of their
|
|
184
|
+
notifications each time they open your site.
|
|
185
|
+
|
|
186
|
+
**Re-copy the service worker after `npm update`.** `public/notiblast-sw.js` is a copy. Add
|
|
187
|
+
`"prebuild": "notiblast check"` to fail the build when it falls behind.
|
|
188
|
+
|
|
189
|
+
**Another service worker at the same scope wins.** A scope can only have one active worker, so
|
|
190
|
+
registering ours at `/` replaces an existing PWA or Workbox worker. If your site already has one,
|
|
191
|
+
keep them apart:
|
|
192
|
+
|
|
193
|
+
```ts
|
|
194
|
+
init({ appId: '…', serviceWorkerPath: '/notiblast/notiblast-sw.js', serviceWorkerScope: '/notiblast/' })
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
```bash
|
|
198
|
+
npx notiblast init --dir public/notiblast
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
Push works from any scope — a subscription belongs to the registration, not to the page.
|
|
202
|
+
|
|
203
|
+
**iOS needs Home Screen.** In a normal Safari tab web push does not exist. Only after Share → Add to
|
|
204
|
+
Home Screen. Check with `await isSupported()`, never the user agent.
|
|
205
|
+
|
|
206
|
+
## Sending
|
|
207
|
+
|
|
208
|
+
From your server, with an API key: `POST /v1/push/send`. Web-only options go in the `web` block,
|
|
209
|
+
passed to Firebase as-is:
|
|
210
|
+
|
|
211
|
+
```json
|
|
212
|
+
{
|
|
213
|
+
"push_app_id": "YOUR_APP_ID",
|
|
214
|
+
"title": "New comment",
|
|
215
|
+
"body": "Budi mentioned you",
|
|
216
|
+
"target": { "type": "external_id", "external_ids": ["usr_88213"] },
|
|
217
|
+
"web": {
|
|
218
|
+
"notification": { "icon": "https://…/avatar.png", "tag": "card-42", "requireInteraction": true },
|
|
219
|
+
"fcm_options": { "link": "https://your-site.com/cards/42" }
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
| Want | Set |
|
|
225
|
+
| --- | --- |
|
|
226
|
+
| Open a page on click | `web.fcm_options.link` — **https only**. Otherwise `data.url` |
|
|
227
|
+
| Replace instead of stack | `web.notification.tag` — same tag, one notification |
|
|
228
|
+
| Stay until dismissed | `web.notification.requireInteraction: true` |
|
|
229
|
+
| Small icon | `web.notification.icon` |
|
|
230
|
+
|
|
231
|
+
## Requirements
|
|
232
|
+
|
|
233
|
+
Node 20+ · ESM bundler (Vite 4+, webpack 5+, Rollup 3+) · **HTTPS** (`localhost` is fine)
|
|
234
|
+
|
|
235
|
+
Chrome, Edge, Firefox, Opera · Safari macOS 16.4+ · Safari iOS 16.4+ (Home Screen only)
|
|
236
|
+
|
|
237
|
+
## License
|
|
238
|
+
|
|
239
|
+
MIT © Cilsy Fiolution
|
package/bin/notiblast.js
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs'
|
|
3
|
+
import { dirname, join, relative, resolve } from 'node:path'
|
|
4
|
+
import { fileURLToPath } from 'node:url'
|
|
5
|
+
|
|
6
|
+
const HERE = dirname(fileURLToPath(import.meta.url))
|
|
7
|
+
const SOURCE = resolve(HERE, '../service-worker/notiblast-sw.js')
|
|
8
|
+
const FILENAME = 'notiblast-sw.js'
|
|
9
|
+
const CANDIDATES = ['public', 'static', 'www', 'assets']
|
|
10
|
+
|
|
11
|
+
const { version } = JSON.parse(readFileSync(resolve(HERE, '../package.json'), 'utf8'))
|
|
12
|
+
|
|
13
|
+
function say(line = '') {
|
|
14
|
+
console.log(line)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function fail(message, hint) {
|
|
18
|
+
console.error(`\n✗ ${message}`)
|
|
19
|
+
if (hint) console.error(` ${hint}`)
|
|
20
|
+
console.error('')
|
|
21
|
+
process.exit(1)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function findPublicDir(explicit) {
|
|
25
|
+
if (explicit) return resolve(process.cwd(), explicit)
|
|
26
|
+
|
|
27
|
+
const found = CANDIDATES.find((name) => existsSync(join(process.cwd(), name)))
|
|
28
|
+
if (found) return join(process.cwd(), found)
|
|
29
|
+
|
|
30
|
+
return null
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function init(explicit, apiUrl) {
|
|
34
|
+
if (apiUrl) say('\n --api-url is no longer needed: the service worker never calls the API. Ignoring it.')
|
|
35
|
+
|
|
36
|
+
const target = findPublicDir(explicit)
|
|
37
|
+
|
|
38
|
+
if (!target) {
|
|
39
|
+
fail(
|
|
40
|
+
'Could not find a public folder.',
|
|
41
|
+
`Looked for: ${CANDIDATES.join(', ')}. Name it yourself: npx notiblast init --dir <folder>`,
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
mkdirSync(target, { recursive: true })
|
|
46
|
+
const destination = join(target, FILENAME)
|
|
47
|
+
|
|
48
|
+
copyFileSync(SOURCE, destination)
|
|
49
|
+
|
|
50
|
+
const shown = relative(process.cwd(), destination) || destination
|
|
51
|
+
|
|
52
|
+
say(`\n✓ ${FILENAME} v${version} copied to ${shown}`)
|
|
53
|
+
say('')
|
|
54
|
+
say(' Make sure it is reachable at https://your-site.com/notiblast-sw.js')
|
|
55
|
+
say(' It must be at the site root — a service worker only controls the folder it sits in.')
|
|
56
|
+
say('')
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function check(explicit) {
|
|
60
|
+
const target = findPublicDir(explicit)
|
|
61
|
+
const destination = target ? join(target, FILENAME) : null
|
|
62
|
+
|
|
63
|
+
if (!destination || !existsSync(destination)) {
|
|
64
|
+
fail(`${FILENAME} is not in your public folder.`, 'Run: npx notiblast init')
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const installed = readFileSync(destination, 'utf8')
|
|
68
|
+
const shown = relative(process.cwd(), destination)
|
|
69
|
+
|
|
70
|
+
if (installed === readFileSync(SOURCE, 'utf8')) {
|
|
71
|
+
say(`\n✓ ${shown} matches package v${version}\n`)
|
|
72
|
+
return
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
fail(
|
|
76
|
+
`${shown} differs from package v${version}.`,
|
|
77
|
+
'Re-copy it: npx notiblast init',
|
|
78
|
+
)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const [command, ...rest] = process.argv.slice(2)
|
|
82
|
+
const flag = (name) => {
|
|
83
|
+
const at = rest.indexOf(name)
|
|
84
|
+
return at === -1 ? null : rest[at + 1]
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const explicit = flag('--dir')
|
|
88
|
+
|
|
89
|
+
if (command === 'init') init(explicit, flag('--api-url'))
|
|
90
|
+
else if (command === 'check') check(explicit)
|
|
91
|
+
else {
|
|
92
|
+
say(`\nnotiblast v${version}`)
|
|
93
|
+
say('')
|
|
94
|
+
say(' npx notiblast init [--dir public]')
|
|
95
|
+
say(' copy the service worker into your public folder')
|
|
96
|
+
say(' npx notiblast check [--dir public] verify that copy still matches')
|
|
97
|
+
say('')
|
|
98
|
+
process.exit(command ? 1 : 0)
|
|
99
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
type PromptTheme = 'auto' | 'light' | 'dark';
|
|
2
|
+
interface PromptOptions {
|
|
3
|
+
message?: string;
|
|
4
|
+
accept?: string;
|
|
5
|
+
cancel?: string;
|
|
6
|
+
icon?: string;
|
|
7
|
+
theme?: PromptTheme;
|
|
8
|
+
}
|
|
9
|
+
interface FirebaseWebConfig {
|
|
10
|
+
apiKey: string;
|
|
11
|
+
authDomain?: string;
|
|
12
|
+
projectId: string;
|
|
13
|
+
storageBucket?: string;
|
|
14
|
+
messagingSenderId: string;
|
|
15
|
+
appId: string;
|
|
16
|
+
measurementId?: string;
|
|
17
|
+
}
|
|
18
|
+
interface NotiblastOptions {
|
|
19
|
+
appId: string;
|
|
20
|
+
firebase: FirebaseWebConfig;
|
|
21
|
+
vapidKey: string;
|
|
22
|
+
apiUrl?: string;
|
|
23
|
+
externalUserId?: string | null;
|
|
24
|
+
serviceWorkerPath?: string;
|
|
25
|
+
serviceWorkerScope?: string;
|
|
26
|
+
prompt?: PromptOptions;
|
|
27
|
+
}
|
|
28
|
+
type PermissionOutcome = 'granted' | 'denied' | 'dismissed' | 'unsupported';
|
|
29
|
+
type SubscribeOutcome = PermissionOutcome | 'error';
|
|
30
|
+
type FailureStep = 'service-worker' | 'token' | 'register';
|
|
31
|
+
interface SubscribeResult {
|
|
32
|
+
outcome: SubscribeOutcome;
|
|
33
|
+
deviceId: string | null;
|
|
34
|
+
step?: FailureStep;
|
|
35
|
+
reason?: string;
|
|
36
|
+
}
|
|
37
|
+
type ReleaseAction = 'logout' | 'unsubscribe' | 'permission_denied';
|
|
38
|
+
|
|
39
|
+
declare function isPromptOpen(): boolean;
|
|
40
|
+
declare function showPrompt(options?: PromptOptions): Promise<boolean>;
|
|
41
|
+
|
|
42
|
+
declare const SDK_VERSION = "0.3.1";
|
|
43
|
+
|
|
44
|
+
declare function init(next: NotiblastOptions): void;
|
|
45
|
+
declare function identify(externalUserId: string | null): Promise<string | null>;
|
|
46
|
+
declare function unsubscribe(): Promise<void>;
|
|
47
|
+
declare function isSupported(): Promise<boolean>;
|
|
48
|
+
declare function permission(): NotificationPermission | 'unsupported';
|
|
49
|
+
declare function promptAndSubscribe(): Promise<SubscribeResult>;
|
|
50
|
+
declare function subscribe(): Promise<SubscribeResult>;
|
|
51
|
+
|
|
52
|
+
export { type FailureStep, type FirebaseWebConfig, type NotiblastOptions, type PermissionOutcome, type PromptOptions, type PromptTheme, type ReleaseAction, SDK_VERSION, type SubscribeOutcome, type SubscribeResult, identify, init, isPromptOpen, isSupported, permission, promptAndSubscribe, showPrompt, subscribe, unsubscribe };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import {initializeApp}from'firebase/app';import {isSupported,getMessaging,getToken}from'firebase/messaging';var V=[["Windows",/Windows NT ([\d.]+)/],["Android",/Android ([\d.]+)/],["iOS",/(?:iPhone|iPad|iPod).*? OS ([\d_]+)/],["macOS",/Mac OS X ([\d_.]+)/],["ChromeOS",/CrOS \S+ ([\d.]+)/],["Linux",/Linux/]];function O(e){for(let[t,n]of V){let o=e.match(n);if(!o)continue;let r=o[1]?.replace(/_/g,".").slice(0,32)??null;return {name:t,version:r}}return {name:null,version:null}}async function P(e,t){return fetch(e,{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify(t)})}async function R(e){try{return await e.json()}catch{return {}}}function F(e,t){let n=O(t),o={push_app_id:e.appId,token:e.token,platform:"web",os_name:n.name,os_version:n.version};return e.externalUserId!==void 0&&(o.external_id=e.externalUserId),o}async function v(e,t){let n=await P(`${e}/v1/push/devices`,F(t,navigator.userAgent)),o=await R(n);if(!n.ok)throw new Error(o.message??`HTTP ${n.status}`);let r=o.data?._id;if(!r)throw new Error("The server answered without a device id");return r}async function N(e,t){let n=await P(`${e}/v1/push/devices/release`,{push_app_id:t.appId,token:t.token,action:t.action});if(n.ok)return;let o=await R(n);throw new Error(o.message??`HTTP ${n.status}`)}function _(e){return e?new Promise(t=>{let n=new MessageChannel,o=setTimeout(()=>r(null),1e3);function r(a){clearTimeout(o),n.port1.close(),t(a);}n.port1.onmessage=a=>{let i=a.data;r(i?.type==="notiblast:version"?i.version??null:null);},e.postMessage("notiblast:version",[n.port2]);}):Promise.resolve(null)}var T="notiblast-prompt",C={message:"Stay in the loop \u2014 turn on notifications to get updates as they happen.",accept:"Allow",cancel:"Not now",theme:"auto"},j=`
|
|
2
|
+
:host { all: initial }
|
|
3
|
+
.wrap {
|
|
4
|
+
position: fixed;
|
|
5
|
+
top: 0; left: 0; right: 0;
|
|
6
|
+
display: flex; justify-content: center;
|
|
7
|
+
z-index: 2147483647;
|
|
8
|
+
pointer-events: none;
|
|
9
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
10
|
+
}
|
|
11
|
+
.card {
|
|
12
|
+
--bg: #fff;
|
|
13
|
+
--fg: #1f2430;
|
|
14
|
+
--muted: #5b6478;
|
|
15
|
+
--accent: #2f6fed;
|
|
16
|
+
--accent-hover: #2a62d4;
|
|
17
|
+
--accent-fg: #fff;
|
|
18
|
+
|
|
19
|
+
pointer-events: auto;
|
|
20
|
+
display: flex; align-items: center; gap: 14px;
|
|
21
|
+
max-width: 520px; width: calc(100% - 24px);
|
|
22
|
+
margin: 12px;
|
|
23
|
+
padding: 14px 16px;
|
|
24
|
+
background: var(--bg); color: var(--fg);
|
|
25
|
+
border-radius: 12px;
|
|
26
|
+
box-shadow: 0 8px 28px rgba(0, 0, 0, .18);
|
|
27
|
+
transform: translateY(-140%);
|
|
28
|
+
transition: transform .28s cubic-bezier(.2, .8, .3, 1);
|
|
29
|
+
}
|
|
30
|
+
.card.dark {
|
|
31
|
+
--bg: #1f2430;
|
|
32
|
+
--fg: #eef1f7;
|
|
33
|
+
--muted: #9aa4ba;
|
|
34
|
+
}
|
|
35
|
+
@media (prefers-color-scheme: dark) {
|
|
36
|
+
.card.auto {
|
|
37
|
+
--bg: #1f2430;
|
|
38
|
+
--fg: #eef1f7;
|
|
39
|
+
--muted: #9aa4ba;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
.card.in { transform: translateY(0) }
|
|
43
|
+
img { width: 40px; height: 40px; border-radius: 8px; flex: none; object-fit: cover }
|
|
44
|
+
p { margin: 0; flex: 1; font-size: 14px; line-height: 1.45 }
|
|
45
|
+
.actions { display: flex; align-items: center; gap: 8px; flex: none }
|
|
46
|
+
button {
|
|
47
|
+
font: inherit; font-size: 14px; font-weight: 600;
|
|
48
|
+
border: 0; border-radius: 8px; padding: 9px 16px; cursor: pointer;
|
|
49
|
+
}
|
|
50
|
+
.cancel { background: transparent; color: var(--muted) }
|
|
51
|
+
.cancel:hover { color: var(--fg) }
|
|
52
|
+
.accept { background: var(--accent); color: var(--accent-fg) }
|
|
53
|
+
.accept:hover { background: var(--accent-hover) }
|
|
54
|
+
@media (max-width: 520px) {
|
|
55
|
+
.card { flex-wrap: wrap }
|
|
56
|
+
.actions { width: 100%; justify-content: flex-end }
|
|
57
|
+
}
|
|
58
|
+
`;function A(){return !!document.getElementById(T)}function W(e={}){if(typeof document>"u"||A())return Promise.resolve(false);let t={...C,...e};return new Promise(n=>{let o=document.createElement("div");o.id=T;let r=o.attachShadow({mode:"closed"}),a=document.createElement("style");a.textContent=j;let i=document.createElement("div");i.className="wrap";let s=document.createElement("div");if(s.className=`card ${t.theme}`,s.setAttribute("role","dialog"),s.setAttribute("aria-live","polite"),e.icon){let c=document.createElement("img");c.src=e.icon,c.alt="",s.append(c);}let S=document.createElement("p");S.textContent=t.message;let m=document.createElement("div");m.className="actions";let u=document.createElement("button");u.className="cancel",u.textContent=t.cancel;let l=document.createElement("button");l.className="accept",l.textContent=t.accept,m.append(u,l),s.append(S,m),i.append(s),r.append(a,i),document.body.append(o),requestAnimationFrame(()=>s.classList.add("in"));function g(c){document.removeEventListener("keydown",E),s.classList.remove("in"),setTimeout(()=>o.remove(),280),n(c);}function E(c){c.key==="Escape"&&g(false);}u.addEventListener("click",()=>g(false)),l.addEventListener("click",()=>g(true)),document.addEventListener("keydown",E),l.focus();})}function b(e){return `notiblast:${e}:token`}function w(e){try{return localStorage.getItem(b(e))}catch{return null}}function D(e,t){try{localStorage.setItem(b(e),t);}catch{return}}function y(e){try{localStorage.removeItem(b(e));}catch{return}}var x="0.3.1";function H(e,t){return t?t===e?"ok":"stale":"unknown"}function L(e,t){return H(e,t)==="ok"?null:[`notiblast: your service worker is version ${t??"unknown"}, the package is ${e}.`,"Re-copy it: npx notiblast init"].join(" ")}var K="https://primaryapi.notiblast.com",Y="/notiblast-sw.js",d=null,p=null;function ge(e){if(!e.appId||!e.firebase||!e.vapidKey)throw new Error("notiblast: init() needs appId, firebase and vapidKey");d={apiUrl:K,...e};}function f(){if(!d)throw new Error("notiblast: call init() first");return d}function k(e){return e.apiUrl??K}async function h(e,t,n){await N(k(e),{appId:e.appId,token:t,action:n});}async function ve(e){let t=f();if(d={...t,externalUserId:e},!p)return null;try{return e===null?(await h(t,p,"logout"),null):await v(k(t),{appId:t.appId,token:p,externalUserId:e})}catch(n){return console.error(`notiblast: identify failed \u2014 ${I(n)}`),null}}async function be(){let e=f(),t=p??w(e.appId);t&&(await h(e,t,"unsubscribe"),y(e.appId),p=null);}async function M(){return typeof window>"u"||!("serviceWorker"in navigator)||!("Notification"in window)?false:isSupported()}function G(){return typeof window>"u"||!("Notification"in window)?"unsupported":Notification.permission}async function we(){let e=f();return await M()?G()!=="default"||await W(e.prompt)?U():{outcome:"dismissed",deviceId:null}:{outcome:"unsupported",deviceId:null}}async function U(){let e=f();if(!await M())return {outcome:"unsupported",deviceId:null};let t=await Q();if(t==="denied"&&await J(e),t!=="granted")return {outcome:t,deviceId:null};let n="service-worker";try{let o=await navigator.serviceWorker.register(e.serviceWorkerPath??Y,e.serviceWorkerScope?{scope:e.serviceWorkerScope}:void 0);await navigator.serviceWorker.ready;let r=L(x,await _(o.active??navigator.serviceWorker.controller));r&&console.warn(r),n="token";let a=getMessaging(initializeApp(e.firebase,`notiblast-${e.appId}`)),i=await getToken(a,{vapidKey:e.vapidKey,serviceWorkerRegistration:o});if(!i)return $("token","Firebase returned no token");p=i,n="register";let s=await v(k(e),{appId:e.appId,token:i,externalUserId:e.externalUserId});return D(e.appId,i),{outcome:"granted",deviceId:s}}catch(o){return $(n,I(o))}}async function J(e){let t=w(e.appId);if(t)try{await h(e,t,"permission_denied"),y(e.appId);}catch(n){console.error(`notiblast: could not report the revoked permission \u2014 ${I(n)}`);}}function I(e){return e instanceof Error?e.message:String(e)}function $(e,t){return console.error(`notiblast: failed at step "${e}" \u2014 ${t}`),{outcome:"error",deviceId:null,step:e,reason:t}}async function Q(){if(Notification.permission==="granted")return "granted";if(Notification.permission==="denied")return "denied";let e=await Notification.requestPermission();return e==="granted"?"granted":e==="denied"?"denied":"dismissed"}
|
|
59
|
+
export{x as SDK_VERSION,ve as identify,ge as init,A as isPromptOpen,M as isSupported,G as permission,we as promptAndSubscribe,W as showPrompt,U as subscribe,be as unsubscribe};
|
package/package.json
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@notiblast/web",
|
|
3
|
+
"version": "0.3.1",
|
|
4
|
+
"description": "Official web push SDK for NotiBlast. Send notifications to your web app's users, even when your site is closed.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"sideEffects": false,
|
|
8
|
+
"engines": {
|
|
9
|
+
"node": ">=20"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"bin",
|
|
13
|
+
"dist",
|
|
14
|
+
"service-worker"
|
|
15
|
+
],
|
|
16
|
+
"bin": {
|
|
17
|
+
"notiblast": "./bin/notiblast.js"
|
|
18
|
+
},
|
|
19
|
+
"main": "./dist/index.js",
|
|
20
|
+
"module": "./dist/index.js",
|
|
21
|
+
"types": "./dist/index.d.ts",
|
|
22
|
+
"exports": {
|
|
23
|
+
".": {
|
|
24
|
+
"types": "./dist/index.d.ts",
|
|
25
|
+
"import": "./dist/index.js"
|
|
26
|
+
},
|
|
27
|
+
"./service-worker": "./service-worker/notiblast-sw.js"
|
|
28
|
+
},
|
|
29
|
+
"scripts": {
|
|
30
|
+
"build": "tsup",
|
|
31
|
+
"typecheck": "tsc --noEmit",
|
|
32
|
+
"test": "vitest run",
|
|
33
|
+
"prepare": "npm run build",
|
|
34
|
+
"prepublishOnly": "npm run typecheck && npm run test"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"tsup": "^8.5.0",
|
|
38
|
+
"typescript": "^5.9.2",
|
|
39
|
+
"vitest": "^3.2.4"
|
|
40
|
+
},
|
|
41
|
+
"dependencies": {
|
|
42
|
+
"firebase": "^12.19.0"
|
|
43
|
+
},
|
|
44
|
+
"publishConfig": {
|
|
45
|
+
"access": "public"
|
|
46
|
+
},
|
|
47
|
+
"keywords": [
|
|
48
|
+
"push",
|
|
49
|
+
"notification",
|
|
50
|
+
"web-push",
|
|
51
|
+
"fcm",
|
|
52
|
+
"notiblast"
|
|
53
|
+
],
|
|
54
|
+
"author": "Cilsy Fiolution",
|
|
55
|
+
"repository": {
|
|
56
|
+
"type": "git",
|
|
57
|
+
"url": "git+https://gitlab.com/bitaplikasi-public/notiblast/notiblast-web.git"
|
|
58
|
+
},
|
|
59
|
+
"homepage": "https://gitlab.com/bitaplikasi-public/notiblast/notiblast-web"
|
|
60
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
(function(){'use strict';function c(n){return n?.url??null}function u(n,t){return n.find(i=>i.url===t)??null}function r(n){return typeof n=="object"&&n!==null?n:{}}function e(n){return typeof n=="string"&&n.length>0?n:void 0}function f(n){return n===true||n==="true"||n==="1"}function d(n){if(typeof n!="object"||n===null)return null;let t=n,i=r(t.notification),o=r(t.data),y=r(t.fcmOptions),s=e(i.title)??e(o.title);if(!s)return null;let a=e(i.tag)??e(o.tag),l=i.renotify??o.renotify;return {title:s,body:e(i.body)??e(o.body),icon:e(i.icon)??e(o.icon),image:e(i.image)??e(o.image_url),url:e(y.link)??e(i.click_action)??e(o.url),tag:a,renotify:a?l===void 0||f(l):false,requireInteraction:f(i.requireInteraction??o.require_interaction)}}function p(n){return {body:n.body??"",icon:n.icon,image:n.image,tag:n.tag,renotify:!!(n.tag&&n.renotify),requireInteraction:!!n.requireInteraction,data:n}}var g="0.3.1";self.addEventListener("install",()=>self.skipWaiting());self.addEventListener("message",n=>{if(n.data!=="notiblast:version")return;let t={type:"notiblast:version",version:g},i=n.ports[0];if(i){i.postMessage(t);return}n.source?.postMessage(t);});self.addEventListener("activate",n=>{n.waitUntil(self.clients.claim());});self.addEventListener("push",n=>{if(!n.data)return;let t=null;try{t=d(n.data.json());}catch{return}t&&n.waitUntil(self.registration.showNotification(t.title,p(t)));});self.addEventListener("notificationclick",n=>{n.notification.close();let t=c(n.notification.data??null);t&&n.waitUntil((async()=>{let i=await self.clients.matchAll({type:"window",includeUncontrolled:true}),o=u(i,t);if(o){await o.focus();return}await self.clients.openWindow(t);})());});})();
|