@stacksjs/push 0.75.422 → 0.75.423

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.
@@ -21,15 +21,20 @@ export declare function resolveAccessToken(): Promise<string | undefined>;
21
21
  */
22
22
  export declare function isExpoPushToken(token: string): boolean;
23
23
  /**
24
- * Send a push notification via Expo Push Service
24
+ * Send a push notification via Expo Push Service.
25
+ *
26
+ * In requests of at most 100 messages, one after another. `data.tickets` has
27
+ * one ticket per token in `to`, in the same order, an invalid token included
28
+ * as an error, so a caller can match each ticket to its token.
25
29
  */
26
30
  export declare function send(message: ExpoPushMessage): Promise<PushResult>;
27
31
  /**
28
- * Get push notification receipts from Expo
32
+ * Get push notification receipts from Expo, 1000 IDs per request.
29
33
  */
30
34
  export declare function getReceipts(ticketIds: string[]): Promise<Record<string, ExpoPushReceipt>>;
31
35
  /**
32
- * Send multiple push notifications in chunks (Expo recommends max 100 per request)
36
+ * Send several messages, `chunkSize` of them at a time. Each message's own
37
+ * tokens go out in requests of at most 100, which `send` does.
33
38
  */
34
39
  export declare function sendBatch(messages: ExpoPushMessage[], chunkSize?: number): Promise<PushResult[]>;
