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:
Yun Chan 2026-09-16 23:24:44 +09:00
parent 5aa268970a
commit bb0e54dcee
13 changed files with 1412 additions and 41 deletions

View file

@ -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> },
))
}
}