feat(server): deliver push without a Firebase project
Every notification depended on Firebase Cloud Messaging, so a missing Firebase project, which is the current state, meant no notification could be delivered on any platform. Web Push and token-based Apple Push are now first class transports alongside FCM, chosen per registered device, and a scheduled Cloudflare Worker drain retries an outbox so a provider outage delays rather than drops a message.
This commit is contained in:
parent
5aa268970a
commit
bb0e54dcee
13 changed files with 1412 additions and 41 deletions
|
|
@ -8,6 +8,7 @@ import {
|
|||
isServiceRoleAuthorization,
|
||||
isUuid,
|
||||
parsePushRequest,
|
||||
providerIsSupported,
|
||||
PushContractError,
|
||||
readFcmConfig,
|
||||
sendFcmMessage,
|
||||
|
|
@ -15,6 +16,8 @@ import {
|
|||
type PushProvider,
|
||||
type TeamInviteNotificationContext,
|
||||
} from '../_shared/push-contract.ts'
|
||||
import { readApnsConfig, sendApnsMessage } from '../_shared/apns.ts'
|
||||
import { readWebPushConfig, sendWebPushMessage } from '../_shared/webpush.ts'
|
||||
import { createServiceRoleClient } from '../_shared/quota.ts'
|
||||
|
||||
const JSON_HEADERS = {
|
||||
|
|
@ -435,8 +438,8 @@ async function finalizeDispatch(
|
|||
return normalizeDispatchSummary(data, attemptId)
|
||||
}
|
||||
|
||||
function retryableFcmError(error: unknown): { code: string; status: number } {
|
||||
if (!(error instanceof PushContractError)) return { code: 'fcm_send_failed', status: 502 }
|
||||
function retryablePushError(error: unknown, fallbackCode: string): { code: string; status: number } {
|
||||
if (!(error instanceof PushContractError)) return { code: fallbackCode, status: 502 }
|
||||
const permanent = new Set(['invalid_push_payload'])
|
||||
return {
|
||||
code: permanent.has(error.code) ? 'push_payload_invalid' : error.code,
|
||||
|
|
@ -478,58 +481,116 @@ async function processAttempt(input: {
|
|||
inviteContext = { teamId: invite.team_id, inviteToken: invite.token }
|
||||
}
|
||||
|
||||
const unsupported = deliveries.filter((delivery) => delivery.provider !== 'fcm')
|
||||
const unsupported = deliveries.filter((delivery) => !providerIsSupported(delivery.provider))
|
||||
await mapWithConcurrency(unsupported, SEND_CONCURRENCY, (delivery) => (
|
||||
finalizeDelivery(delivery, 'permanent_failure', 'push_provider_not_supported')
|
||||
))
|
||||
const fcmDeliveries = deliveries.filter((delivery) => delivery.provider === 'fcm')
|
||||
|
||||
let transientError: string | null = null
|
||||
let transientStatus: number | null = null
|
||||
if (fcmDeliveries.length > 0) {
|
||||
|
||||
const recordTransient = (code: string, status: number): void => {
|
||||
transientError ??= code
|
||||
transientStatus ??= status
|
||||
}
|
||||
|
||||
const deliveriesFor = (provider: PushProvider): LeasedDelivery[] => (
|
||||
deliveries.filter((delivery) => delivery.provider === provider)
|
||||
)
|
||||
|
||||
const failProvider = async (provider: PushProvider, code: string): Promise<void> => {
|
||||
await mapWithConcurrency(deliveriesFor(provider), SEND_CONCURRENCY, (delivery) => (
|
||||
finalizeDelivery(delivery, 'retryable_failure', code)
|
||||
))
|
||||
}
|
||||
|
||||
const sendFor = async (
|
||||
provider: PushProvider,
|
||||
fallbackCode: string,
|
||||
send: (registrationId: string) => Promise<unknown>,
|
||||
): Promise<void> => {
|
||||
const scoped = deliveriesFor(provider)
|
||||
if (scoped.length === 0) return
|
||||
await mapWithConcurrency(scoped, SEND_CONCURRENCY, async (delivery) => {
|
||||
try {
|
||||
await send(delivery.registrationId)
|
||||
await finalizeDelivery(delivery, 'delivered', null)
|
||||
} catch (error) {
|
||||
if (error instanceof PushContractError && error.staleRegistration) {
|
||||
await finalizeDelivery(delivery, 'stale', 'registration_stale')
|
||||
return
|
||||
}
|
||||
const normalized = retryablePushError(error, fallbackCode)
|
||||
if (normalized.code === 'push_payload_invalid') {
|
||||
await finalizeDelivery(delivery, 'permanent_failure', normalized.code)
|
||||
} else {
|
||||
recordTransient(normalized.code, normalized.status)
|
||||
await finalizeDelivery(delivery, 'retryable_failure', normalized.code)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const buildNotification = (): ReturnType<typeof buildPushNotification> => (
|
||||
buildPushNotification(input.eventType, input.resourceId, 'en', inviteContext)
|
||||
)
|
||||
|
||||
if (deliveriesFor('fcm').length > 0) {
|
||||
let config: ReturnType<typeof readFcmConfig> | null = null
|
||||
let accessToken: string | null = null
|
||||
try {
|
||||
config = readFcmConfig()
|
||||
accessToken = await getFcmAccessToken(config)
|
||||
} catch (error) {
|
||||
const normalized = retryableFcmError(error)
|
||||
transientError = normalized.code
|
||||
transientStatus = normalized.status
|
||||
await mapWithConcurrency(fcmDeliveries, SEND_CONCURRENCY, (delivery) => (
|
||||
finalizeDelivery(delivery, 'retryable_failure', normalized.code)
|
||||
const normalized = retryablePushError(error, 'fcm_send_failed')
|
||||
recordTransient(normalized.code, normalized.status)
|
||||
await failProvider('fcm', normalized.code)
|
||||
}
|
||||
if (config && accessToken) {
|
||||
await sendFor('fcm', 'fcm_send_failed', (registrationId) => sendFcmMessage(
|
||||
registrationId,
|
||||
buildNotification(),
|
||||
{
|
||||
config: config as ReturnType<typeof readFcmConfig>,
|
||||
getAccessToken: () => Promise.resolve(accessToken as string),
|
||||
},
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
if (config && accessToken) {
|
||||
await mapWithConcurrency(fcmDeliveries, SEND_CONCURRENCY, async (delivery) => {
|
||||
const notification = buildPushNotification(
|
||||
input.eventType,
|
||||
input.resourceId,
|
||||
'en',
|
||||
inviteContext,
|
||||
)
|
||||
try {
|
||||
await sendFcmMessage(delivery.registrationId, notification, {
|
||||
config,
|
||||
getAccessToken: () => Promise.resolve(accessToken),
|
||||
})
|
||||
await finalizeDelivery(delivery, 'delivered', null)
|
||||
} catch (error) {
|
||||
if (error instanceof PushContractError && error.staleRegistration) {
|
||||
await finalizeDelivery(delivery, 'stale', 'registration_stale')
|
||||
return
|
||||
}
|
||||
const normalized = retryableFcmError(error)
|
||||
if (normalized.code === 'push_payload_invalid') {
|
||||
await finalizeDelivery(delivery, 'permanent_failure', normalized.code)
|
||||
} else {
|
||||
transientError ??= normalized.code
|
||||
transientStatus ??= normalized.status
|
||||
await finalizeDelivery(delivery, 'retryable_failure', normalized.code)
|
||||
}
|
||||
}
|
||||
})
|
||||
if (deliveriesFor('webpush').length > 0) {
|
||||
let webPushConfig: ReturnType<typeof readWebPushConfig> | null = null
|
||||
try {
|
||||
webPushConfig = readWebPushConfig()
|
||||
} catch (error) {
|
||||
const normalized = retryablePushError(error, 'webpush_send_failed')
|
||||
recordTransient(normalized.code, normalized.status)
|
||||
await failProvider('webpush', normalized.code)
|
||||
}
|
||||
if (webPushConfig) {
|
||||
await sendFor('webpush', 'webpush_send_failed', (registrationId) => sendWebPushMessage(
|
||||
registrationId,
|
||||
buildNotification(),
|
||||
{ config: webPushConfig as ReturnType<typeof readWebPushConfig> },
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
if (deliveriesFor('apns').length > 0) {
|
||||
let apnsConfig: ReturnType<typeof readApnsConfig> | null = null
|
||||
try {
|
||||
apnsConfig = readApnsConfig()
|
||||
} catch (error) {
|
||||
const normalized = retryablePushError(error, 'apns_send_failed')
|
||||
recordTransient(normalized.code, normalized.status)
|
||||
await failProvider('apns', normalized.code)
|
||||
}
|
||||
if (apnsConfig) {
|
||||
await sendFor('apns', 'apns_send_failed', (registrationId) => sendApnsMessage(
|
||||
registrationId,
|
||||
buildNotification(),
|
||||
{ config: apnsConfig as ReturnType<typeof readApnsConfig> },
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue