@stacksjs/ts-cloud 0.1.8 → 0.1.9
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/bin/cli.js +1 -1
- package/package.json +18 -16
- package/src/aws/acm.ts +768 -0
- package/src/aws/application-autoscaling.ts +845 -0
- package/src/aws/bedrock.ts +4074 -0
- package/src/aws/client.ts +891 -0
- package/src/aws/cloudformation.ts +896 -0
- package/src/aws/cloudfront.ts +1531 -0
- package/src/aws/cloudwatch-logs.ts +154 -0
- package/src/aws/comprehend.ts +839 -0
- package/src/aws/connect.ts +1056 -0
- package/src/aws/deploy-imap.ts +384 -0
- package/src/aws/dynamodb.ts +340 -0
- package/src/aws/ec2.ts +1385 -0
- package/src/aws/ecr.ts +621 -0
- package/src/aws/ecs.ts +615 -0
- package/src/aws/elasticache.ts +301 -0
- package/src/aws/elbv2.ts +942 -0
- package/src/aws/email.ts +928 -0
- package/src/aws/eventbridge.ts +248 -0
- package/src/aws/iam.ts +1689 -0
- package/src/aws/imap-server.ts +2100 -0
- package/src/aws/index.ts +213 -0
- package/src/aws/kendra.ts +1097 -0
- package/src/aws/lambda.ts +786 -0
- package/src/aws/opensearch.ts +158 -0
- package/src/aws/personalize.ts +977 -0
- package/src/aws/polly.ts +559 -0
- package/src/aws/rds.ts +888 -0
- package/src/aws/rekognition.ts +846 -0
- package/src/aws/route53-domains.ts +359 -0
- package/src/aws/route53.ts +1046 -0
- package/src/aws/s3.ts +2334 -0
- package/src/aws/scheduler.ts +571 -0
- package/src/aws/secrets-manager.ts +769 -0
- package/src/aws/ses.ts +1081 -0
- package/src/aws/setup-phone.ts +104 -0
- package/src/aws/setup-sms.ts +580 -0
- package/src/aws/sms.ts +1735 -0
- package/src/aws/smtp-server.ts +531 -0
- package/src/aws/sns.ts +758 -0
- package/src/aws/sqs.ts +382 -0
- package/src/aws/ssm.ts +807 -0
- package/src/aws/sts.ts +92 -0
- package/src/aws/support.ts +391 -0
- package/src/aws/test-imap.ts +86 -0
- package/src/aws/textract.ts +780 -0
- package/src/aws/transcribe.ts +108 -0
- package/src/aws/translate.ts +641 -0
- package/src/aws/voice.ts +1379 -0
- package/src/config.ts +35 -0
- package/src/deploy/index.ts +7 -0
- package/src/deploy/static-site-external-dns.ts +945 -0
- package/src/deploy/static-site.ts +1175 -0
- package/src/dns/cloudflare.ts +548 -0
- package/src/dns/godaddy.ts +412 -0
- package/src/dns/index.ts +205 -0
- package/src/dns/porkbun.ts +362 -0
- package/src/dns/route53-adapter.ts +414 -0
- package/src/dns/types.ts +119 -0
- package/src/dns/validator.ts +369 -0
- package/src/generators/index.ts +5 -0
- package/src/generators/infrastructure.ts +1660 -0
- package/src/index.ts +163 -0
- package/src/push/apns.ts +452 -0
- package/src/push/fcm.ts +506 -0
- package/src/push/index.ts +58 -0
- package/src/security/pre-deploy-scanner.ts +655 -0
- package/src/ssl/acme-client.ts +478 -0
- package/src/ssl/index.ts +7 -0
- package/src/ssl/letsencrypt.ts +747 -0
- package/src/types.ts +2 -0
- package/src/utils/cli.ts +398 -0
- package/src/validation/index.ts +5 -0
- package/src/validation/template.ts +405 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
export * from './config'
|
|
2
|
+
// Note: ./types re-exports @stacksjs/ts-cloud-types, which we export below
|
|
3
|
+
// export * from './types'
|
|
4
|
+
export * from './generators'
|
|
5
|
+
|
|
6
|
+
// Validation exports - functions
|
|
7
|
+
export {
|
|
8
|
+
validateTemplate,
|
|
9
|
+
validateTemplateSize,
|
|
10
|
+
validateResourceLimits,
|
|
11
|
+
} from './validation'
|
|
12
|
+
|
|
13
|
+
// Validation exports - types with prefixed names for conflicts with @stacksjs/ts-cloud-core
|
|
14
|
+
export type {
|
|
15
|
+
ValidationError as TemplateValidationError,
|
|
16
|
+
ValidationResult as TemplateValidationResult,
|
|
17
|
+
} from './validation'
|
|
18
|
+
|
|
19
|
+
// Export AWS module - classes and functions
|
|
20
|
+
export {
|
|
21
|
+
AWSClient,
|
|
22
|
+
CloudFormationClient as AWSCloudFormationClient,
|
|
23
|
+
CloudFrontClient as AWSCloudFrontClient,
|
|
24
|
+
EC2Client,
|
|
25
|
+
S3Client,
|
|
26
|
+
Route53Client,
|
|
27
|
+
Route53DomainsClient,
|
|
28
|
+
ACMClient,
|
|
29
|
+
ACMDnsValidator,
|
|
30
|
+
ECRClient,
|
|
31
|
+
ECSClient,
|
|
32
|
+
STSClient,
|
|
33
|
+
SSMClient,
|
|
34
|
+
SecretsManagerClient,
|
|
35
|
+
SESClient,
|
|
36
|
+
EmailClient,
|
|
37
|
+
SNSClient,
|
|
38
|
+
SQSClient,
|
|
39
|
+
LambdaClient,
|
|
40
|
+
CloudWatchLogsClient,
|
|
41
|
+
ConnectClient,
|
|
42
|
+
ELBv2Client,
|
|
43
|
+
RDSClient,
|
|
44
|
+
DynamoDBClient,
|
|
45
|
+
OpenSearchClient,
|
|
46
|
+
TranscribeClient,
|
|
47
|
+
BedrockRuntimeClient,
|
|
48
|
+
ComprehendClient,
|
|
49
|
+
RekognitionClient,
|
|
50
|
+
TextractClient,
|
|
51
|
+
PollyClient,
|
|
52
|
+
TranslateClient,
|
|
53
|
+
PersonalizeClient,
|
|
54
|
+
KendraClient,
|
|
55
|
+
EventBridgeClient,
|
|
56
|
+
ElastiCacheClient,
|
|
57
|
+
SchedulerClient,
|
|
58
|
+
IAMClient,
|
|
59
|
+
ApplicationAutoScalingClient,
|
|
60
|
+
SmsClient,
|
|
61
|
+
VoiceClient,
|
|
62
|
+
SupportClient,
|
|
63
|
+
} from './aws'
|
|
64
|
+
|
|
65
|
+
// Export AWS module - types with prefixed names where needed
|
|
66
|
+
export type {
|
|
67
|
+
AWSRequestOptions,
|
|
68
|
+
AWSClientConfig,
|
|
69
|
+
AWSError,
|
|
70
|
+
AWSCredentials as AWSClientCredentials,
|
|
71
|
+
StackParameter,
|
|
72
|
+
StackTag,
|
|
73
|
+
CreateStackOptions,
|
|
74
|
+
UpdateStackOptions,
|
|
75
|
+
DescribeStacksOptions,
|
|
76
|
+
StackEvent,
|
|
77
|
+
Stack,
|
|
78
|
+
InvalidationOptions,
|
|
79
|
+
Distribution,
|
|
80
|
+
S3SyncOptions,
|
|
81
|
+
S3CopyOptions,
|
|
82
|
+
S3ListOptions,
|
|
83
|
+
S3Object,
|
|
84
|
+
CertificateDetail,
|
|
85
|
+
Certificate as ELBv2Certificate,
|
|
86
|
+
RekognitionS3Object,
|
|
87
|
+
RekognitionBoundingBox,
|
|
88
|
+
TextractS3Object,
|
|
89
|
+
TextractBoundingBox,
|
|
90
|
+
KendraCreateDataSourceCommandInput,
|
|
91
|
+
KendraCreateDataSourceCommandOutput,
|
|
92
|
+
KendraListDataSourcesCommandInput,
|
|
93
|
+
KendraListDataSourcesCommandOutput,
|
|
94
|
+
} from './aws'
|
|
95
|
+
|
|
96
|
+
export * from './ssl'
|
|
97
|
+
|
|
98
|
+
// Export deployment modules
|
|
99
|
+
export {
|
|
100
|
+
deployStaticSite,
|
|
101
|
+
deployStaticSiteFull,
|
|
102
|
+
uploadStaticFiles,
|
|
103
|
+
invalidateCache,
|
|
104
|
+
deleteStaticSite,
|
|
105
|
+
generateStaticSiteTemplate,
|
|
106
|
+
// External DNS support
|
|
107
|
+
deployStaticSiteWithExternalDns,
|
|
108
|
+
deployStaticSiteWithExternalDnsFull,
|
|
109
|
+
generateExternalDnsStaticSiteTemplate,
|
|
110
|
+
} from './deploy'
|
|
111
|
+
export type {
|
|
112
|
+
StaticSiteConfig,
|
|
113
|
+
DeployResult,
|
|
114
|
+
UploadOptions,
|
|
115
|
+
// External DNS types
|
|
116
|
+
ExternalDnsStaticSiteConfig,
|
|
117
|
+
ExternalDnsDeployResult,
|
|
118
|
+
} from './deploy'
|
|
119
|
+
|
|
120
|
+
// Export DNS providers
|
|
121
|
+
export {
|
|
122
|
+
createDnsProvider,
|
|
123
|
+
detectDnsProvider,
|
|
124
|
+
DnsProviderFactory,
|
|
125
|
+
dnsProviders,
|
|
126
|
+
PorkbunProvider,
|
|
127
|
+
GoDaddyProvider,
|
|
128
|
+
Route53Provider,
|
|
129
|
+
UnifiedDnsValidator,
|
|
130
|
+
createPorkbunValidator,
|
|
131
|
+
createGoDaddyValidator,
|
|
132
|
+
createRoute53Validator,
|
|
133
|
+
} from './dns'
|
|
134
|
+
export type {
|
|
135
|
+
DnsProvider,
|
|
136
|
+
DnsProviderConfig,
|
|
137
|
+
DnsRecord,
|
|
138
|
+
DnsRecordType,
|
|
139
|
+
DnsRecordResult,
|
|
140
|
+
CreateRecordResult,
|
|
141
|
+
DeleteRecordResult,
|
|
142
|
+
ListRecordsResult,
|
|
143
|
+
} from './dns'
|
|
144
|
+
|
|
145
|
+
// Re-export core functionality (these take precedence for common types)
|
|
146
|
+
export * from '@stacksjs/ts-cloud-core'
|
|
147
|
+
|
|
148
|
+
// Re-export @stacksjs/ts-cloud-types (includes VpcConfig, etc.)
|
|
149
|
+
export * from '@stacksjs/ts-cloud-types'
|
|
150
|
+
|
|
151
|
+
// Re-export @stacksjs/ts-cloud-aws-types with explicit handling for duplicates
|
|
152
|
+
// Note: @stacksjs/ts-cloud-core also exports CloudFormation* types, so we skip re-exporting them here
|
|
153
|
+
// to avoid duplicates. Users can import directly from @stacksjs/ts-cloud-aws-types if needed.
|
|
154
|
+
export type {
|
|
155
|
+
// S3 types
|
|
156
|
+
S3Bucket,
|
|
157
|
+
S3BucketPolicy,
|
|
158
|
+
// CloudFront types
|
|
159
|
+
CloudFrontDistribution,
|
|
160
|
+
CloudFrontOriginAccessControl,
|
|
161
|
+
CloudFrontCacheBehavior,
|
|
162
|
+
CloudFrontOrigin,
|
|
163
|
+
} from '@stacksjs/ts-cloud-aws-types'
|
package/src/push/apns.ts
ADDED
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Apple Push Notification Service (APNs) Client
|
|
3
|
+
* Uses HTTP/2 with JWT token authentication
|
|
4
|
+
*
|
|
5
|
+
* Prerequisites:
|
|
6
|
+
* - Apple Developer account
|
|
7
|
+
* - APNs Key (p8 file) from Apple Developer Portal
|
|
8
|
+
* - Key ID and Team ID
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* ```ts
|
|
12
|
+
* const apns = new APNsClient({
|
|
13
|
+
* keyId: 'ABC123DEFG',
|
|
14
|
+
* teamId: 'DEF456GHIJ',
|
|
15
|
+
* privateKey: fs.readFileSync('AuthKey_ABC123DEFG.p8', 'utf8'),
|
|
16
|
+
* bundleId: 'com.example.app',
|
|
17
|
+
* production: false // true for production, false for sandbox
|
|
18
|
+
* })
|
|
19
|
+
*
|
|
20
|
+
* await apns.send({
|
|
21
|
+
* deviceToken: '...',
|
|
22
|
+
* title: 'Hello',
|
|
23
|
+
* body: 'World',
|
|
24
|
+
* })
|
|
25
|
+
* ```
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { createSign } from 'node:crypto'
|
|
29
|
+
import * as http2 from 'node:http2'
|
|
30
|
+
|
|
31
|
+
export interface APNsConfig {
|
|
32
|
+
/** APNs Key ID from Apple Developer Portal */
|
|
33
|
+
keyId: string
|
|
34
|
+
/** Team ID from Apple Developer Portal */
|
|
35
|
+
teamId: string
|
|
36
|
+
/** Private key content (p8 file content) */
|
|
37
|
+
privateKey: string
|
|
38
|
+
/** iOS app bundle ID (e.g., com.example.app) */
|
|
39
|
+
bundleId: string
|
|
40
|
+
/** Use production APNs server (default: false) */
|
|
41
|
+
production?: boolean
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface APNsNotification {
|
|
45
|
+
/** Device token to send to */
|
|
46
|
+
deviceToken: string
|
|
47
|
+
/** Alert title */
|
|
48
|
+
title?: string
|
|
49
|
+
/** Alert subtitle */
|
|
50
|
+
subtitle?: string
|
|
51
|
+
/** Alert body */
|
|
52
|
+
body?: string
|
|
53
|
+
/** Badge number to display on app icon */
|
|
54
|
+
badge?: number
|
|
55
|
+
/** Sound to play (use 'default' for default sound) */
|
|
56
|
+
sound?: string | { name: string; critical?: number; volume?: number }
|
|
57
|
+
/** Category identifier for actionable notifications */
|
|
58
|
+
category?: string
|
|
59
|
+
/** Thread identifier for grouping notifications */
|
|
60
|
+
threadId?: string
|
|
61
|
+
/** Custom data payload */
|
|
62
|
+
data?: Record<string, any>
|
|
63
|
+
/** Content available flag for background updates */
|
|
64
|
+
contentAvailable?: boolean
|
|
65
|
+
/** Mutable content flag for notification service extension */
|
|
66
|
+
mutableContent?: boolean
|
|
67
|
+
/** Push type (default: 'alert') */
|
|
68
|
+
pushType?: 'alert' | 'background' | 'voip' | 'complication' | 'fileprovider' | 'mdm' | 'liveactivity'
|
|
69
|
+
/** Notification priority (10 = immediate, 5 = can be delayed) */
|
|
70
|
+
priority?: 5 | 10
|
|
71
|
+
/** Expiration timestamp (Unix time in seconds) */
|
|
72
|
+
expiration?: number
|
|
73
|
+
/** Collapse identifier for coalescing notifications */
|
|
74
|
+
collapseId?: string
|
|
75
|
+
/** Target content id for live activities */
|
|
76
|
+
targetContentId?: string
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface APNsSendResult {
|
|
80
|
+
success: boolean
|
|
81
|
+
deviceToken: string
|
|
82
|
+
apnsId?: string
|
|
83
|
+
statusCode?: number
|
|
84
|
+
error?: string
|
|
85
|
+
reason?: string
|
|
86
|
+
timestamp?: number
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface APNsBatchResult {
|
|
90
|
+
sent: number
|
|
91
|
+
failed: number
|
|
92
|
+
results: APNsSendResult[]
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const APNS_PRODUCTION_HOST = 'api.push.apple.com'
|
|
96
|
+
const APNS_SANDBOX_HOST = 'api.sandbox.push.apple.com'
|
|
97
|
+
const TOKEN_EXPIRY_MS = 45 * 60 * 1000 // 45 minutes (tokens valid for 1 hour)
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Apple Push Notification Service client
|
|
101
|
+
*/
|
|
102
|
+
export class APNsClient {
|
|
103
|
+
private config: APNsConfig
|
|
104
|
+
private token: string | null = null
|
|
105
|
+
private tokenGeneratedAt: number = 0
|
|
106
|
+
private client: http2.ClientHttp2Session | null = null
|
|
107
|
+
private host: string
|
|
108
|
+
|
|
109
|
+
constructor(config: APNsConfig) {
|
|
110
|
+
this.config = config
|
|
111
|
+
this.host = config.production ? APNS_PRODUCTION_HOST : APNS_SANDBOX_HOST
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Generate a new JWT token for APNs authentication
|
|
116
|
+
*/
|
|
117
|
+
private generateToken(): string {
|
|
118
|
+
const now = Math.floor(Date.now() / 1000)
|
|
119
|
+
|
|
120
|
+
// Check if current token is still valid
|
|
121
|
+
if (this.token && (Date.now() - this.tokenGeneratedAt) < TOKEN_EXPIRY_MS) {
|
|
122
|
+
return this.token
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// JWT Header
|
|
126
|
+
const header = {
|
|
127
|
+
alg: 'ES256',
|
|
128
|
+
kid: this.config.keyId,
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// JWT Payload
|
|
132
|
+
const payload = {
|
|
133
|
+
iss: this.config.teamId,
|
|
134
|
+
iat: now,
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Encode header and payload
|
|
138
|
+
const encodedHeader = Buffer.from(JSON.stringify(header)).toString('base64url')
|
|
139
|
+
const encodedPayload = Buffer.from(JSON.stringify(payload)).toString('base64url')
|
|
140
|
+
const signatureInput = `${encodedHeader}.${encodedPayload}`
|
|
141
|
+
|
|
142
|
+
// Sign with ES256 (ECDSA P-256)
|
|
143
|
+
const sign = createSign('SHA256')
|
|
144
|
+
sign.update(signatureInput)
|
|
145
|
+
const signature = sign.sign(this.config.privateKey)
|
|
146
|
+
|
|
147
|
+
// Convert DER signature to raw format (64 bytes)
|
|
148
|
+
const rawSignature = this.derToRaw(signature)
|
|
149
|
+
const encodedSignature = rawSignature.toString('base64url')
|
|
150
|
+
|
|
151
|
+
this.token = `${signatureInput}.${encodedSignature}`
|
|
152
|
+
this.tokenGeneratedAt = Date.now()
|
|
153
|
+
|
|
154
|
+
return this.token
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Convert DER encoded ECDSA signature to raw format
|
|
159
|
+
*/
|
|
160
|
+
private derToRaw(derSignature: Buffer): Buffer {
|
|
161
|
+
// DER format: 0x30 [total-length] 0x02 [r-length] [r] 0x02 [s-length] [s]
|
|
162
|
+
let offset = 2 // Skip 0x30 and total length
|
|
163
|
+
|
|
164
|
+
// Read R
|
|
165
|
+
if (derSignature[offset] !== 0x02) {
|
|
166
|
+
throw new Error('Invalid DER signature: expected 0x02 for R')
|
|
167
|
+
}
|
|
168
|
+
offset++
|
|
169
|
+
const rLength = derSignature[offset]
|
|
170
|
+
offset++
|
|
171
|
+
let r = derSignature.subarray(offset, offset + rLength)
|
|
172
|
+
offset += rLength
|
|
173
|
+
|
|
174
|
+
// Read S
|
|
175
|
+
if (derSignature[offset] !== 0x02) {
|
|
176
|
+
throw new Error('Invalid DER signature: expected 0x02 for S')
|
|
177
|
+
}
|
|
178
|
+
offset++
|
|
179
|
+
const sLength = derSignature[offset]
|
|
180
|
+
offset++
|
|
181
|
+
let s = derSignature.subarray(offset, offset + sLength)
|
|
182
|
+
|
|
183
|
+
// Remove leading zeros and pad to 32 bytes
|
|
184
|
+
if (r[0] === 0x00 && r.length === 33) {
|
|
185
|
+
r = r.subarray(1)
|
|
186
|
+
}
|
|
187
|
+
if (s[0] === 0x00 && s.length === 33) {
|
|
188
|
+
s = s.subarray(1)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Pad to 32 bytes if needed
|
|
192
|
+
const result = Buffer.alloc(64)
|
|
193
|
+
r.copy(result, 32 - r.length)
|
|
194
|
+
s.copy(result, 64 - s.length)
|
|
195
|
+
|
|
196
|
+
return result
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Get or create HTTP/2 client connection
|
|
201
|
+
*/
|
|
202
|
+
private async getClient(): Promise<http2.ClientHttp2Session> {
|
|
203
|
+
if (this.client && !this.client.destroyed) {
|
|
204
|
+
return this.client
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return new Promise((resolve, reject) => {
|
|
208
|
+
this.client = http2.connect(`https://${this.host}`)
|
|
209
|
+
|
|
210
|
+
this.client.on('error', (err) => {
|
|
211
|
+
reject(err)
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
this.client.on('connect', () => {
|
|
215
|
+
resolve(this.client!)
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
// Set up automatic reconnection on close
|
|
219
|
+
this.client.on('close', () => {
|
|
220
|
+
this.client = null
|
|
221
|
+
})
|
|
222
|
+
})
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Build APNs payload from notification options
|
|
227
|
+
*/
|
|
228
|
+
private buildPayload(notification: APNsNotification): object {
|
|
229
|
+
const aps: Record<string, any> = {}
|
|
230
|
+
|
|
231
|
+
// Alert
|
|
232
|
+
if (notification.title || notification.body || notification.subtitle) {
|
|
233
|
+
aps.alert = {}
|
|
234
|
+
if (notification.title) aps.alert.title = notification.title
|
|
235
|
+
if (notification.subtitle) aps.alert.subtitle = notification.subtitle
|
|
236
|
+
if (notification.body) aps.alert.body = notification.body
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Badge
|
|
240
|
+
if (notification.badge !== undefined) {
|
|
241
|
+
aps.badge = notification.badge
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Sound
|
|
245
|
+
if (notification.sound !== undefined) {
|
|
246
|
+
aps.sound = notification.sound
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Category
|
|
250
|
+
if (notification.category) {
|
|
251
|
+
aps.category = notification.category
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Thread ID
|
|
255
|
+
if (notification.threadId) {
|
|
256
|
+
aps['thread-id'] = notification.threadId
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Content available (for background updates)
|
|
260
|
+
if (notification.contentAvailable) {
|
|
261
|
+
aps['content-available'] = 1
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Mutable content (for notification service extension)
|
|
265
|
+
if (notification.mutableContent) {
|
|
266
|
+
aps['mutable-content'] = 1
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Target content id (for live activities)
|
|
270
|
+
if (notification.targetContentId) {
|
|
271
|
+
aps['target-content-id'] = notification.targetContentId
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const payload: Record<string, any> = { aps }
|
|
275
|
+
|
|
276
|
+
// Add custom data
|
|
277
|
+
if (notification.data) {
|
|
278
|
+
Object.assign(payload, notification.data)
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
return payload
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Send a push notification to a single device
|
|
286
|
+
*/
|
|
287
|
+
async send(notification: APNsNotification): Promise<APNsSendResult> {
|
|
288
|
+
try {
|
|
289
|
+
const client = await this.getClient()
|
|
290
|
+
const token = this.generateToken()
|
|
291
|
+
const payload = JSON.stringify(this.buildPayload(notification))
|
|
292
|
+
|
|
293
|
+
const headers: http2.OutgoingHttpHeaders = {
|
|
294
|
+
':method': 'POST',
|
|
295
|
+
':path': `/3/device/${notification.deviceToken}`,
|
|
296
|
+
'authorization': `bearer ${token}`,
|
|
297
|
+
'apns-topic': this.config.bundleId,
|
|
298
|
+
'apns-push-type': notification.pushType || 'alert',
|
|
299
|
+
'apns-priority': String(notification.priority || 10),
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
if (notification.expiration !== undefined) {
|
|
303
|
+
headers['apns-expiration'] = String(notification.expiration)
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
if (notification.collapseId) {
|
|
307
|
+
headers['apns-collapse-id'] = notification.collapseId
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
return new Promise((resolve) => {
|
|
311
|
+
const req = client.request(headers)
|
|
312
|
+
|
|
313
|
+
let responseData = ''
|
|
314
|
+
let statusCode: number = 0
|
|
315
|
+
let apnsId: string | undefined
|
|
316
|
+
|
|
317
|
+
req.on('response', (headers) => {
|
|
318
|
+
statusCode = headers[':status'] as number
|
|
319
|
+
apnsId = headers['apns-id'] as string | undefined
|
|
320
|
+
})
|
|
321
|
+
|
|
322
|
+
req.on('data', (chunk) => {
|
|
323
|
+
responseData += chunk.toString()
|
|
324
|
+
})
|
|
325
|
+
|
|
326
|
+
req.on('end', () => {
|
|
327
|
+
if (statusCode === 200) {
|
|
328
|
+
resolve({
|
|
329
|
+
success: true,
|
|
330
|
+
deviceToken: notification.deviceToken,
|
|
331
|
+
apnsId,
|
|
332
|
+
statusCode,
|
|
333
|
+
})
|
|
334
|
+
} else {
|
|
335
|
+
let error = 'Unknown error'
|
|
336
|
+
let reason: string | undefined
|
|
337
|
+
let timestamp: number | undefined
|
|
338
|
+
|
|
339
|
+
if (responseData) {
|
|
340
|
+
try {
|
|
341
|
+
const parsed = JSON.parse(responseData)
|
|
342
|
+
reason = parsed.reason
|
|
343
|
+
timestamp = parsed.timestamp
|
|
344
|
+
error = reason || error
|
|
345
|
+
} catch {
|
|
346
|
+
error = responseData
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
resolve({
|
|
351
|
+
success: false,
|
|
352
|
+
deviceToken: notification.deviceToken,
|
|
353
|
+
apnsId,
|
|
354
|
+
statusCode,
|
|
355
|
+
error,
|
|
356
|
+
reason,
|
|
357
|
+
timestamp,
|
|
358
|
+
})
|
|
359
|
+
}
|
|
360
|
+
})
|
|
361
|
+
|
|
362
|
+
req.on('error', (err) => {
|
|
363
|
+
resolve({
|
|
364
|
+
success: false,
|
|
365
|
+
deviceToken: notification.deviceToken,
|
|
366
|
+
error: err.message,
|
|
367
|
+
})
|
|
368
|
+
})
|
|
369
|
+
|
|
370
|
+
req.write(payload)
|
|
371
|
+
req.end()
|
|
372
|
+
})
|
|
373
|
+
} catch (error: any) {
|
|
374
|
+
return {
|
|
375
|
+
success: false,
|
|
376
|
+
deviceToken: notification.deviceToken,
|
|
377
|
+
error: error.message,
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Send push notifications to multiple devices
|
|
384
|
+
*/
|
|
385
|
+
async sendBatch(
|
|
386
|
+
notifications: APNsNotification[],
|
|
387
|
+
options?: { concurrency?: number }
|
|
388
|
+
): Promise<APNsBatchResult> {
|
|
389
|
+
const concurrency = options?.concurrency || 10
|
|
390
|
+
const results: APNsSendResult[] = []
|
|
391
|
+
|
|
392
|
+
// Process in batches
|
|
393
|
+
for (let i = 0; i < notifications.length; i += concurrency) {
|
|
394
|
+
const batch = notifications.slice(i, i + concurrency)
|
|
395
|
+
const batchResults = await Promise.all(batch.map(n => this.send(n)))
|
|
396
|
+
results.push(...batchResults)
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
return {
|
|
400
|
+
sent: results.filter(r => r.success).length,
|
|
401
|
+
failed: results.filter(r => !r.success).length,
|
|
402
|
+
results,
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Send a simple notification (convenience method)
|
|
408
|
+
*/
|
|
409
|
+
async sendSimple(
|
|
410
|
+
deviceToken: string,
|
|
411
|
+
title: string,
|
|
412
|
+
body: string,
|
|
413
|
+
data?: Record<string, any>
|
|
414
|
+
): Promise<APNsSendResult> {
|
|
415
|
+
return this.send({
|
|
416
|
+
deviceToken,
|
|
417
|
+
title,
|
|
418
|
+
body,
|
|
419
|
+
data,
|
|
420
|
+
})
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Send a silent/background notification
|
|
425
|
+
*/
|
|
426
|
+
async sendSilent(
|
|
427
|
+
deviceToken: string,
|
|
428
|
+
data?: Record<string, any>
|
|
429
|
+
): Promise<APNsSendResult> {
|
|
430
|
+
return this.send({
|
|
431
|
+
deviceToken,
|
|
432
|
+
contentAvailable: true,
|
|
433
|
+
pushType: 'background',
|
|
434
|
+
priority: 5,
|
|
435
|
+
data,
|
|
436
|
+
})
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* Close the HTTP/2 connection
|
|
441
|
+
*/
|
|
442
|
+
close(): void {
|
|
443
|
+
if (this.client) {
|
|
444
|
+
this.client.close()
|
|
445
|
+
this.client = null
|
|
446
|
+
}
|
|
447
|
+
this.token = null
|
|
448
|
+
this.tokenGeneratedAt = 0
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
export default APNsClient
|