@stacksjs/push 0.75.72 → 0.75.73
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/dist/app-config.d.ts +21 -0
- package/dist/drivers/expo.d.ts +22 -1
- package/dist/drivers/fcm.d.ts +28 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +2 -1
- package/package.json +4 -4
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { ServicesConfig } from '@stacksjs/types';
|
|
2
|
+
/**
|
|
3
|
+
* The application's `config/services.ts`, read when a push is sent.
|
|
4
|
+
*
|
|
5
|
+
* Read lazily and after `awaitConfig()`, never at module load: until the
|
|
6
|
+
* app's own config files have loaded, `config.services` holds the framework
|
|
7
|
+
* defaults, so a value captured on import would be the empty string the
|
|
8
|
+
* defaults carry rather than the credential the app set (stacksjs/stacks#2857).
|
|
9
|
+
*
|
|
10
|
+
* Imported dynamically so a script that only builds payloads, and the tests
|
|
11
|
+
* that exercise them, do not pull the whole config graph in.
|
|
12
|
+
*/
|
|
13
|
+
export declare function appServices(): Promise<ServicesConfig>;
|
|
14
|
+
/**
|
|
15
|
+
* A credential as it arrives from config: blank means unset.
|
|
16
|
+
*
|
|
17
|
+
* `config/services.ts` writes `String(env.X || '')`, so a missing variable is
|
|
18
|
+
* an empty string, not `undefined`, and has to lose to an explicit value
|
|
19
|
+
* rather than overwrite it.
|
|
20
|
+
*/
|
|
21
|
+
export declare function present(value: string | undefined | null): string | undefined;
|
package/dist/drivers/expo.d.ts
CHANGED
|
@@ -1,4 +1,21 @@
|
|
|
1
1
|
import type { PushResult } from '@stacksjs/types';
|
|
2
|
+
/**
|
|
3
|
+
* Configure the Expo driver. Optional: without it the token comes from
|
|
4
|
+
* `config/services.ts` (`EXPO_ACCESS_TOKEN`), and anything set here wins.
|
|
5
|
+
*/
|
|
6
|
+
export declare function configure(options: ExpoConfig): void;
|
|
7
|
+
/** Forget what `configure()` set. */
|
|
8
|
+
export declare function resetConfiguration(): void;
|
|
9
|
+
/**
|
|
10
|
+
* The access token a request carries, if any: `configure()`, then the app's
|
|
11
|
+
* `config/services.ts`.
|
|
12
|
+
*
|
|
13
|
+
* `services.expo.accessToken` was declared, typed and read by nothing, so a
|
|
14
|
+
* project with enhanced push security had every push rejected however it was
|
|
15
|
+
* configured (stacksjs/stacks#2857). Read per call, after the app's config
|
|
16
|
+
* has loaded.
|
|
17
|
+
*/
|
|
18
|
+
export declare function resolveAccessToken(): Promise<string | undefined>;
|
|
2
19
|
/**
|
|
3
20
|
* Validates an Expo push token
|
|
4
21
|
*/
|
|
@@ -19,8 +36,12 @@ declare const __dtsx_default_export__: {
|
|
|
19
36
|
send: typeof send;
|
|
20
37
|
sendBatch: typeof sendBatch;
|
|
21
38
|
getReceipts: typeof getReceipts;
|
|
22
|
-
isExpoPushToken: typeof isExpoPushToken
|
|
39
|
+
isExpoPushToken: typeof isExpoPushToken;
|
|
40
|
+
configure: typeof configure
|
|
23
41
|
};
|
|
42
|
+
export declare interface ExpoConfig {
|
|
43
|
+
accessToken?: string
|
|
44
|
+
}
|
|
24
45
|
export declare interface ExpoPushMessage {
|
|
25
46
|
to: string | string[]
|
|
26
47
|
title?: string
|
package/dist/drivers/fcm.d.ts
CHANGED
|
@@ -1,8 +1,34 @@
|
|
|
1
1
|
import type { PushResult } from '@stacksjs/types';
|
|
2
2
|
/**
|
|
3
3
|
* Configure FCM with a service account
|
|
4
|
+
*
|
|
5
|
+
* Optional: without it, the credentials come from `config/services.ts`
|
|
6
|
+
* (`FCM_PROJECT_ID`, `FCM_CLIENT_EMAIL`, `FCM_PRIVATE_KEY`). Anything set here
|
|
7
|
+
* wins over config.
|
|
4
8
|
*/
|
|
5
9
|
export declare function configure(options: FCMConfig): void;
|
|
10
|
+
/** Forget what `configure()` set. For tests, and for a process that rotates keys. */
|
|
11
|
+
export declare function resetConfiguration(): void;
|
|
12
|
+
/**
|
|
13
|
+
* A private key as it comes out of an env file.
|
|
14
|
+
*
|
|
15
|
+
* A service account JSON stores the PEM with `\n` escapes, and pasting it
|
|
16
|
+
* into `.env` keeps them as two characters, so the base64 body arrives with
|
|
17
|
+
* literal backslash-n sequences in it and `atob` rejects the key. Real
|
|
18
|
+
* newlines pass through untouched.
|
|
19
|
+
*/
|
|
20
|
+
export declare function normalizePrivateKey(key: string): string;
|
|
21
|
+
/**
|
|
22
|
+
* The credentials a request uses: what `configure()` set, then the app's
|
|
23
|
+
* `config/services.ts`.
|
|
24
|
+
*
|
|
25
|
+
* `config/services.ts` declared all three FCM values and nothing read them, so
|
|
26
|
+
* an app that set the documented env vars got "not configured" back
|
|
27
|
+
* (stacksjs/stacks#2857). Resolved per call, after the app's config has
|
|
28
|
+
* loaded, so a value set in `config/services.ts` is never shadowed by the
|
|
29
|
+
* framework default it replaced.
|
|
30
|
+
*/
|
|
31
|
+
export declare function resolveConfig(): Promise<FCMConfig>;
|
|
6
32
|
/**
|
|
7
33
|
* Build the signed JWT that Google's OAuth2 endpoint accepts as a service
|
|
8
34
|
* account assertion.
|
|
@@ -11,6 +37,8 @@ export declare function configure(options: FCMConfig): void;
|
|
|
11
37
|
* can be asserted against a fixed claim set.
|
|
12
38
|
*/
|
|
13
39
|
export declare function buildServiceAccountAssertion(clientEmail: string, privateKey: string, nowSeconds: number): Promise<string>;
|
|
40
|
+
/** Forget every cached access token. For tests, and after rotating a key. */
|
|
41
|
+
export declare function clearAccessTokens(): void;
|
|
14
42
|
/**
|
|
15
43
|
* Send push notification via FCM v1 API (using service account)
|
|
16
44
|
*/
|
package/dist/index.d.ts
CHANGED
|
@@ -13,8 +13,16 @@ export type {
|
|
|
13
13
|
export declare function send(to: string | string[], notification: PushNotification, options?: SendOptions): Promise<PushResult>;
|
|
14
14
|
/**
|
|
15
15
|
* Configure FCM with credentials
|
|
16
|
+
*
|
|
17
|
+
* Optional: `config/services.ts` is read when nothing is configured here.
|
|
16
18
|
*/
|
|
17
19
|
export declare function configureFCM(config: fcm.FCMConfig): void;
|
|
20
|
+
/**
|
|
21
|
+
* Configure Expo with an access token
|
|
22
|
+
*
|
|
23
|
+
* Optional: `config/services.ts` is read when nothing is configured here.
|
|
24
|
+
*/
|
|
25
|
+
export declare function configureExpo(config: expo.ExpoConfig): void;
|
|
18
26
|
export declare interface PushNotification {
|
|
19
27
|
title?: string
|
|
20
28
|
body: string
|
package/dist/index.js
CHANGED
|
@@ -1,2 +1,3 @@
|
|
|
1
1
|
// @bun
|
|
2
|
-
var
|
|
2
|
+
var re=Object.defineProperty;var se=(e)=>e;function ne(e,t){this[e]=se.bind(null,t)}var F=(e,t)=>{for(var r in t)re(e,r,{get:t[r],enumerable:!0,configurable:!0,set:ne.bind(t,r)})};var U={};F(U,{Send:()=>v,configure:()=>E,default:()=>ce,getReceipts:()=>N,isExpoPushToken:()=>S,resetConfiguration:()=>ie,resolveAccessToken:()=>O,send:()=>v,sendBatch:()=>W});import{log as m}from"@stacksjs/cli";async function w(){let{awaitConfig:e}=await import("@stacksjs/config");return(await e()).services??{}}function g(e){let t=e?.trim();return t?t:void 0}var oe="https://exp.host/--/api/v2/push/send",b={};function E(e){b={...b,...e}}function ie(){b={}}async function O(){if(g(b.accessToken))return g(b.accessToken);try{return g((await w()).expo?.accessToken)}catch(e){m.debug(`Expo: could not read config/services.ts: ${e instanceof Error?e.message:String(e)}`);return}}async function B(e={}){let t={Accept:"application/json","Content-Type":"application/json",...e},r=await O();if(r)t.Authorization=`Bearer ${r}`;return t}function S(e){return/^Expo(?:nent)?PushToken\[.+\]$/.test(e)||/^[a-zA-Z0-9-_]+$/.test(e)}async function v(e){let t=Array.isArray(e.to)?e.to:[e.to],r=t.filter((s)=>!S(s));if(r.length>0)m.warn(`Invalid Expo push tokens found: ${r.join(", ")}`);let n=t.filter(S);if(n.length===0)return{success:!1,provider:"expo",message:"No valid Expo push tokens provided"};try{let s=n.map((a)=>({to:a,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 a of s){let f=new TextEncoder().encode(JSON.stringify(a)).length;if(f>4096)return{success:!1,provider:"expo",message:`Expo message payload (${f} bytes) exceeds 4 KiB per-message limit`}}let o=await fetch(oe,{method:"POST",headers:await B({"Accept-Encoding":"gzip, deflate"}),body:JSON.stringify(s)});if(!o.ok){let a=await o.text();return m.error(`Expo push failed: ${a}`),{success:!1,provider:"expo",message:`HTTP ${o.status}: ${a}`}}let p=(await o.json()).data,d=p.filter((a)=>a.status==="error");if(d.length>0)m.warn(`Some push notifications failed: ${JSON.stringify(d)}`);let y=p.filter((a)=>a.status==="ok").length;return m.info(`Expo push sent: ${y}/${p.length} successful`),{success:d.length===0,provider:"expo",message:`Sent ${y}/${p.length} notifications`,messageId:p.find((a)=>a.id)?.id,data:{tickets:p}}}catch(s){let i=s instanceof Error?s:Error(String(s));return m.error(`Expo push error: ${i.message}`),{success:!1,provider:"expo",message:i.message}}}async function N(e){let t=await fetch("https://exp.host/--/api/v2/push/getReceipts",{method:"POST",headers:await B(),body:JSON.stringify({ids:e})});if(!t.ok)throw Error(`Failed to get receipts: ${t.status}`);return(await t.json()).data}async function W(e,t=100){let r=[];for(let n=0;n<e.length;n+=t){let s=e.slice(n,n+t),i=await Promise.all(s.map((o)=>v(o)));r.push(...i)}return r}var ce={send:v,sendBatch:W,getReceipts:N,isExpoPushToken:S,configure:E};var q={};F(q,{Send:()=>h,buildServiceAccountAssertion:()=>V,clearAccessTokens:()=>pe,configure:()=>k,default:()=>de,normalizePrivateKey:()=>J,resetConfiguration:()=>ae,resolveConfig:()=>I,send:()=>h,sendMulticast:()=>T,sendToTopic:()=>L,subscribeToTopic:()=>X,unsubscribeFromTopic:()=>Y});import{log as P}from"@stacksjs/cli";var D="https://fcm.googleapis.com/v1/projects",l={};function k(e){l={...l,...e}}function ae(){l={},C.clear()}function J(e){return e.includes("\\n")?e.replace(/\\n/g,`
|
|
3
|
+
`):e}async function I(){if(l.projectId&&l.serviceAccount)return l;let e={};try{e=await w()}catch(s){P.debug(`FCM: could not read config/services.ts: ${s instanceof Error?s.message:String(s)}`)}let t=e.fcm??{},r=g(t.clientEmail),n=g(t.privateKey);return{projectId:l.projectId??g(t.projectId),serviceAccount:l.serviceAccount??(r&&n?{clientEmail:r,privateKey:J(n)}:void 0)}}function K(e){return btoa(e).replace(/\+/g,"-").replace(/\//g,"_").replace(/=+$/,"")}async function V(e,t,r){let n={alg:"RS256",typ:"JWT"},s={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=K(JSON.stringify(n)),c=K(JSON.stringify(s)),p=`${o}.${c}`,d=t.replace(/-----BEGIN PRIVATE KEY-----/,"").replace(/-----END PRIVATE KEY-----/,"").replace(/\s/g,""),y=Uint8Array.from(atob(d),(M)=>M.charCodeAt(0)),a=await crypto.subtle.importKey("pkcs8",y,{name:"RSASSA-PKCS1-v1_5",hash:"SHA-256"},!1,["sign"]),f=await crypto.subtle.sign("RSASSA-PKCS1-v1_5",a,i.encode(p)),A=K(String.fromCharCode(...new Uint8Array(f)));return`${o}.${c}.${A}`}var C=new Map,ue=60000;function pe(){C.clear()}async function H(e){if(!e.serviceAccount)throw Error("Service account not configured for FCM v1 API");let{clientEmail:t,privateKey:r}=e.serviceAccount,n=C.get(t);if(n&&n.expiresAt-ue>Date.now())return n.token;let s=await V(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=${s}`});if(!i.ok)throw Error(`Failed to get access token: ${i.status}`);let o=await i.json(),c=Number(o.expires_in);return C.set(t,{token:o.access_token,expiresAt:Date.now()+(Number.isFinite(c)&&c>0?c:3600)*1000}),o.access_token}async function h(e){let t=await I();if(!t.serviceAccount||!t.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 r=await H(t),n=`${D}/${t.projectId}/messages:send`,s={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)s.message.token=e.to;else if(e.topic)s.message.topic=e.topic;else if(e.condition)s.message.condition=e.condition;let i=await fetch(n,{method:"POST",headers:{Authorization:`Bearer ${r}`,"Content-Type":"application/json"},body:JSON.stringify(s)});if(!i.ok){let c=await i.text();return P.error(`FCM v1 push failed: ${c}`),{success:!1,provider:"fcm",message:`HTTP ${i.status}: ${c}`}}let o=await i.json();return P.info(`FCM v1 push sent: ${o.name}`),{success:!0,provider:"fcm",message:"Notification sent successfully",messageId:o.name}}catch(r){let n=r instanceof Error?r:Error(String(r));return P.error(`FCM v1 push error: ${n.message}`),{success:!1,provider:"fcm",message:n.message}}}async function T(e,t){return Promise.all(e.map((r)=>h({...t,to:r})))}async function L(e,t){return h({...t,topic:e})}async function z(e,t,r,n){let s=await H(e),i=`${D}/${e.projectId}/registrations/${encodeURIComponent(t)}/topicSubscriptions`,o=n?`${i}?topic_name=${encodeURIComponent(r)}`:`${i}/${encodeURIComponent(r)}`,c=await fetch(o,{method:n?"POST":"DELETE",headers:{Authorization:`Bearer ${s}`,"Content-Type":"application/json"},body:"{}"});if(c.ok||n&&c.status===409)return!0;return P.error(`FCM topic ${n?"subscribe":"unsubscribe"} failed for topic ${r}: HTTP ${c.status}`),!1}async function G(e){let t=await I();if(!t.serviceAccount||!t.projectId)throw Error(`FCM service account and projectId required for ${e}`);return t}async function X(e,t){let r=await G("topic subscription");return(await Promise.all(e.map((s)=>z(r,s,t,!0)))).every(Boolean)}async function Y(e,t){let r=await G("topic unsubscription");return(await Promise.all(e.map((s)=>z(r,s,t,!1)))).every(Boolean)}var de={send:h,sendMulticast:T,sendToTopic:L,subscribeToTopic:X,unsubscribeFromTopic:Y,configure:k};import{Buffer as u}from"buffer";import{createCipheriv as fe,createECDH as ge,createHmac as Z,createPrivateKey as le,createSign as ye,generateKeyPairSync as me,randomBytes as he}from"crypto";var Q="prime256v1";function x(e){return e.toString("base64url")}function R(e){return u.from(e,"base64url")}function xe(){let{publicKey:e,privateKey:t}=me("ec",{namedCurve:Q}),r=e.export({type:"spki",format:"der"}),n=t.export({format:"jwk"});return{publicKey:x(r.subarray(r.length-65)),privateKey:String(n.d)}}function be(e){let t=new URL(e);return`${t.protocol}//${t.host}`}function ee(e){let r=Math.floor(e.nowSeconds??Date.now()/1000)+Math.min(e.expiresInSeconds??43200,86400),n=x(u.from(JSON.stringify({typ:"JWT",alg:"ES256"}))),s=x(u.from(JSON.stringify({aud:be(e.endpoint),exp:r,sub:e.subject}))),i=`${n}.${s}`,o=le({key:{kty:"EC",crv:"P-256",d:e.vapid.privateKey,x:x(R(e.vapid.publicKey).subarray(1,33)),y:x(R(e.vapid.publicKey).subarray(33,65))},format:"jwk"}),c=ye("SHA256");c.update(i);let p=c.sign({key:o,dsaEncoding:"ieee-p1363"});return{Authorization:`vapid t=${i}.${x(p)}, k=${e.vapid.publicKey}`,"Crypto-Key":`p256ecdsa=${e.vapid.publicKey}`}}function j(e,t,r,n){let s=Z("sha256",e).update(t).digest();return Z("sha256",s).update(u.concat([r,u.from([1])])).digest().subarray(0,n)}function te(e,t,r=he(16)){let n=R(e.keys.p256dh),s=R(e.keys.auth),i=ge(Q);i.generateKeys();let o=i.getPublicKey(),c=i.computeSecret(n),p=u.concat([u.from("WebPush: info\x00"),n,o]),d=j(s,c,p,32),y=j(r,d,u.from("Content-Encoding: aes128gcm\x00"),16),a=j(r,d,u.from("Content-Encoding: nonce\x00"),12),f=fe("aes-128-gcm",y,a),A=u.concat([u.from(t,"utf8"),u.from([2])]),M=u.concat([f.update(A),f.final(),f.getAuthTag()]),_=u.alloc(4);return _.writeUInt32BE(4096,0),u.concat([r,_,u.from([o.length]),o,M])}async function ve(e){let{subscription:t,vapid:r,subject:n}=e;if(!t?.endpoint)return{success:!1,status:0,expired:!1,error:"no endpoint"};let s={...ee({endpoint:t.endpoint,vapid:r,subject:n}),TTL:String(e.ttl??86400),Urgency:e.urgency??"normal"};if(e.topic)s.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=te(t,e.payload),s["Content-Encoding"]="aes128gcm",s["Content-Type"]="application/octet-stream",s["Content-Length"]=String(i.length)}try{let o=await fetch(t.endpoint,{method:"POST",headers:s,body:i?new Uint8Array(i):void 0}),c=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:c,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 Ae(e,t,r={}){let n=r.driver??"expo";if(n==="expo")return v({to:e,title:t.title,body:t.body,data:t.data,badge:t.badge,sound:t.sound,priority:t.priority});if(n==="fcm"){let s=Array.isArray(e)?e:[e];if(s.length===1)return h({to:s[0],notification:{title:t.title??"",body:t.body},data:t.data,priority:t.priority==="default"?"normal":t.priority});let i=await T(s,{notification:{title:t.title??"",body:t.body},data:t.data,priority:t.priority==="default"?"normal":t.priority});return{success:i.every((c)=>c.success),provider:"fcm",message:`Sent to ${i.filter((c)=>c.success).length}/${i.length} devices`}}return{success:!1,provider:n,message:`Unknown push driver: ${n}`}}function Me(e){k(e)}function Ke(e){E(e)}export{ee as buildVapidHeaders,Ke as configureExpo,Me as configureFCM,te as encryptPayload,U as expo,q as fcm,xe as generateVapidKeys,Ae as send,ve 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.
|
|
5
|
+
"version": "0.75.73",
|
|
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.
|
|
59
|
+
"@stacksjs/cli": "0.75.73",
|
|
60
|
+
"@stacksjs/config": "0.75.73"
|
|
60
61
|
},
|
|
61
62
|
"devDependencies": {
|
|
62
|
-
"@stacksjs/
|
|
63
|
-
"@stacksjs/error-handling": "0.75.72",
|
|
63
|
+
"@stacksjs/error-handling": "0.75.73",
|
|
64
64
|
"better-dx": "^0.2.24"
|
|
65
65
|
}
|
|
66
66
|
}
|