@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 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
+ ![3 kB](https://img.shields.io/badge/size-3%20kB-informational)
9
+ ![Frameworks](https://img.shields.io/badge/React%20%C2%B7%20Vue%20%C2%B7%20Angular%20%C2%B7%20Svelte-informational)
10
+ ![TypeScript](https://img.shields.io/badge/TypeScript-informational)
11
+ ![MIT](https://img.shields.io/badge/license-MIT-informational)
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
@@ -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
+ }
@@ -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);})());});})();