78 lines
2.6 KiB
JavaScript
78 lines
2.6 KiB
JavaScript
import crypto from 'node:crypto'
|
|
|
|
const sha256 = (value) => crypto.createHash('sha256').update(value).digest('hex')
|
|
|
|
const cleanBaseUrl = (value) => {
|
|
const url = new URL(String(value || '').trim())
|
|
if (url.protocol !== 'https:' && url.hostname !== '127.0.0.1' && url.hostname !== 'localhost') {
|
|
throw new Error('通知中心地址必须使用 HTTPS')
|
|
}
|
|
if (url.username || url.password || url.search || url.hash) {
|
|
throw new Error('通知中心地址不能包含凭据、查询参数或片段')
|
|
}
|
|
return url.toString().replace(/\/$/, '')
|
|
}
|
|
|
|
export class YouYiDianNotificationClient {
|
|
constructor({ baseUrl, projectCode, projectSecret, fetchImpl = fetch }) {
|
|
if (!/^[a-z0-9_-]{2,40}$/.test(String(projectCode || ''))) {
|
|
throw new Error('projectCode 格式不正确')
|
|
}
|
|
if (String(projectSecret || '').length < 24) {
|
|
throw new Error('projectSecret 未配置或长度不足')
|
|
}
|
|
this.baseUrl = cleanBaseUrl(baseUrl)
|
|
this.projectCode = projectCode
|
|
this.projectSecret = projectSecret
|
|
this.fetchImpl = fetchImpl
|
|
}
|
|
|
|
createHeaders({ method, path, body = '' }) {
|
|
const timestamp = String(Math.floor(Date.now() / 1000))
|
|
const nonce = crypto.randomBytes(18).toString('base64url')
|
|
const canonical = [timestamp, nonce, method.toUpperCase(), path, sha256(body)].join('\n')
|
|
const signature = crypto.createHmac('sha256', this.projectSecret).update(canonical).digest('hex')
|
|
return {
|
|
'content-type': 'application/json',
|
|
'x-yyd-project-code': this.projectCode,
|
|
'x-yyd-timestamp': timestamp,
|
|
'x-yyd-nonce': nonce,
|
|
'x-yyd-signature': signature,
|
|
}
|
|
}
|
|
|
|
async request({ method, path, body }) {
|
|
const rawBody = body === undefined ? '' : JSON.stringify(body)
|
|
const response = await this.fetchImpl(`${this.baseUrl}${path}`, {
|
|
method,
|
|
headers: this.createHeaders({ method, path, body: rawBody }),
|
|
...(rawBody ? { body: rawBody } : {}),
|
|
})
|
|
const payload = await response.json().catch(() => ({ error: 'INVALID_JSON_RESPONSE' }))
|
|
if (!response.ok) {
|
|
const error = new Error(payload.message || payload.error || `通知中心返回 HTTP ${response.status}`)
|
|
error.status = response.status
|
|
error.code = payload.error
|
|
error.payload = payload
|
|
throw error
|
|
}
|
|
return payload
|
|
}
|
|
|
|
send(input) {
|
|
return this.request({
|
|
method: 'POST',
|
|
path: '/v1/notifications',
|
|
body: input,
|
|
})
|
|
}
|
|
|
|
getDelivery(id) {
|
|
if (!id) throw new Error('投递 id 不能为空')
|
|
return this.request({
|
|
method: 'GET',
|
|
path: `/v1/notifications/${encodeURIComponent(id)}`,
|
|
})
|
|
}
|
|
}
|