@lunora/notify 1.0.0-alpha.76 → 1.0.0-alpha.78

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 CHANGED
@@ -22,7 +22,7 @@ export default defineNotify({
22
22
  });
23
23
  ```
24
24
 
25
- `.dev.vars` (scaffolded by `lunora dev` from `@lunora/config`'s package-secrets registry):
25
+ `.dev.vars` (scaffolded by `lunora dev` from `@lunora/config`'s package-secrets registry — only for the channels `defineNotify` configures):
26
26
 
27
27
  ```
28
28
  VAPID_PUBLIC_KEY=<your-vapid-public-key>
@@ -43,6 +43,10 @@ const { replacedEndpoint, subscription } = await subscribeToPush({ serviceWorker
43
43
  await client.mutation("registerDevice", { replacedEndpoint, subscription });
44
44
  ```
45
45
 
46
+ Without `serviceWorkerUrl`, `subscribeToPush` uses the page's existing
47
+ registration: await your own `navigator.serviceWorker.register()` first, or the
48
+ call rejects with "no service worker is registered" instead of waiting for it.
49
+
46
50
  `replacedEndpoint` is set only after a **VAPID key rotation**: the stale browser
47
51
  subscription is dropped and a new one minted, and the new one has a new endpoint
48
52
  — hence a new store id — so it never upserts over the old row. Every send to that
@@ -128,6 +132,12 @@ export const announce = action.input({ title: v.string(), body: v.string() }).ac
128
132
  await ctx.push.send(subscriptionId, { title: "Hi", body: "…" });
129
133
  ```
130
134
 
135
+ `send` and `broadcast` take per-message delivery hints, overriding the channel's `urgency` / `ttl`. Web Push sends them as the `Urgency` / `TTL` headers; FCM maps them to `android.priority` / `android.ttl`:
136
+
137
+ ```ts
138
+ await ctx.push.broadcast({ title: "Approval needed", body: "…", urgency: "high", ttl: 3600 }, { userId });
139
+ ```
140
+
131
141
  Multi-channel through `ctx.notify.send`:
132
142
 
133
143
  ```ts
package/dist/index.d.mts CHANGED
@@ -374,7 +374,11 @@ interface PushOwner {
374
374
  */
375
375
  userId: string | null | undefined;
376
376
  }
377
- /** A push payload without its `to` target — the facade derives `to` from the stored subscription. */
377
+ /**
378
+ * A push payload without its `to` target — the facade derives `to` from the
379
+ * stored subscription. Its `ttl` / `urgency` override the channel defaults per
380
+ * message on every push transport (Web Push, FCM, APNs, Expo).
381
+ */
378
382
  type PushContent = Omit<PushPayload, "to">;
379
383
  /**
380
384
  * The multi-channel notification facade — spliced onto ctx as `ctx.notify`.
package/dist/index.d.ts CHANGED
@@ -374,7 +374,11 @@ interface PushOwner {
374
374
  */
375
375
  userId: string | null | undefined;
376
376
  }
377
- /** A push payload without its `to` target — the facade derives `to` from the stored subscription. */
377
+ /**
378
+ * A push payload without its `to` target — the facade derives `to` from the
379
+ * stored subscription. Its `ttl` / `urgency` override the channel defaults per
380
+ * message on every push transport (Web Push, FCM, APNs, Expo).
381
+ */
378
382
  type PushContent = Omit<PushPayload, "to">;
379
383
  /**
380
384
  * The multi-channel notification facade — spliced onto ctx as `ctx.notify`.
package/dist/index.mjs CHANGED
@@ -1 +1 @@
1
- import{FCM_ENV_KEYS as e,WEB_PUSH_ENV_KEYS as t,fcmFromEnv as i,webPushFromEnv as f}from"./packem_shared/FCM_ENV_KEYS-BbhPScGH.mjs";import{defineNotify as n,isNotifyDefinition as m}from"./packem_shared/defineNotify-DVPpVkU0.mjs";import{createNotify as u}from"./packem_shared/createNotify-BB43NqyO.mjs";import{buildEngine as E,routingPushProvider as d}from"./packem_shared/buildEngine-BMMRV5cw.mjs";import{enqueuePushBroadcast as P,runPushBroadcastPage as c}from"./packem_shared/enqueuePushBroadcast-C9tO0BZj.mjs";import{d1SubscriptionStore as b}from"./packem_shared/d1SubscriptionStore-h6gSRs-m.mjs";import{memorySubscriptionStore as h}from"./packem_shared/memorySubscriptionStore-q8KD05cV.mjs";import{f as _,i as y,n as v,t as w,w as B}from"./packem_shared/normalize-B5B9QUol.mjs";export{e as FCM_ENV_KEYS,t as WEB_PUSH_ENV_KEYS,E as buildEngine,u as createNotify,b as d1SubscriptionStore,n as defineNotify,P as enqueuePushBroadcast,i as fcmFromEnv,_ as fcmId,y as isGoneError,m as isNotifyDefinition,h as memorySubscriptionStore,v as normalizeRegisterInput,d as routingPushProvider,c as runPushBroadcastPage,w as targetOf,f as webPushFromEnv,B as webPushId};
1
+ import{FCM_ENV_KEYS as e,WEB_PUSH_ENV_KEYS as t,fcmFromEnv as i,webPushFromEnv as f}from"./packem_shared/FCM_ENV_KEYS-BbhPScGH.mjs";import{defineNotify as n,isNotifyDefinition as m}from"./packem_shared/defineNotify-DVPpVkU0.mjs";import{createNotify as u}from"./packem_shared/createNotify-DnO2QQ8p.mjs";import{buildEngine as E,routingPushProvider as d}from"./packem_shared/buildEngine-x7r_sr3M.mjs";import{enqueuePushBroadcast as P,runPushBroadcastPage as c}from"./packem_shared/enqueuePushBroadcast-C9tO0BZj.mjs";import{d1SubscriptionStore as b}from"./packem_shared/d1SubscriptionStore-h6gSRs-m.mjs";import{memorySubscriptionStore as h}from"./packem_shared/memorySubscriptionStore-q8KD05cV.mjs";import{f as _,i as y,n as v,t as w,w as B}from"./packem_shared/normalize-B5B9QUol.mjs";export{e as FCM_ENV_KEYS,t as WEB_PUSH_ENV_KEYS,E as buildEngine,u as createNotify,b as d1SubscriptionStore,n as defineNotify,P as enqueuePushBroadcast,i as fcmFromEnv,_ as fcmId,y as isGoneError,m as isNotifyDefinition,h as memorySubscriptionStore,v as normalizeRegisterInput,d as routingPushProvider,c as runPushBroadcastPage,w as targetOf,f as webPushFromEnv,B as webPushId};
@@ -0,0 +1 @@
1
+ import{LunoraError as d,isLunoraError as U}from"@lunora/errors";import{createNotification as L}from"@visulima/notification";import{retryMiddleware as _}from"@visulima/notification/middleware";import{createFcmProvider as B}from"@visulima/notification/providers/fcm";import{createWebPushProvider as H}from"@visulima/notification/providers/web-push";import{a as F,p as k,b as G,d as M,i as z}from"./normalize-B5B9QUol.mjs";const V=(e,t)=>{if(e.size<t)return;const r=e.keys().next().value;r!==void 0&&e.delete(r)},j="https://cloudflare-dns.com/dns-query",E=1,A=28,Y=2e3,Q=(e,t)=>{if(t===E){const r=k(e);return r===void 0||G(r)}return M(e.toLowerCase())},I=async(e,t,r)=>{try{const s=await fetch(`${j}?name=${encodeURIComponent(e)}&type=${String(t)}`,{headers:{accept:"application/dns-json"},signal:AbortSignal.timeout(r)});if(!s.ok)return;const n=await s.json();return n.Status===void 0||n.Status===0?n.Answer??[]:[]}catch{return}},J=async(e,t=Y)=>{const r=F(e);if(r.includes(":")||k(r)!==void 0)return{kind:"skipped"};const[s,n]=await Promise.all([I(r,E,t),I(r,A,t)]);if(s===void 0||n===void 0)return{kind:"failed"};const o=[...s,...n].filter(i=>i.type===E||i.type===A);for(const i of o)if(Q(i.data,i.type))return{address:i.data,kind:"private"};return o.length===0?{kind:"unresolved"}:{kind:"public"}},$=e=>{let t=e;if(typeof e=="string"){if(!e.startsWith("{"))return;try{t=JSON.parse(e)}catch{return}}const r=t?.endpoint;return typeof r=="string"?r:void 0},K=e=>$(e)===void 0?"fcm":"web-push",h="lunora-push-router",y=new Map,W=256,q=async(e,t)=>{if(t!==void 0&&t.length>0)return;let r;try{({hostname:r}=new URL(e))}catch{return}const s=y.get(r),n=s??J(r),o=await n;if(s===void 0&&(o.kind==="private"||o.kind==="public")&&(V(y,W),y.set(r,n)),o.kind==="private")throw new d("FORBIDDEN",`@lunora/notify: web-push endpoint host "${r}" resolves to a private/internal address (${o.address}); refusing to send (DNS-rebinding guard)`);if(o.kind==="unresolved")throw new d("FORBIDDEN",`@lunora/notify: web-push endpoint host "${r}" did not resolve to any address; refusing to send (DNS-rebinding guard)`);if(o.kind==="failed")throw new d("FORBIDDEN",`@lunora/notify: could not verify where web-push endpoint host "${r}" resolves (the DNS-over-HTTPS lookup failed); refusing to send (DNS-rebinding guard)`)},x=(e,t)=>{const r=[e.provider,t.provider].filter(n=>n!==void 0),s=[...e.recipients??[],...t.recipients??[]];return{...e,messageId:[e.messageId,t.messageId].join(","),response:[e.response,t.response],sent:e.sent&&t.sent,timestamp:new Date(Math.max(e.timestamp.getTime(),t.timestamp.getTime())),...r.length>0?{provider:r.join(",")}:{},...s.length>0?{recipients:s}:{}}},O=e=>e instanceof Error?e.message:typeof e=="string"?e:void 0,N="PUSH_PARTIALLY_DELIVERED",C=e=>U(e)&&e.code===N,X=e=>{if(!C(e))return;const{data:t}=e;return t==="fcm"||t==="web-push"?t:void 0},m=(e,t)=>{if(!e.success||!t.success){if(e.success||t.success){const[s,n]=e.success?["web-push","fcm"]:["fcm","web-push"],o=e.success?t.error:e.error;return{error:new d(N,`@lunora/notify: push partially delivered — the ${s} group was accepted, the ${n} group failed and re-attempting it did not recover it (${O(o)??"unknown error"}). The whole send is not retried: that would re-send to the ${s} targets that already received it. Re-send to the ${n} targets only.`,{cause:o,data:n}),success:!1}}return{error:new AggregateError([e.error,t.error],"@lunora/notify: both push target groups failed"),success:!1}}const r=e.data===void 0||t.data===void 0?e.data??t.data:x(e.data,t.data);return r===void 0?{success:!0}:{data:r,success:!0}},v=e=>z(O(e)),p=5,Z=3e4,ee=e=>{if(e.provider!==h)return[e.provider];const{to:t}=e.payload,r=Array.isArray(t)?t:[t],s=new Set(r.map(n=>K(n)));return s.size===0?[e.provider]:[...s].map(n=>`${e.provider}:${n}`)},te=(e,t)=>e.failures>=p&&t-e.openedAt<Z,re=(e,t,r)=>{const s=X(e),n=s===void 0?void 0:`${h}:${s}`,o=n!==void 0&&t.includes(n);for(const[i,a]of r.entries())o&&t[i]!==n?a.failures=0:(a.failures+=1,a.failures>=p&&(a.openedAt=Date.now()))},ne=()=>{const e=new Map,t=r=>{const s=e.get(r);if(s!==void 0)return s;const n={failures:0,openedAt:0};return e.set(r,n),n};return async(r,s)=>{const n=ee(r),o=n.map(u=>t(u)),i=Date.now();if(o.every(u=>te(u,i)))return{error:new d("SERVICE_UNAVAILABLE",`@lunora/notify: circuit open for "${n.join('", "')}" after ${p.toString()} consecutive failures`),success:!1};for(const u of o)u.failures>=p&&(u.failures=p-1);const a=await s(r);if(a.success)for(const u of o)u.failures=0;else v(a.error)||re(a.error,n,o);return a}},se=2,b=async(e,t)=>{try{return await e.send(t)}catch(r){return{error:r,success:!1}}},oe=new Set(["high","low","normal","very-low"]),ie=e=>oe.has(e),ve=e=>{if(e!==void 0&&(typeof e!="number"||!Number.isInteger(e)||e<0))throw new d("BAD_REQUEST",`@lunora/notify: push ttl must be a non-negative integer number of seconds, got ${typeof e=="number"?String(e):typeof e}`);return e},we=e=>{if(e!==void 0&&!ie(e))throw new d("BAD_REQUEST",`@lunora/notify: push urgency must be "very-low", "low", "normal" or "high", got ${JSON.stringify(e)}`)},ae=e=>{const t=_({baseDelay:e.retryBaseDelay,retries:se,shouldRetry:n=>!v(n)}),r=async(n,o,i)=>v(i.error)?i:t({channel:"push",payload:o,provider:h},async a=>b(n,a.payload)),s=n=>{const o=n===void 0?e.fcm:e.webPush;if(o===void 0)throw new Error(n===void 0?"@lunora/notify: received an FCM token target but no `fcm` channel is configured":"@lunora/notify: received a web-push target but no `webPush` channel is configured");return o};return{channel:"push",id:h,initialize:async()=>{await e.webPush?.initialize(),await e.fcm?.initialize()},isAvailable:()=>(e.webPush??e.fcm)!==void 0,send:async n=>{const o=Array.isArray(n.to)?n.to:[n.to];if(o.length===0)throw new d("BAD_REQUEST","@lunora/notify: push send has no recipients — `to` is an empty array");const i=o.map(c=>$(c));for(const c of i)c!==void 0&&await q(c,e.allowedPushOrigins);const a=o.filter((c,g)=>i[g]!==void 0),u=o.filter((c,g)=>i[g]===void 0),w=i.find(c=>c!==void 0);if(u.length===0&&w!==void 0)return s(w).send(n);if(a.length===0)return s(void 0).send(n);const R=c=>({...n,to:c.length===1&&c[0]!==void 0?c[0]:c}),D=s(w),P=s(void 0),S=R(a),T=R(u),[f,l]=await Promise.all([b(D,S),b(P,T)]);return f.success&&!l.success?m(f,await r(P,T,l)):l.success&&!f.success?m(await r(D,S,f),l):m(f,l)}}},ce=(e,t={})=>e.use(_({baseDelay:t.retryBaseDelay,shouldRetry:r=>!v(r)&&!C(r)})).use(ne()),ge=e=>{const t=e.webPush===void 0?void 0:H(e.webPush),r=e.fcm===void 0?void 0:B(e.fcm),s={};return(t!==void 0||r!==void 0)&&(s.push=ae({allowedPushOrigins:e.allowedPushOrigins,fcm:r,webPush:t})),e.chat!==void 0&&(s.chat=e.chat),e.inApp!==void 0&&(s.inapp=e.inApp),e.webhook!==void 0&&(s.webhook=e.webhook),ce(L(s))};export{ce as attachResilience,ge as buildEngine,ve as checkPushTtl,we as checkPushUrgency,ae as routingPushProvider};
@@ -0,0 +1 @@
1
+ import{LunoraError as v}from"@lunora/errors";import{buildEngine as R,checkPushTtl as x,checkPushUrgency as C}from"./buildEngine-x7r_sr3M.mjs";import{memorySubscriptionStore as B}from"./memorySubscriptionStore-q8KD05cV.mjs";import{n as L,t as U,i as W}from"./normalize-B5B9QUol.mjs";const j=250,w=(o,u)=>typeof o=="function"?o(u):o,S=o=>o.successful?void 0:o.errorMessages.join("; "),G=(o,u,d)=>o.successful?"accepted":W(u,d)?"gone":"failed",Q=async(o,u,d)=>{const l=Array.from({length:o.length});let h=0;const g=async()=>{for(;h<o.length;){const f=h;h+=1,l[f]=await d(o[f])}};return await Promise.all(Array.from({length:Math.min(u,o.length)},()=>g())),l},Z=(o,u)=>({allowedPushOrigins:o.allowedPushOrigins,chat:w(o.chat,u),fcm:w(o.fcm,u),inApp:w(o.inApp,u),webhook:w(o.webhook,u),webPush:w(o.webPush,u)}),M=new WeakMap,q=(o,u)=>{let d=M.get(o);d===void 0&&(d=new WeakMap,M.set(o,d));let l=d.get(u);return l===void 0&&(l={warnedNoPushOriginAllowlist:!1,warnedNoStore:!1},d.set(u,l)),l},te=(o,u,d={})=>{const l=q(o,u);let h;d.engine===void 0?(l.engine??=R(Z(o,u)),h=l.engine):h=d.engine,l.store??=o.store?.(u);let{store:g}=l;g===void 0&&(l.fallbackStore??=B(),!d.silent&&!l.warnedNoStore&&(l.warnedNoStore=!0,console.warn("@lunora/notify: no `store` configured — using a non-durable in-memory subscription store. Configure `store: (env) => d1SubscriptionStore(env.DB)` for production.")),g=l.fallbackStore);const f=g,I=Math.max(1,d.concurrency??o.concurrency??10),P=Math.max(1,d.broadcastPageSize??o.broadcastPageSize??j),{log:T,metrics:b}=d,p=(e,t,r,n=1)=>{b?.count("notify.send",n,{channel:e,provider:t??e,status:r})},y=(e,t,r)=>{T?.warn(`notify ${e} delivery failed`,{channel:e,provider:t??e,status:"failed",...r})},A=(e,t)=>{b?.count("notify.skipped",1,{channel:e,reason:t})},_=()=>{const e=o.allowedPushOrigins!==void 0&&o.allowedPushOrigins.length>0;d.silent||e||l.warnedNoPushOriginAllowlist||(l.warnedNoPushOriginAllowlist=!0,console.warn("@lunora/notify: Web Push registered without `allowedPushOrigins` — endpoints are guarded by a string classifier at register time and a best-effort DNS re-check at send time, both of which are defeatable. Set `allowedPushOrigins` to the exact push-service origins for a hard guarantee."))},D=async e=>(await f.list(e)).map(({keys:r,token:n,...a})=>a),$=async e=>{if(typeof e!="string")return e;const t=await f.get(e);if(t===void 0)throw new v("BAD_REQUEST",`@lunora/notify: no registered subscription with id "${e}"`);return t},O=async(e,t,r)=>{let n,a,s;try{n=await h.sendToChannel("push",{...t,to:U(e)}),a=S(n),s=G(n,a,e.kind)}catch(c){s="failed",a=c instanceof Error?c.message:String(c)}try{s==="accepted"?await f.markStatus(e.id,"ok"):s==="gone"?await f.delete(e.id):await f.markStatus(e.id,"failed",a)}catch{}return s==="failed"&&y("push",e.kind,{error:a,subscriptionId:e.id,userId:e.userId??null}),r&&p("push",e.kind,s),{error:a,receipt:n,status:s}},z=async(e,t)=>{const r=await Q(t,I,async s=>{const{error:c,status:i}=await O(s,e,!1);return{error:c,kind:s.kind,status:i,subscription:s}}),n=new Map;for(const{kind:s,status:c}of r){const i=`${s} ${c}`,m=n.get(i);m===void 0?n.set(i,{count:1,kind:s,status:c}):m.count+=1}for(const{count:s,kind:c,status:i}of n.values())p("push",c,i,s);const a=r.map(({error:s,status:c,subscription:i})=>c==="accepted"?{id:i.id,status:"ok"}:c==="gone"?{error:s,id:i.id,status:"expired"}:{error:s,id:i.id,status:"failed"});return{failed:a.filter(s=>s.status==="failed").length,outcomes:a,pruned:a.filter(s=>s.status==="expired").length,sent:a.filter(s=>s.status==="ok").length,total:a.length}},E=async(e,t)=>{if(x(e.ttl),C(e.urgency),t?.limit!==void 0&&t.limit<=0)return{nextCursor:void 0,result:{failed:0,outcomes:[],pruned:0,sent:0,total:0}};const r=t?.limit!==void 0&&t.limit>0?Math.trunc(t.limit):void 0,n=r===void 0?P:Math.min(r,P),a=await f.list({after:t?.after,kind:t?.kind,limit:n+1,userId:t?.userId}),s=t?.after===void 0?a:a.filter(F=>F.id>t.after),c=s.length>n,i=c?s.slice(0,n):s;i.length===0&&t?.after===void 0&&A("push","no-subscriptions-matched");const m=await z(e,i);return{nextCursor:c?i[i.length-1]?.id:void 0,result:m}},N={broadcast:async(e,t)=>{const r={failed:0,outcomes:[],pruned:0,sent:0,total:0};let n=t?.after;const a=t?.limit;if(a!==void 0&&a<=0)return r;for(;;){const s=a===void 0?{...t,after:n}:{...t,after:n,limit:a-r.total},{nextCursor:c,result:i}=await E(e,s);if(r.failed+=i.failed,r.pruned+=i.pruned,r.sent+=i.sent,r.total+=i.total,r.outcomes.push(...i.outcomes),a!==void 0&&r.total>=a||c===void 0||c===n)break;n=c}return r},broadcastPage:E,list:e=>D(e),register:e=>("token"in e||_(),f.put(L(e,void 0,{allowedPushOrigins:o.allowedPushOrigins}))),send:async(e,t)=>{x(t.ttl),C(t.urgency);const{error:r,receipt:n}=await O(await $(e),t,!0);if(n===void 0)throw new v("INTERNAL",`@lunora/notify: push send failed: ${r??"unknown error"}`);return n},unregister:async(e,t)=>{await f.deleteOwned(e,t.userId??null)}},k=async(e,t)=>{if(h.getProvider(e)===void 0)throw A(e,"channel-not-configured"),new v("BAD_REQUEST",`@lunora/notify: the "${e}" channel is not configured in defineNotify(...)`);const r=await h.sendToChannel(e,t),n=r.successful?"accepted":"failed";return p(e,r.provider,n),n==="failed"&&y(e,r.provider,{error:S(r)}),r};return{notify:{chat:e=>k("chat",e),inApp:e=>k("inapp",e),push:N,send:async e=>{const t=await h.send(e);for(const r of t){const n=r.channel??"unknown",a=r.successful?"accepted":"failed";p(n,r.provider,a),a==="failed"&&y(n,r.provider,{error:S(r)})}return t},webhook:e=>k("webhook",e)},push:N}};export{te as createNotify};
package/dist/web.d.mts CHANGED
@@ -1,3 +1,13 @@
1
+ /**
2
+ * How long {@link existingRegistration} waits for a REGISTERED service worker to
3
+ * become active before failing (ms). Boundless, `navigator.serviceWorker.ready`
4
+ * never rejects — a worker stuck in `installing` forever (failed install,
5
+ * hung update script) leaves callers hanging. After this bound, the caller
6
+ * fails instead. Generous: activation normally completes in well under a second
7
+ * after the script is fetched; the bound only trades a permanent hang for a
8
+ * bounded failure.
9
+ */
10
+ declare const READY_WAIT_TIMEOUT_MS = 3e4;
1
11
  interface SerializedPushSubscription {
2
12
  endpoint: string;
3
13
  expirationTime: number | null;
@@ -38,7 +48,8 @@ interface SubscribeToPushOptions {
38
48
  scope?: string;
39
49
  /**
40
50
  * URL of the service worker script to register. Omit to reuse the page's
41
- * already-active registration (`navigator.serviceWorker.ready`).
51
+ * existing registration (`navigator.serviceWorker.ready`); the call rejects
52
+ * when the page has none.
42
53
  */
43
54
  serviceWorkerUrl?: string;
44
55
  /**
@@ -58,9 +69,14 @@ declare const isPushSupported: () => boolean;
58
69
  /**
59
70
  * Register (or reuse) a service worker and subscribe the browser to Web Push,
60
71
  * returning the subscription in serialisable form. Reuses an existing subscription
61
- * when present. Throws if push is unsupported or the user denies permission.
72
+ * when present. Throws if push is unsupported, no service worker is registered
73
+ * (without `serviceWorkerUrl`), or the user denies permission.
74
+ *
75
+ * Without `serviceWorkerUrl`, await your own `navigator.serviceWorker.register()`
76
+ * before calling this: a registration still in flight is not found yet, and the
77
+ * call rejects rather than waiting for it.
62
78
  */
63
79
  declare const subscribeToPush: (options: SubscribeToPushOptions) => Promise<SubscribeToPushResult>;
64
80
  /** Unsubscribe the browser's current Web Push subscription. Returns whether one was removed. */
65
81
  declare const unsubscribeFromPush: () => Promise<boolean>;
66
- export { type SerializedPushSubscription, type SubscribeToPushOptions, type SubscribeToPushResult, isPushSupported, subscribeToPush, unsubscribeFromPush };
82
+ export { READY_WAIT_TIMEOUT_MS, type SerializedPushSubscription, type SubscribeToPushOptions, type SubscribeToPushResult, isPushSupported, subscribeToPush, unsubscribeFromPush };
package/dist/web.d.ts CHANGED
@@ -1,3 +1,13 @@
1
+ /**
2
+ * How long {@link existingRegistration} waits for a REGISTERED service worker to
3
+ * become active before failing (ms). Boundless, `navigator.serviceWorker.ready`
4
+ * never rejects — a worker stuck in `installing` forever (failed install,
5
+ * hung update script) leaves callers hanging. After this bound, the caller
6
+ * fails instead. Generous: activation normally completes in well under a second
7
+ * after the script is fetched; the bound only trades a permanent hang for a
8
+ * bounded failure.
9
+ */
10
+ declare const READY_WAIT_TIMEOUT_MS = 3e4;
1
11
  interface SerializedPushSubscription {
2
12
  endpoint: string;
3
13
  expirationTime: number | null;
@@ -38,7 +48,8 @@ interface SubscribeToPushOptions {
38
48
  scope?: string;
39
49
  /**
40
50
  * URL of the service worker script to register. Omit to reuse the page's
41
- * already-active registration (`navigator.serviceWorker.ready`).
51
+ * existing registration (`navigator.serviceWorker.ready`); the call rejects
52
+ * when the page has none.
42
53
  */
43
54
  serviceWorkerUrl?: string;
44
55
  /**
@@ -58,9 +69,14 @@ declare const isPushSupported: () => boolean;
58
69
  /**
59
70
  * Register (or reuse) a service worker and subscribe the browser to Web Push,
60
71
  * returning the subscription in serialisable form. Reuses an existing subscription
61
- * when present. Throws if push is unsupported or the user denies permission.
72
+ * when present. Throws if push is unsupported, no service worker is registered
73
+ * (without `serviceWorkerUrl`), or the user denies permission.
74
+ *
75
+ * Without `serviceWorkerUrl`, await your own `navigator.serviceWorker.register()`
76
+ * before calling this: a registration still in flight is not found yet, and the
77
+ * call rejects rather than waiting for it.
62
78
  */
63
79
  declare const subscribeToPush: (options: SubscribeToPushOptions) => Promise<SubscribeToPushResult>;
64
80
  /** Unsubscribe the browser's current Web Push subscription. Returns whether one was removed. */
65
81
  declare const unsubscribeFromPush: () => Promise<boolean>;
66
- export { type SerializedPushSubscription, type SubscribeToPushOptions, type SubscribeToPushResult, isPushSupported, subscribeToPush, unsubscribeFromPush };
82
+ export { READY_WAIT_TIMEOUT_MS, type SerializedPushSubscription, type SubscribeToPushOptions, type SubscribeToPushResult, isPushSupported, subscribeToPush, unsubscribeFromPush };
package/dist/web.mjs CHANGED
@@ -1 +1 @@
1
- const p=r=>{const e=atob(r),t=new Uint8Array(e.length);for(let i=0;i<e.length;i+=1)t[i]=e.codePointAt(i)??0;return t},b=r=>{const e=r.replaceAll("-","+").replaceAll("_","/"),t=e+"=".repeat((4-e.length%4)%4);return p(t)},a=r=>b(r),d=(r,e)=>r.length===e.length&&r.every((t,i)=>t===e[i]),g=(r,e)=>{const t=r.options.applicationServerKey;return t===null?!1:d(new Uint8Array(t),a(e))},s=globalThis,c=()=>s.navigator?.serviceWorker!==void 0&&s.PushManager!==void 0&&s.Notification!==void 0,v=async r=>{if(!c())throw new Error("@lunora/notify: Web Push is not supported in this browser (needs service workers + PushManager + Notification)");let e;if(r.serviceWorkerUrl===void 0)e=await navigator.serviceWorker.ready;else{const u=r.scope===void 0?void 0:{scope:r.scope};e=await navigator.serviceWorker.register(r.serviceWorkerUrl,u)}const t=await Notification.requestPermission();if(t!=="granted")throw new Error(`@lunora/notify: notification permission was not granted (got "${t}")`);const i=await e.pushManager.getSubscription();let n=null,o;i!==null&&(g(i,r.vapidPublicKey)?n=i:(o=i.endpoint,await i.unsubscribe()));const l=n??await e.pushManager.subscribe({applicationServerKey:a(r.vapidPublicKey),userVisibleOnly:!0});return{replacedEndpoint:o,subscription:l.toJSON()}},f=async()=>{if(!c())return!1;const e=await(await navigator.serviceWorker.ready).pushManager.getSubscription();return e===null?!1:e.unsubscribe()};export{c as isPushSupported,v as subscribeToPush,f as unsubscribeFromPush};
1
+ const p=e=>{const r=atob(e),t=new Uint8Array(r.length);for(let i=0;i<r.length;i+=1)t[i]=r.codePointAt(i)??0;return t},v=e=>{const r=e.replaceAll("-","+").replaceAll("_","/"),t=r+"=".repeat((4-r.length%4)%4);return p(t)},w=3e4,c="@lunora/notify: the registered service worker never became active",l=e=>v(e),d=(e,r)=>e.length===r.length&&e.every((t,i)=>t===r[i]),b=(e,r)=>{const t=e.options.applicationServerKey;return t===null?!1:d(new Uint8Array(t),l(r))},o=globalThis,u=()=>o.navigator?.serviceWorker!==void 0&&o.PushManager!==void 0&&o.Notification!==void 0,g=async()=>{const e=await navigator.serviceWorker.getRegistration();return e?.active!==null?e:await new Promise((r,t)=>{const i=setTimeout(()=>{t(new Error(c))},3e4);navigator.serviceWorker.ready.then(n=>(clearTimeout(i),r(n),n)).catch(n=>{clearTimeout(i),t(n instanceof Error?n:new Error(String(n)))})})},h=async e=>{if(!u())throw new Error("@lunora/notify: Web Push is not supported in this browser (needs service workers + PushManager + Notification)");let r;if(e.serviceWorkerUrl===void 0){const s=await g();if(s===void 0)throw new Error("@lunora/notify: no service worker is registered for this page — register one first, or pass `serviceWorkerUrl`");r=s}else{const s=e.scope===void 0?void 0:{scope:e.scope};r=await navigator.serviceWorker.register(e.serviceWorkerUrl,s)}const t=await Notification.requestPermission();if(t!=="granted")throw new Error(`@lunora/notify: notification permission was not granted (got "${t}")`);const i=await r.pushManager.getSubscription();let n=null,a;i!==null&&(b(i,e.vapidPublicKey)?n=i:(a=i.endpoint,await i.unsubscribe()));const f=n??await r.pushManager.subscribe({applicationServerKey:l(e.vapidPublicKey),userVisibleOnly:!0});return{replacedEndpoint:a,subscription:f.toJSON()}},y=async()=>{if(!u())return!1;let e;try{e=await g()}catch(t){if(t instanceof Error&&t.message===c)return!1;throw t}if(e===void 0)return!1;const r=await e.pushManager.getSubscription();return r===null?!1:r.unsubscribe()};export{w as READY_WAIT_TIMEOUT_MS,u as isPushSupported,h as subscribeToPush,y as unsubscribeFromPush};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lunora/notify",
3
- "version": "1.0.0-alpha.76",
3
+ "version": "1.0.0-alpha.78",
4
4
  "description": "Multi-channel notifications for Lunora — ctx.notify / ctx.push over @visulima/notification: edge-safe Web Push + FCM, plus chat, in-app inbox and webhook channels, with subscription storage and queue-backed fan-out",
5
5
  "keywords": [
6
6
  "cloudflare",
@@ -49,8 +49,8 @@
49
49
  "access": "public"
50
50
  },
51
51
  "dependencies": {
52
- "@lunora/errors": "1.0.0-alpha.51",
53
- "@visulima/notification": "1.1.22"
52
+ "@lunora/errors": ">=1.0.0-alpha.51 <2.0.0-0",
53
+ "@visulima/notification": "1.2.0"
54
54
  },
55
55
  "engines": {
56
56
  "node": "^22.15.0 || >=24.11.0"
@@ -1 +0,0 @@
1
- import{LunoraError as u,isLunoraError as L}from"@lunora/errors";import{createNotification as U}from"@visulima/notification";import{retryMiddleware as _}from"@visulima/notification/middleware";import{createFcmProvider as B}from"@visulima/notification/providers/fcm";import{createWebPushProvider as H}from"@visulima/notification/providers/web-push";import{a as F,p as k,b as G,d as M,i as z}from"./normalize-B5B9QUol.mjs";const V=(e,t)=>{if(e.size<t)return;const r=e.keys().next().value;r!==void 0&&e.delete(r)},j="https://cloudflare-dns.com/dns-query",E=1,T=28,Y=2e3,K=(e,t)=>{if(t===E){const r=k(e);return r===void 0||G(r)}return M(e.toLowerCase())},S=async(e,t,r)=>{try{const n=await fetch(`${j}?name=${encodeURIComponent(e)}&type=${String(t)}`,{headers:{accept:"application/dns-json"},signal:AbortSignal.timeout(r)});if(!n.ok)return;const s=await n.json();return s.Status===void 0||s.Status===0?s.Answer??[]:[]}catch{return}},W=async(e,t=Y)=>{const r=F(e);if(r.includes(":")||k(r)!==void 0)return{kind:"skipped"};const[n,s]=await Promise.all([S(r,E,t),S(r,T,t)]);if(n===void 0||s===void 0)return{kind:"failed"};const o=[...n,...s].filter(i=>i.type===E||i.type===T);for(const i of o)if(K(i.data,i.type))return{address:i.data,kind:"private"};return o.length===0?{kind:"unresolved"}:{kind:"public"}},O=e=>{let t=e;if(typeof e=="string"){if(!e.startsWith("{"))return;try{t=JSON.parse(e)}catch{return}}const r=t?.endpoint;return typeof r=="string"?r:void 0},q=e=>O(e)===void 0?"fcm":"web-push",h="lunora-push-router",g=new Map,x=256,J=async(e,t)=>{if(t!==void 0&&t.length>0)return;let r;try{({hostname:r}=new URL(e))}catch{return}const n=g.get(r),s=n??W(r),o=await s;if(n===void 0&&(o.kind==="private"||o.kind==="public")&&(V(g,x),g.set(r,s)),o.kind==="private")throw new u("FORBIDDEN",`@lunora/notify: web-push endpoint host "${r}" resolves to a private/internal address (${o.address}); refusing to send (DNS-rebinding guard)`);if(o.kind==="unresolved")throw new u("FORBIDDEN",`@lunora/notify: web-push endpoint host "${r}" did not resolve to any address; refusing to send (DNS-rebinding guard)`);if(o.kind==="failed")throw new u("FORBIDDEN",`@lunora/notify: could not verify where web-push endpoint host "${r}" resolves (the DNS-over-HTTPS lookup failed); refusing to send (DNS-rebinding guard)`)},Q=(e,t)=>{const r=[e.provider,t.provider].filter(s=>s!==void 0),n=[...e.recipients??[],...t.recipients??[]];return{...e,messageId:[e.messageId,t.messageId].join(","),response:[e.response,t.response],sent:e.sent&&t.sent,timestamp:new Date(Math.max(e.timestamp.getTime(),t.timestamp.getTime())),...r.length>0?{provider:r.join(",")}:{},...n.length>0?{recipients:n}:{}}},$=e=>e instanceof Error?e.message:typeof e=="string"?e:void 0,C="PUSH_PARTIALLY_DELIVERED",N=e=>L(e)&&e.code===C,X=e=>{if(!N(e))return;const{data:t}=e;return t==="fcm"||t==="web-push"?t:void 0},m=(e,t)=>{if(!e.success||!t.success){if(e.success||t.success){const[n,s]=e.success?["web-push","fcm"]:["fcm","web-push"],o=e.success?t.error:e.error;return{error:new u(C,`@lunora/notify: push partially delivered — the ${n} group was accepted, the ${s} group failed and re-attempting it did not recover it (${$(o)??"unknown error"}). The whole send is not retried: that would re-send to the ${n} targets that already received it. Re-send to the ${s} targets only.`,{cause:o,data:s}),success:!1}}return{error:new AggregateError([e.error,t.error],"@lunora/notify: both push target groups failed"),success:!1}}const r=e.data===void 0||t.data===void 0?e.data??t.data:Q(e.data,t.data);return r===void 0?{success:!0}:{data:r,success:!0}},v=e=>z($(e)),p=5,Z=3e4,ee=e=>{if(e.provider!==h)return[e.provider];const{to:t}=e.payload,r=Array.isArray(t)?t:[t],n=new Set(r.map(s=>q(s)));return n.size===0?[e.provider]:[...n].map(s=>`${e.provider}:${s}`)},te=(e,t)=>e.failures>=p&&t-e.openedAt<Z,re=(e,t,r)=>{const n=X(e),s=n===void 0?void 0:`${h}:${n}`,o=s!==void 0&&t.includes(s);for(const[i,a]of r.entries())o&&t[i]!==s?a.failures=0:(a.failures+=1,a.failures>=p&&(a.openedAt=Date.now()))},se=()=>{const e=new Map,t=r=>{const n=e.get(r);if(n!==void 0)return n;const s={failures:0,openedAt:0};return e.set(r,s),s};return async(r,n)=>{const s=ee(r),o=s.map(d=>t(d)),i=Date.now();if(o.every(d=>te(d,i)))return{error:new u("SERVICE_UNAVAILABLE",`@lunora/notify: circuit open for "${s.join('", "')}" after ${p.toString()} consecutive failures`),success:!1};for(const d of o)d.failures>=p&&(d.failures=p-1);const a=await n(r);if(a.success)for(const d of o)d.failures=0;else v(a.error)||re(a.error,s,o);return a}},ne=2,b=async(e,t)=>{try{return await e.send(t)}catch(r){return{error:r,success:!1}}},oe=e=>{const t=_({baseDelay:e.retryBaseDelay,retries:ne,shouldRetry:s=>!v(s)}),r=async(s,o,i)=>v(i.error)?i:t({channel:"push",payload:o,provider:h},async a=>b(s,a.payload)),n=s=>{const o=s===void 0?e.fcm:e.webPush;if(o===void 0)throw new Error(s===void 0?"@lunora/notify: received an FCM token target but no `fcm` channel is configured":"@lunora/notify: received a web-push target but no `webPush` channel is configured");return o};return{channel:"push",id:h,initialize:async()=>{await e.webPush?.initialize(),await e.fcm?.initialize()},isAvailable:()=>(e.webPush??e.fcm)!==void 0,send:async s=>{const o=Array.isArray(s.to)?s.to:[s.to];if(o.length===0)throw new u("BAD_REQUEST","@lunora/notify: push send has no recipients — `to` is an empty array");const i=o.map(c=>O(c));for(const c of i)c!==void 0&&await J(c,e.allowedPushOrigins);const a=o.filter((c,y)=>i[y]!==void 0),d=o.filter((c,y)=>i[y]===void 0),w=i.find(c=>c!==void 0);if(d.length===0&&w!==void 0)return n(w).send(s);if(a.length===0)return n(void 0).send(s);const R=c=>({...s,to:c.length===1&&c[0]!==void 0?c[0]:c}),D=n(w),P=n(void 0),A=R(a),I=R(d),[f,l]=await Promise.all([b(D,A),b(P,I)]);return f.success&&!l.success?m(f,await r(P,I,l)):l.success&&!f.success?m(await r(D,A,f),l):m(f,l)}}},ie=(e,t={})=>e.use(_({baseDelay:t.retryBaseDelay,shouldRetry:r=>!v(r)&&!N(r)})).use(se()),pe=e=>{const t=e.webPush===void 0?void 0:H(e.webPush),r=e.fcm===void 0?void 0:B(e.fcm),n={};return(t!==void 0||r!==void 0)&&(n.push=oe({allowedPushOrigins:e.allowedPushOrigins,fcm:r,webPush:t})),e.chat!==void 0&&(n.chat=e.chat),e.inApp!==void 0&&(n.inapp=e.inApp),e.webhook!==void 0&&(n.webhook=e.webhook),ie(U(n))};export{ie as attachResilience,pe as buildEngine,oe as routingPushProvider};
@@ -1 +0,0 @@
1
- import{LunoraError as v}from"@lunora/errors";import{buildEngine as z}from"./buildEngine-BMMRV5cw.mjs";import{memorySubscriptionStore as F}from"./memorySubscriptionStore-q8KD05cV.mjs";import{n as R,t as B,i as L}from"./normalize-B5B9QUol.mjs";const W=250,w=(o,l)=>typeof o=="function"?o(l):o,S=o=>o.successful?void 0:o.errorMessages.join("; "),U=(o,l,d)=>o.successful?"accepted":L(l,d)?"gone":"failed",j=async(o,l,d)=>{const u=Array.from({length:o.length});let h=0;const g=async()=>{for(;h<o.length;){const f=h;h+=1,u[f]=await d(o[f])}};return await Promise.all(Array.from({length:Math.min(l,o.length)},()=>g())),u},G=(o,l)=>({allowedPushOrigins:o.allowedPushOrigins,chat:w(o.chat,l),fcm:w(o.fcm,l),inApp:w(o.inApp,l),webhook:w(o.webhook,l),webPush:w(o.webPush,l)}),x=new WeakMap,Q=(o,l)=>{let d=x.get(o);d===void 0&&(d=new WeakMap,x.set(o,d));let u=d.get(l);return u===void 0&&(u={warnedNoPushOriginAllowlist:!1,warnedNoStore:!1},d.set(l,u)),u},Y=(o,l,d={})=>{const u=Q(o,l);let h;d.engine===void 0?(u.engine??=z(G(o,l)),h=u.engine):h=d.engine,u.store??=o.store?.(l);let{store:g}=u;g===void 0&&(u.fallbackStore??=F(),!d.silent&&!u.warnedNoStore&&(u.warnedNoStore=!0,console.warn("@lunora/notify: no `store` configured — using a non-durable in-memory subscription store. Configure `store: (env) => d1SubscriptionStore(env.DB)` for production.")),g=u.fallbackStore);const f=g,C=Math.max(1,d.concurrency??o.concurrency??10),b=Math.max(1,d.broadcastPageSize??o.broadcastPageSize??W),{log:M,metrics:P}=d,p=(e,t,r,n=1)=>{P?.count("notify.send",n,{channel:e,provider:t??e,status:r})},y=(e,t,r)=>{M?.warn(`notify ${e} delivery failed`,{channel:e,provider:t??e,status:"failed",...r})},A=(e,t)=>{P?.count("notify.skipped",1,{channel:e,reason:t})},I=()=>{const e=o.allowedPushOrigins!==void 0&&o.allowedPushOrigins.length>0;d.silent||e||u.warnedNoPushOriginAllowlist||(u.warnedNoPushOriginAllowlist=!0,console.warn("@lunora/notify: Web Push registered without `allowedPushOrigins` — endpoints are guarded by a string classifier at register time and a best-effort DNS re-check at send time, both of which are defeatable. Set `allowedPushOrigins` to the exact push-service origins for a hard guarantee."))},T=async e=>(await f.list(e)).map(({keys:r,token:n,...a})=>a),_=async e=>{if(typeof e!="string")return e;const t=await f.get(e);if(t===void 0)throw new v("BAD_REQUEST",`@lunora/notify: no registered subscription with id "${e}"`);return t},O=async(e,t,r)=>{let n,a,s;try{n=await h.sendToChannel("push",{...t,to:B(e)}),a=S(n),s=U(n,a,e.kind)}catch(c){s="failed",a=c instanceof Error?c.message:String(c)}try{s==="accepted"?await f.markStatus(e.id,"ok"):s==="gone"?await f.delete(e.id):await f.markStatus(e.id,"failed",a)}catch{}return s==="failed"&&y("push",e.kind,{error:a,subscriptionId:e.id,userId:e.userId??null}),r&&p("push",e.kind,s),{error:a,receipt:n,status:s}},D=async(e,t)=>{const r=await j(t,C,async s=>{const{error:c,status:i}=await O(s,e,!1);return{error:c,kind:s.kind,status:i,subscription:s}}),n=new Map;for(const{kind:s,status:c}of r){const i=`${s} ${c}`,m=n.get(i);m===void 0?n.set(i,{count:1,kind:s,status:c}):m.count+=1}for(const{count:s,kind:c,status:i}of n.values())p("push",c,i,s);const a=r.map(({error:s,status:c,subscription:i})=>c==="accepted"?{id:i.id,status:"ok"}:c==="gone"?{error:s,id:i.id,status:"expired"}:{error:s,id:i.id,status:"failed"});return{failed:a.filter(s=>s.status==="failed").length,outcomes:a,pruned:a.filter(s=>s.status==="expired").length,sent:a.filter(s=>s.status==="ok").length,total:a.length}},E=async(e,t)=>{if(t?.limit!==void 0&&t.limit<=0)return{nextCursor:void 0,result:{failed:0,outcomes:[],pruned:0,sent:0,total:0}};const r=t?.limit!==void 0&&t.limit>0?Math.trunc(t.limit):void 0,n=r===void 0?b:Math.min(r,b),a=await f.list({after:t?.after,kind:t?.kind,limit:n+1,userId:t?.userId}),s=t?.after===void 0?a:a.filter($=>$.id>t.after),c=s.length>n,i=c?s.slice(0,n):s;i.length===0&&t?.after===void 0&&A("push","no-subscriptions-matched");const m=await D(e,i);return{nextCursor:c?i[i.length-1]?.id:void 0,result:m}},N={broadcast:async(e,t)=>{const r={failed:0,outcomes:[],pruned:0,sent:0,total:0};let n=t?.after;const a=t?.limit;if(a!==void 0&&a<=0)return r;for(;;){const s=a===void 0?{...t,after:n}:{...t,after:n,limit:a-r.total},{nextCursor:c,result:i}=await E(e,s);if(r.failed+=i.failed,r.pruned+=i.pruned,r.sent+=i.sent,r.total+=i.total,r.outcomes.push(...i.outcomes),a!==void 0&&r.total>=a||c===void 0||c===n)break;n=c}return r},broadcastPage:E,list:e=>T(e),register:e=>("token"in e||I(),f.put(R(e,void 0,{allowedPushOrigins:o.allowedPushOrigins}))),send:async(e,t)=>{const{error:r,receipt:n}=await O(await _(e),t,!0);if(n===void 0)throw new v("INTERNAL",`@lunora/notify: push send failed: ${r??"unknown error"}`);return n},unregister:async(e,t)=>{await f.deleteOwned(e,t.userId??null)}},k=async(e,t)=>{if(h.getProvider(e)===void 0)throw A(e,"channel-not-configured"),new v("BAD_REQUEST",`@lunora/notify: the "${e}" channel is not configured in defineNotify(...)`);const r=await h.sendToChannel(e,t),n=r.successful?"accepted":"failed";return p(e,r.provider,n),n==="failed"&&y(e,r.provider,{error:S(r)}),r};return{notify:{chat:e=>k("chat",e),inApp:e=>k("inapp",e),push:N,send:async e=>{const t=await h.send(e);for(const r of t){const n=r.channel??"unknown",a=r.successful?"accepted":"failed";p(n,r.provider,a),a==="failed"&&y(n,r.provider,{error:S(r)})}return t},webhook:e=>k("webhook",e)},push:N}};export{Y as createNotify};