35
40
  declare const __dtsx_default_export__: {
@@ -1,4 +1,12 @@
1
1
  import type { PushResult } from '@stacksjs/types';
2
+ /**
3
+ * `data` as FCM v1 takes it: a map of strings. It answers 400 INVALID_ARGUMENT
4
+ * for any other value, and `push.send()` takes the same `data` for every
5
+ * driver - Expo and APNs carry any JSON - so `{ orderId: 42 }` failed only
6
+ * here. A value that is not a string goes as its JSON text; an undefined one
7
+ * is left out, as JSON would leave it out.
8
+ */
9
+ export declare function fcmData(data: Record<string, unknown> | undefined): Record<string, string> | undefined;
2
10
  /**
3
11
  * Configure FCM with a service account
4
12
  *
@@ -84,7 +92,7 @@ export declare interface FCMMessage {
84
92
  clickAction?: string
85
93
  tag?: string
86
94
  }
87
- data?: Record<string, string>
95
+ data?: Record<string, unknown>
88
96
  priority?: 'high' | 'normal'
89
97
  ttl?: number
90
98
  collapseKey?: string
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
1
  // @bun
2
- var xe=Object.defineProperty;var ve=(e)=>e;function Pe(e,t){this[e]=ve.bind(null,t)}var D=(e,t)=>{for(var r in t)xe(e,r,{get:t[r],enumerable:!0,configurable:!0,set:Pe.bind(t,r)})};var te={};D(te,{Send:()=>T,configure:()=>K,default:()=>we,getReceipts:()=>Q,isExpoPushToken:()=>R,resetConfiguration:()=>Ce,resolveAccessToken:()=>Y,send:()=>T,sendBatch:()=>ee});var pe={};D(pe,{Send:()=>C,buildServiceAccountAssertion:()=>se,clearAccessTokens:()=>Ae,configure:()=>j,default:()=>$e,normalizePrivateKey:()=>U,resetConfiguration:()=>Ee,resolveConfig:()=>V,send:()=>C,sendMulticast:()=>F,sendToTopic:()=>oe,subscribeToTopic:()=>ce,unsubscribeFromTopic:()=>ue});var ge={};D(ge,{buildPayload:()=>fe,buildProviderToken:()=>de,configure:()=>H,resetConfiguration:()=>Ne,send:()=>L});import{log as S}from"@stacksjs/cli";async function P(){let{awaitConfig:e}=await import("@stacksjs/config");return(await e()).services??{}}function c(e){let t=e?.trim();return t?t:void 0}var Se="https://exp.host/--/api/v2/push/send",A={};function K(e){A={...A,...e}}function Ce(){A={}}async function Y(){if(c(A.accessToken))return c(A.accessToken);try{return c((await P()).expo?.accessToken)}catch(e){S.debug(`Expo: could not read config/services.ts: ${e instanceof Error?e.message:String(e)}`);return}}async function Z(e={}){let t={Accept:"application/json","Content-Type":"application/json",...e},r=await Y();if(r)t.Authorization=`Bearer ${r}`;return t}function R(e){return/^Expo(?:nent)?PushToken\[.+\]$/.test(e)||/^[a-zA-Z0-9-_]+$/.test(e)}async function T(e){let t=Array.isArray(e.to)?e.to:[e.to],r=t.filter((n)=>!R(n));if(r.length>0)S.warn(`Invalid Expo push tokens found: ${r.join(", ")}`);let s=t.filter(R);if(s.length===0)return{success:!1,provider:"expo",message:"No valid Expo push tokens provided"};try{let n=s.map((u)=>({to:u,title:e.title,body:e.body,data:e.data,sound:e.sound??"default",badge:e.badge,channelId:e.channelId,priority:e.priority??"high",ttl:e.ttl})),i=4096;for(let u of n){let g=new TextEncoder().encode(JSON.stringify(u)).length;if(g>4096)return{success:!1,provider:"expo",message:`Expo message payload (${g} bytes) exceeds 4 KiB per-message limit`}}let o=await fetch(Se,{method:"POST",headers:await Z({"Accept-Encoding":"gzip, deflate"}),body:JSON.stringify(n)});if(!o.ok){let u=await o.text();return S.error(`Expo push failed: ${u}`),{success:!1,provider:"expo",message:`HTTP ${o.status}: ${u}`}}let p=(await o.json()).data,l=p.filter((u)=>u.status==="error");if(l.length>0)S.warn(`Some push notifications failed: ${JSON.stringify(l)}`);let f=p.filter((u)=>u.status==="ok").length;return S.info(`Expo push sent: ${f}/${p.length} successful`),{success:l.length===0,provider:"expo",message:`Sent ${f}/${p.length} notifications`,messageId:p.find((u)=>u.id)?.id,data:{tickets:p}}}catch(n){let i=n instanceof Error?n:Error(String(n));return S.error(`Expo push error: ${i.message}`),{success:!1,provider:"expo",message:i.message}}}async function Q(e){let t=await fetch("https://exp.host/--/api/v2/push/getReceipts",{method:"POST",headers:await Z(),body:JSON.stringify({ids:e})});if(!t.ok)throw Error(`Failed to get receipts: ${t.status}`);return(await t.json()).data}async function ee(e,t=100){let r=[];for(let s=0;s<e.length;s+=t){let n=e.slice(s,s+t),i=await Promise.all(n.map((o)=>T(o)));r.push(...i)}return r}var we={send:T,sendBatch:ee,getReceipts:Q,isExpoPushToken:R,configure:K};import{log as I}from"@stacksjs/cli";var re="https://fcm.googleapis.com/v1/projects",h={};function j(e){h={...h,...e}}function Ee(){h={},N.clear()}function U(e){return e.includes("\\n")?e.replace(/\\n/g,`
3
- `):e}async function V(){if(h.projectId&&h.serviceAccount)return h;let e={};try{e=await P()}catch(n){I.debug(`FCM: could not read config/services.ts: ${n instanceof Error?n.message:String(n)}`)}let t=e.fcm??{},r=c(t.clientEmail),s=c(t.privateKey);return{projectId:h.projectId??c(t.projectId),serviceAccount:h.serviceAccount??(r&&s?{clientEmail:r,privateKey:U(s)}:void 0)}}function W(e){return btoa(e).replace(/\+/g,"-").replace(/\//g,"_").replace(/=+$/,"")}async function se(e,t,r){let s={alg:"RS256",typ:"JWT"},n={iss:e,scope:"https://www.googleapis.com/auth/firebase.messaging",aud:"https://oauth2.googleapis.com/token",iat:r,exp:r+3600},i=new TextEncoder,o=W(JSON.stringify(s)),a=W(JSON.stringify(n)),p=`${o}.${a}`,l=t.replace(/-----BEGIN PRIVATE KEY-----/,"").replace(/-----END PRIVATE KEY-----/,"").replace(/\s/g,""),f=Uint8Array.from(atob(l),(E)=>E.charCodeAt(0)),u=await crypto.subtle.importKey("pkcs8",f,{name:"RSASSA-PKCS1-v1_5",hash:"SHA-256"},!1,["sign"]),g=await crypto.subtle.sign("RSASSA-PKCS1-v1_5",u,i.encode(p)),y=W(String.fromCharCode(...new Uint8Array(g)));return`${o}.${a}.${y}`}var N=new Map,ke=60000;function Ae(){N.clear()}async function ne(e){if(!e.serviceAccount)throw Error("Service account not configured for FCM v1 API");let{clientEmail:t,privateKey:r}=e.serviceAccount,s=N.get(t);if(s&&s.expiresAt-ke>Date.now())return s.token;let n=await se(t,r,Math.floor(Date.now()/1000)),i=await fetch("https://oauth2.googleapis.com/token",{method:"POST",headers:{"Content-Type":"application/x-www-form-urlencoded"},body:`grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer&assertion=${n}`});if(!i.ok)throw Error(`Failed to get access token: ${i.status}`);let o=await i.json(),a=Number(o.expires_in);return N.set(t,{token:o.access_token,expiresAt:Date.now()+(Number.isFinite(a)&&a>0?a:3600)*1000}),o.access_token}async function Te(e){let t=c(e?.projectId),r=c(e?.serviceAccount?.clientEmail),s=c(e?.serviceAccount?.privateKey);if(t&&r&&s)return{projectId:t,serviceAccount:{clientEmail:r,privateKey:U(s)}};return V()}async function C(e,t){let r=await Te(t);if(!r.serviceAccount||!r.projectId)return{success:!1,provider:"fcm",message:"FCM service account and projectId not configured - set FCM_PROJECT_ID, FCM_CLIENT_EMAIL and FCM_PRIVATE_KEY, or call configureFCM()"};try{let s=await ne(r),n=`${re}/${r.projectId}/messages:send`,i={message:{notification:e.notification,data:e.data,android:{priority:e.priority??"high",ttl:e.ttl?`${e.ttl}s`:void 0,collapseKey:e.collapseKey},apns:{headers:{"apns-priority":e.priority==="high"?"10":"5"}}}};if(e.to)i.message.token=e.to;else if(e.topic)i.message.topic=e.topic;else if(e.condition)i.message.condition=e.condition;let o=await fetch(n,{method:"POST",headers:{Authorization:`Bearer ${s}`,"Content-Type":"application/json"},body:JSON.stringify(i)});if(!o.ok){let p=await o.text();return I.error(`FCM v1 push failed: ${p}`),{success:!1,provider:"fcm",message:`HTTP ${o.status}: ${p}`}}let a=await o.json();return I.info(`FCM v1 push sent: ${a.name}`),{success:!0,provider:"fcm",message:"Notification sent successfully",messageId:a.name}}catch(s){let n=s instanceof Error?s:Error(String(s));return I.error(`FCM v1 push error: ${n.message}`),{success:!1,provider:"fcm",message:n.message}}}async function F(e,t,r){return Promise.all(e.map((s)=>C({...t,to:s},r)))}async function oe(e,t){return C({...t,topic:e})}async function ie(e,t,r,s){let n=await ne(e),i=`${re}/${e.projectId}/registrations/${encodeURIComponent(t)}/topicSubscriptions`,o=s?`${i}?topic_name=${encodeURIComponent(r)}`:`${i}/${encodeURIComponent(r)}`,a=await fetch(o,{method:s?"POST":"DELETE",headers:{Authorization:`Bearer ${n}`,"Content-Type":"application/json"},body:"{}"});if(a.ok||s&&a.status===409)return!0;return I.error(`FCM topic ${s?"subscribe":"unsubscribe"} failed for topic ${r}: HTTP ${a.status}`),!1}async function ae(e){let t=await V();if(!t.serviceAccount||!t.projectId)throw Error(`FCM service account and projectId required for ${e}`);return t}async function ce(e,t){let r=await ae("topic subscription");return(await Promise.all(e.map((n)=>ie(r,n,t,!0)))).every(Boolean)}async function ue(e,t){let r=await ae("topic unsubscription");return(await Promise.all(e.map((n)=>ie(r,n,t,!1)))).every(Boolean)}var $e={send:C,sendMulticast:F,sendToTopic:oe,subscribeToTopic:ce,unsubscribeFromTopic:ue,configure:j};import{createPrivateKey as Ie,sign as Me}from"crypto";import Re,{constants as Ke}from"http2";var x={},O=new Map;function H(e){x={...e},O.clear()}function Ne(){x={},O.clear()}function de(e,t,r,s=Math.floor(Date.now()/1000)){let n=Buffer.from(JSON.stringify({alg:"ES256",kid:t})).toString("base64url"),i=Buffer.from(JSON.stringify({iss:e,iat:s})).toString("base64url"),o=`${n}.${i}`,a=Ie(r.replace(/\\n/g,`
4
- `));if(a.asymmetricKeyType!=="ec"||a.asymmetricKeyDetails?.namedCurve!=="prime256v1")throw Error("APNs requires an ES256 P-256 signing key");return`${o}.${Me("sha256",Buffer.from(o),{key:a,dsaEncoding:"ieee-p1363"}).toString("base64url")}`}function fe(e){let t=JSON.stringify({...e.data,aps:{alert:{title:e.title??"",body:e.body},...e.sound!==null?{sound:e.sound??"default"}:{},...e.badge!==void 0?{badge:e.badge}:{}}});if(Buffer.byteLength(t)>4096)throw Error("APNs notification exceeds 4096 bytes");return t}async function L(e,t,r={}){let s=Array.isArray(e)?e:[e];if(!s.length||s.some((f)=>!/^[a-f\d]{64}$/i.test(f)))return{success:!1,provider:"apns",message:"A valid Apple device token is required"};let n=(await P()).apns,i=c(r.teamId)??c(x.teamId)??c(n?.teamId),o=c(r.keyId)??c(x.keyId)??c(n?.keyId),a=c(r.privateKey)??c(x.privateKey)??c(n?.privateKey),p=c(r.topic)??c(x.topic)??c(n?.topic),l=r.sandbox??x.sandbox??n?.sandbox;if(!i||!o||!a||!p)return{success:!1,provider:"apns",message:"APNs requires teamId, keyId, privateKey and topic"};try{let f=fe(t),u=Math.floor(Date.now()/1000),g=`${i}:${o}:${a}`,y=O.get(g);if(!y||u-y.issuedAt>=3000||u<y.issuedAt)y={token:de(i,o,a,u),issuedAt:u},O.set(g,y);let E=y.token,v=Re.connect(l?"https://api.sandbox.push.apple.com":"https://api.push.apple.com");v.on("error",()=>{});try{let B=await Promise.all(s.map((J)=>new Promise((M)=>{let m=v.request({":method":"POST",":path":`/3/device/${J}`,authorization:`bearer ${E}`,"apns-topic":p,"apns-push-type":"alert","apns-priority":t.priority==="normal"?"5":"10","content-type":"application/json",...t.collapseId?{"apns-collapse-id":t.collapseId}:{}}),k=0,G="",X;m.setEncoding("utf8");let be=setTimeout(()=>{m.close(Ke.NGHTTP2_CANCEL),M({success:!1,status:0,reason:"Timeout"})},15000);m.on("close",()=>{clearTimeout(be),M({success:!1,status:k,reason:"TransportClosed"})}),m.on("response",(b)=>{k=Number(b[":status"]),X=String(b["apns-id"]??"")||void 0}),m.on("data",(b)=>{G+=b}),m.on("error",()=>M({success:!1,status:k,reason:"TransportError"})),m.on("end",()=>{let b;try{b=JSON.parse(G).reason}catch{}M({success:k===200,status:k,reason:b,messageId:X})}),m.end(f)}))),q=B.filter((J)=>J.success).length;return{success:q===s.length,provider:"apns",message:`Apple accepted ${q}/${s.length} notifications`,messageId:B[0]?.messageId,data:{results:B}}}finally{v.destroy()}}catch(f){return{success:!1,provider:"apns",message:f instanceof Error?f.message:"APNs delivery failed"}}}import{Buffer as d}from"buffer";import{createCipheriv as je,createECDH as Fe,createHmac as le,createPrivateKey as Oe,createSign as _e,generateKeyPairSync as Be,randomBytes as Je}from"crypto";var ye="prime256v1";function w(e){return e.toString("base64url")}function _(e){return d.from(e,"base64url")}function De(){let{publicKey:e,privateKey:t}=Be("ec",{namedCurve:ye}),r=e.export({type:"spki",format:"der"}),s=t.export({format:"jwk"});return{publicKey:w(r.subarray(r.length-65)),privateKey:String(s.d)}}function We(e){let t=new URL(e);return`${t.protocol}//${t.host}`}function me(e){let r=Math.floor(e.nowSeconds??Date.now()/1000)+Math.min(e.expiresInSeconds??43200,86400),s=w(d.from(JSON.stringify({typ:"JWT",alg:"ES256"}))),n=w(d.from(JSON.stringify({aud:We(e.endpoint),exp:r,sub:e.subject}))),i=`${s}.${n}`,o=Oe({key:{kty:"EC",crv:"P-256",d:e.vapid.privateKey,x:w(_(e.vapid.publicKey).subarray(1,33)),y:w(_(e.vapid.publicKey).subarray(33,65))},format:"jwk"}),a=_e("SHA256");a.update(i);let p=a.sign({key:o,dsaEncoding:"ieee-p1363"});return{Authorization:`vapid t=${i}.${w(p)}, k=${e.vapid.publicKey}`,"Crypto-Key":`p256ecdsa=${e.vapid.publicKey}`}}function z(e,t,r,s){let n=le("sha256",e).update(t).digest();return le("sha256",n).update(d.concat([r,d.from([1])])).digest().subarray(0,s)}function he(e,t,r=Je(16)){let s=_(e.keys.p256dh),n=_(e.keys.auth),i=Fe(ye);i.generateKeys();let o=i.getPublicKey(),a=i.computeSecret(s),p=d.concat([d.from("WebPush: info\x00"),s,o]),l=z(n,a,p,32),f=z(r,l,d.from("Content-Encoding: aes128gcm\x00"),16),u=z(r,l,d.from("Content-Encoding: nonce\x00"),12),g=je("aes-128-gcm",f,u),y=d.concat([d.from(t,"utf8"),d.from([2])]),E=d.concat([g.update(y),g.final(),g.getAuthTag()]),v=d.alloc(4);return v.writeUInt32BE(4096,0),d.concat([r,v,d.from([o.length]),o,E])}async function Ue(e){let{subscription:t,vapid:r,subject:s}=e;if(!t?.endpoint)return{success:!1,status:0,expired:!1,error:"no endpoint"};let n={...me({endpoint:t.endpoint,vapid:r,subject:s}),TTL:String(e.ttl??86400),Urgency:e.urgency??"normal"};if(e.topic)n.Topic=e.topic;let i;if(e.payload){if(!t.keys?.p256dh||!t.keys?.auth)return{success:!1,status:0,expired:!0,error:"subscription has no keys"};i=he(t,e.payload),n["Content-Encoding"]="aes128gcm",n["Content-Type"]="application/octet-stream",n["Content-Length"]=String(i.length)}try{let o=await fetch(t.endpoint,{method:"POST",headers:n,body:i?new Uint8Array(i):void 0}),a=o.status===404||o.status===410;if(o.status>=200&&o.status<300)return{success:!0,status:o.status,expired:!1};return{success:!1,status:o.status,expired:a,error:(await o.text().catch(()=>"")).slice(0,500)||`push service answered ${o.status}`}}catch(o){return{success:!1,status:0,expired:!1,error:o instanceof Error?o.message:String(o)}}}async function rt(e,t,r={}){let s=r.driver??"expo";if(s==="apns")return L(e,t,r.apns);if(s==="expo")return T({to:e,title:t.title,body:t.body,data:t.data,badge:t.badge,sound:t.sound,priority:t.priority});if(s==="fcm"){let n=Array.isArray(e)?e:[e];if(n.length===1)return C({to:n[0],notification:{title:t.title??"",body:t.body},data:t.data,priority:t.priority==="default"?"normal":t.priority},r.fcm);let i=await F(n,{notification:{title:t.title??"",body:t.body},data:t.data,priority:t.priority==="default"?"normal":t.priority},r.fcm);return{success:i.every((a)=>a.success),provider:"fcm",message:`Sent to ${i.filter((a)=>a.success).length}/${i.length} devices`}}return{success:!1,provider:s,message:`Unknown push driver: ${s}`}}function st(e){j(e)}function nt(e){K(e)}function ot(e){H(e)}export{ge as apns,me as buildVapidHeaders,ot as configureAPNS,nt as configureExpo,st as configureFCM,he as encryptPayload,te as expo,pe as fcm,De as generateVapidKeys,rt as send,Ue as sendWebPush};
2
+ var Se=Object.defineProperty;var Ee=(e)=>e;function Ce(e,t){this[e]=Ee.bind(null,t)}var U=(e,t)=>{for(var r in t)Se(e,r,{get:t[r],enumerable:!0,configurable:!0,set:Ce.bind(t,r)})};var se={};U(se,{Send:()=>R,configure:()=>j,default:()=>Ae,getReceipts:()=>te,isExpoPushToken:()=>M,resetConfiguration:()=>ke,resolveAccessToken:()=>Z,send:()=>R,sendBatch:()=>re});var ge={};U(ge,{Send:()=>w,buildServiceAccountAssertion:()=>ie,clearAccessTokens:()=>Me,configure:()=>F,default:()=>Re,fcmData:()=>oe,normalizePrivateKey:()=>V,resetConfiguration:()=>Te,resolveConfig:()=>H,send:()=>w,sendMulticast:()=>O,sendToTopic:()=>ce,subscribeToTopic:()=>de,unsubscribeFromTopic:()=>fe});var me={};U(me,{buildPayload:()=>ye,buildProviderToken:()=>le,configure:()=>L,resetConfiguration:()=>Fe,send:()=>z});import{log as C}from"@stacksjs/cli";async function E(){let{awaitConfig:e}=await import("@stacksjs/config");return(await e()).services??{}}function u(e){let t=e?.trim();return t?t:void 0}var we="https://exp.host/--/api/v2/push/send",Y=100,Q=1000,I={};function j(e){I={...I,...e}}function ke(){I={}}async function Z(){if(u(I.accessToken))return u(I.accessToken);try{return u((await E()).expo?.accessToken)}catch(e){C.debug(`Expo: could not read config/services.ts: ${e instanceof Error?e.message:String(e)}`);return}}async function ee(e={}){let t={Accept:"application/json","Content-Type":"application/json",...e},r=await Z();if(r)t.Authorization=`Bearer ${r}`;return t}function M(e){return/^Expo(?:nent)?PushToken\[.+\]$/.test(e)||/^[a-zA-Z0-9-_]+$/.test(e)}async function R(e){let t=Array.isArray(e.to)?e.to:[e.to],r=t.filter((n)=>!M(n));if(r.length>0)C.warn(`Invalid Expo push tokens found: ${r.join(", ")}`);let s=t.filter(M);if(s.length===0)return{success:!1,provider:"expo",message:"No valid Expo push tokens provided"};try{let n=s.map((c)=>({to:c,title:e.title,body:e.body,data:e.data,sound:e.sound??"default",badge:e.badge,channelId:e.channelId,priority:e.priority??"high",ttl:e.ttl})),i=4096;for(let c of n){let m=new TextEncoder().encode(JSON.stringify(c)).length;if(m>4096)return{success:!1,provider:"expo",message:`Expo message payload (${m} bytes) exceeds 4 KiB per-message limit`}}let o=await ee({"Accept-Encoding":"gzip, deflate"}),a=[],f;for(let c=0;c<n.length;c+=Y){let m=n.slice(c,c+Y),h=await fetch(we,{method:"POST",headers:o,body:JSON.stringify(m)});if(!h.ok){let S=`HTTP ${h.status}: ${await h.text()}`;C.error(`Expo push failed: ${S}`),f??=S,a.push(...m.map(()=>({status:"error",message:S})));continue}let A=await h.json();a.push(...A.data)}let y=0,p=t.map((c)=>M(c)?a[y++]??{status:"error",message:"Expo returned no ticket for this token"}:{status:"error",message:`"${c}" is not an Expo push token`}),g=p.filter((c)=>c.status==="error");if(g.length>0)C.warn(`Some push notifications failed: ${JSON.stringify(g)}`);let l=p.filter((c)=>c.status==="ok").length;return C.info(`Expo push sent: ${l}/${p.length} successful`),{success:g.length===0,provider:"expo",message:l===0&&f?f:`Sent ${l}/${p.length} notifications`,messageId:p.find((c)=>c.id)?.id,data:{tickets:p}}}catch(n){let i=n instanceof Error?n:Error(String(n));return C.error(`Expo push error: ${i.message}`),{success:!1,provider:"expo",message:i.message}}}async function te(e){let t=await ee(),r={};for(let s=0;s<e.length;s+=Q){let n=await fetch("https://exp.host/--/api/v2/push/getReceipts",{method:"POST",headers:t,body:JSON.stringify({ids:e.slice(s,s+Q)})});if(!n.ok)throw Error(`Failed to get receipts: ${n.status}`);let i=await n.json();Object.assign(r,i.data)}return r}async function re(e,t=100){let r=[];for(let s=0;s<e.length;s+=t){let n=e.slice(s,s+t),i=await Promise.all(n.map((o)=>R(o)));r.push(...i)}return r}var Ae={send:R,sendBatch:re,getReceipts:te,isExpoPushToken:M,configure:j};import{log as K}from"@stacksjs/cli";var ne="https://fcm.googleapis.com/v1/projects",x={};function oe(e){if(!e)return;let t=Object.entries(e).filter(([,r])=>r!==void 0);return Object.fromEntries(t.map(([r,s])=>[r,typeof s==="string"?s:JSON.stringify(s)]))}function F(e){x={...x,...e}}function Te(){x={},_.clear()}function V(e){return e.includes("\\n")?e.replace(/\\n/g,`
3
+ `):e}async function H(){if(x.projectId&&x.serviceAccount)return x;let e={};try{e=await E()}catch(n){K.debug(`FCM: could not read config/services.ts: ${n instanceof Error?n.message:String(n)}`)}let t=e.fcm??{},r=u(t.clientEmail),s=u(t.privateKey);return{projectId:x.projectId??u(t.projectId),serviceAccount:x.serviceAccount??(r&&s?{clientEmail:r,privateKey:V(s)}:void 0)}}function W(e){return btoa(e).replace(/\+/g,"-").replace(/\//g,"_").replace(/=+$/,"")}async function ie(e,t,r){let s={alg:"RS256",typ:"JWT"},n={iss:e,scope:"https://www.googleapis.com/auth/firebase.messaging",aud:"https://oauth2.googleapis.com/token",iat:r,exp:r+3600},i=new TextEncoder,o=W(JSON.stringify(s)),a=W(JSON.stringify(n)),f=`${o}.${a}`,y=t.replace(/-----BEGIN PRIVATE KEY-----/,"").replace(/-----END PRIVATE KEY-----/,"").replace(/\s/g,""),p=Uint8Array.from(atob(y),(m)=>m.charCodeAt(0)),g=await crypto.subtle.importKey("pkcs8",p,{name:"RSASSA-PKCS1-v1_5",hash:"SHA-256"},!1,["sign"]),l=await crypto.subtle.sign("RSASSA-PKCS1-v1_5",g,i.encode(f)),c=W(String.fromCharCode(...new Uint8Array(l)));return`${o}.${a}.${c}`}var _=new Map,$e=60000;function Me(){_.clear()}async function ae(e){if(!e.serviceAccount)throw Error("Service account not configured for FCM v1 API");let{clientEmail:t,privateKey:r}=e.serviceAccount,s=_.get(t);if(s&&s.expiresAt-$e>Date.now())return s.token;let n=await ie(t,r,Math.floor(Date.now()/1000)),i=await fetch("https://oauth2.googleapis.com/token",{method:"POST",headers:{"Content-Type":"application/x-www-form-urlencoded"},body:`grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer&assertion=${n}`});if(!i.ok)throw Error(`Failed to get access token: ${i.status}`);let o=await i.json(),a=Number(o.expires_in);return _.set(t,{token:o.access_token,expiresAt:Date.now()+(Number.isFinite(a)&&a>0?a:3600)*1000}),o.access_token}async function Ie(e){let t=u(e?.projectId),r=u(e?.serviceAccount?.clientEmail),s=u(e?.serviceAccount?.privateKey);if(t&&r&&s)return{projectId:t,serviceAccount:{clientEmail:r,privateKey:V(s)}};return H()}async function w(e,t){let r=await Ie(t);if(!r.serviceAccount||!r.projectId)return{success:!1,provider:"fcm",message:"FCM service account and projectId not configured - set FCM_PROJECT_ID, FCM_CLIENT_EMAIL and FCM_PRIVATE_KEY, or call configureFCM()"};try{let s=await ae(r),n=`${ne}/${r.projectId}/messages:send`,i=e.priority??"high",o={message:{notification:e.notification,data:oe(e.data),android:{priority:i,ttl:e.ttl?`${e.ttl}s`:void 0,collapseKey:e.collapseKey},apns:{headers:{"apns-priority":i==="high"?"10":"5"}}}};if(e.to)o.message.token=e.to;else if(e.topic)o.message.topic=e.topic;else if(e.condition)o.message.condition=e.condition;let a=await fetch(n,{method:"POST",headers:{Authorization:`Bearer ${s}`,"Content-Type":"application/json"},body:JSON.stringify(o)});if(!a.ok){let y=await a.text();return K.error(`FCM v1 push failed: ${y}`),{success:!1,provider:"fcm",message:`HTTP ${a.status}: ${y}`}}let f=await a.json();return K.info(`FCM v1 push sent: ${f.name}`),{success:!0,provider:"fcm",message:"Notification sent successfully",messageId:f.name}}catch(s){let n=s instanceof Error?s:Error(String(s));return K.error(`FCM v1 push error: ${n.message}`),{success:!1,provider:"fcm",message:n.message}}}async function O(e,t,r){return Promise.all(e.map((s)=>w({...t,to:s},r)))}async function ce(e,t){return w({...t,topic:e})}async function ue(e,t,r,s){let n=await ae(e),i=`${ne}/${e.projectId}/registrations/${encodeURIComponent(t)}/topicSubscriptions`,o=s?`${i}?topic_name=${encodeURIComponent(r)}`:`${i}/${encodeURIComponent(r)}`,a=await fetch(o,{method:s?"POST":"DELETE",headers:{Authorization:`Bearer ${n}`,"Content-Type":"application/json"},body:"{}"});if(a.ok||s&&a.status===409)return!0;return K.error(`FCM topic ${s?"subscribe":"unsubscribe"} failed for topic ${r}: HTTP ${a.status}`),!1}async function pe(e){let t=await H();if(!t.serviceAccount||!t.projectId)throw Error(`FCM service account and projectId required for ${e}`);return t}async function de(e,t){let r=await pe("topic subscription");return(await Promise.all(e.map((n)=>ue(r,n,t,!0)))).every(Boolean)}async function fe(e,t){let r=await pe("topic unsubscription");return(await Promise.all(e.map((n)=>ue(r,n,t,!1)))).every(Boolean)}var Re={send:w,sendMulticast:O,sendToTopic:ce,subscribeToTopic:de,unsubscribeFromTopic:fe,configure:F};import{createPrivateKey as Ke,sign as Ne}from"crypto";import je,{constants as _e}from"http2";var v={},B=new Map;function L(e){v={...e},B.clear()}function Fe(){v={},B.clear()}function le(e,t,r,s=Math.floor(Date.now()/1000)){let n=Buffer.from(JSON.stringify({alg:"ES256",kid:t})).toString("base64url"),i=Buffer.from(JSON.stringify({iss:e,iat:s})).toString("base64url"),o=`${n}.${i}`,a=Ke(r.replace(/\\n/g,`
4
+ `));if(a.asymmetricKeyType!=="ec"||a.asymmetricKeyDetails?.namedCurve!=="prime256v1")throw Error("APNs requires an ES256 P-256 signing key");return`${o}.${Ne("sha256",Buffer.from(o),{key:a,dsaEncoding:"ieee-p1363"}).toString("base64url")}`}function ye(e){let t=JSON.stringify({...e.data,aps:{alert:{title:e.title??"",body:e.body},...e.sound!==null?{sound:e.sound??"default"}:{},...e.badge!==void 0?{badge:e.badge}:{}}});if(Buffer.byteLength(t)>4096)throw Error("APNs notification exceeds 4096 bytes");return t}async function z(e,t,r={}){let s=Array.isArray(e)?e:[e];if(!s.length||s.some((p)=>!/^[a-f\d]{64}$/i.test(p)))return{success:!1,provider:"apns",message:"A valid Apple device token is required"};let n=(await E()).apns,i=u(r.teamId)??u(v.teamId)??u(n?.teamId),o=u(r.keyId)??u(v.keyId)??u(n?.keyId),a=u(r.privateKey)??u(v.privateKey)??u(n?.privateKey),f=u(r.topic)??u(v.topic)??u(n?.topic),y=r.sandbox??v.sandbox??n?.sandbox;if(!i||!o||!a||!f)return{success:!1,provider:"apns",message:"APNs requires teamId, keyId, privateKey and topic"};try{let p=ye(t),g=Math.floor(Date.now()/1000),l=`${i}:${o}:${a}`,c=B.get(l);if(!c||g-c.issuedAt>=3000||g<c.issuedAt)c={token:le(i,o,a,g),issuedAt:g},B.set(l,c);let m=c.token,h=je.connect(y?"https://api.sandbox.push.apple.com":"https://api.push.apple.com");h.on("error",()=>{});try{let A=await Promise.all(s.map((D)=>new Promise((N)=>{let b=h.request({":method":"POST",":path":`/3/device/${D}`,authorization:`bearer ${m}`,"apns-topic":f,"apns-push-type":"alert","apns-priority":t.priority==="normal"?"5":"10","content-type":"application/json",...t.collapseId?{"apns-collapse-id":t.collapseId}:{}}),T=0,G="",X;b.setEncoding("utf8");let ve=setTimeout(()=>{b.close(_e.NGHTTP2_CANCEL),N({success:!1,status:0,reason:"Timeout"})},15000);b.on("close",()=>{clearTimeout(ve),N({success:!1,status:T,reason:"TransportClosed"})}),b.on("response",(P)=>{T=Number(P[":status"]),X=String(P["apns-id"]??"")||void 0}),b.on("data",(P)=>{G+=P}),b.on("error",()=>N({success:!1,status:T,reason:"TransportError"})),b.on("end",()=>{let P;try{P=JSON.parse(G).reason}catch{}N({success:T===200,status:T,reason:P,messageId:X})}),b.end(p)}))),S=A.filter((D)=>D.success).length;return{success:S===s.length,provider:"apns",message:`Apple accepted ${S}/${s.length} notifications`,messageId:A[0]?.messageId,data:{results:A}}}finally{h.destroy()}}catch(p){return{success:!1,provider:"apns",message:p instanceof Error?p.message:"APNs delivery failed"}}}import{Buffer as d}from"buffer";import{createCipheriv as Oe,createECDH as Be,createHmac as he,createPrivateKey as Je,createSign as De,generateKeyPairSync as Ue,randomBytes as We}from"crypto";var be="prime256v1";function k(e){return e.toString("base64url")}function J(e){return d.from(e,"base64url")}function Ve(){let{publicKey:e,privateKey:t}=Ue("ec",{namedCurve:be}),r=e.export({type:"spki",format:"der"}),s=t.export({format:"jwk"});return{publicKey:k(r.subarray(r.length-65)),privateKey:String(s.d)}}function He(e){let t=new URL(e);return`${t.protocol}//${t.host}`}function xe(e){let r=Math.floor(e.nowSeconds??Date.now()/1000)+Math.min(e.expiresInSeconds??43200,86400),s=k(d.from(JSON.stringify({typ:"JWT",alg:"ES256"}))),n=k(d.from(JSON.stringify({aud:He(e.endpoint),exp:r,sub:e.subject}))),i=`${s}.${n}`,o=Je({key:{kty:"EC",crv:"P-256",d:e.vapid.privateKey,x:k(J(e.vapid.publicKey).subarray(1,33)),y:k(J(e.vapid.publicKey).subarray(33,65))},format:"jwk"}),a=De("SHA256");a.update(i);let f=a.sign({key:o,dsaEncoding:"ieee-p1363"});return{Authorization:`vapid t=${i}.${k(f)}, k=${e.vapid.publicKey}`,"Crypto-Key":`p256ecdsa=${e.vapid.publicKey}`}}function q(e,t,r,s){let n=he("sha256",e).update(t).digest();return he("sha256",n).update(d.concat([r,d.from([1])])).digest().subarray(0,s)}function Pe(e,t,r=We(16)){let s=J(e.keys.p256dh),n=J(e.keys.auth),i=Be(be);i.generateKeys();let o=i.getPublicKey(),a=i.computeSecret(s),f=d.concat([d.from("WebPush: info\x00"),s,o]),y=q(n,a,f,32),p=q(r,y,d.from("Content-Encoding: aes128gcm\x00"),16),g=q(r,y,d.from("Content-Encoding: nonce\x00"),12),l=Oe("aes-128-gcm",p,g),c=d.concat([d.from(t,"utf8"),d.from([2])]),m=d.concat([l.update(c),l.final(),l.getAuthTag()]),h=d.alloc(4);return h.writeUInt32BE(4096,0),d.concat([r,h,d.from([o.length]),o,m])}async function Le(e){let{subscription:t,vapid:r,subject:s}=e;if(!t?.endpoint)return{success:!1,status:0,expired:!1,error:"no endpoint"};let n={...xe({endpoint:t.endpoint,vapid:r,subject:s}),TTL:String(e.ttl??86400),Urgency:e.urgency??"normal"};if(e.topic)n.Topic=e.topic;let i;if(e.payload){if(!t.keys?.p256dh||!t.keys?.auth)return{success:!1,status:0,expired:!0,error:"subscription has no keys"};i=Pe(t,e.payload),n["Content-Encoding"]="aes128gcm",n["Content-Type"]="application/octet-stream",n["Content-Length"]=String(i.length)}try{let o=await fetch(t.endpoint,{method:"POST",headers:n,body:i?new Uint8Array(i):void 0}),a=o.status===404||o.status===410;if(o.status>=200&&o.status<300)return{success:!0,status:o.status,expired:!1};return{success:!1,status:o.status,expired:a,error:(await o.text().catch(()=>"")).slice(0,500)||`push service answered ${o.status}`}}catch(o){return{success:!1,status:0,expired:!1,error:o instanceof Error?o.message:String(o)}}}async function ot(e,t,r={}){let s=r.driver??"expo";if(s==="apns")return z(e,t,r.apns);if(s==="expo")return R({to:e,title:t.title,body:t.body,data:t.data,badge:t.badge,sound:t.sound,priority:t.priority});if(s==="fcm"){let n=Array.isArray(e)?e:[e];if(n.length===1)return w({to:n[0],notification:{title:t.title??"",body:t.body},data:t.data,priority:t.priority==="default"?"normal":t.priority},r.fcm);let i=await O(n,{notification:{title:t.title??"",body:t.body},data:t.data,priority:t.priority==="default"?"normal":t.priority},r.fcm);return{success:i.every((a)=>a.success),provider:"fcm",message:`Sent to ${i.filter((a)=>a.success).length}/${i.length} devices`,data:{results:i}}}return{success:!1,provider:s,message:`Unknown push driver: ${s}`}}function it(e){F(e)}function at(e){j(e)}function ct(e){L(e)}export{me as apns,xe as buildVapidHeaders,ct as configureAPNS,at as configureExpo,it as configureFCM,Pe as encryptPayload,se as expo,ge as fcm,Ve as generateVapidKeys,ot as send,Le as sendWebPush};
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@stacksjs/push",
3
3
  "type": "module",
4
4
  "sideEffects": false,
5
- "version": "0.75.422",
5
+ "version": "0.75.423",
6
6
  "description": "The Stacks Push integration",
7
7
  "author": "Chris Breuer",
8
8
  "contributors": [
@@ -56,11 +56,11 @@
56
56
  "prepublishOnly": "bun run build"
57
57
  },
58
58
  "dependencies": {
59
- "@stacksjs/cli": "0.75.422",
60
- "@stacksjs/config": "0.75.422"
59
+ "@stacksjs/cli": "0.75.423",
60
+ "@stacksjs/config": "0.75.423"
61
61
  },
62
62
  "devDependencies": {
63
- "@stacksjs/error-handling": "0.75.422",
63
+ "@stacksjs/error-handling": "0.75.423",
64
64
  "better-dx": "^0.2.24"
65
65
  }
66
66
  }