- Published on
使用 Cloudflare Workers 托管 Firebase Cloud Messaging 服务
- Authors

- Name
- Monster Cone
在中国大陆,由于无法直接访问 Firebase Cloud Messaging(FCM)服务,很多项目需要通过境外服务器进行中转。这类服务器访问速度较慢,配置也低,性价比不高。不过,最近我发现 Cloudflare Workers 是一个非常适合解决这一问题的方案,能够高效托管 FCM 服务。接下来,我将详细介绍如何使用 Cloudflare Workers 和 Hono 框架来实现这一功能。
1. 为什么选择 Hono?
Hono 是一个轻量级的框架,专为 JavaScript 环境设计,并且与 Cloudflare Workers 深度集成,使得在服务器上部署服务变得极其简单。我们可以通过以下命令快速搭建一个 Cloudflare Workers 项目:
npm create hono@latest my-app
创建完成后,进入项目目录并启动开发环境:
cd my-app
npm install
npm run dev
这时,访问 http://localhost:8787 即可看到接口返回的数据。
2. 使用 Oauth 2.0 获取 Access Token
为了认证,我们使用服务账号和私钥文件获取 Firebase 的 access_token。你可以通过 Firebase 控制台生成服务账号私钥文件,并将其转化为字符串存储到 .dev.vars 文件中。
接下来,我们实现 fetchAccessToken 方法来发送请求并获取 access_token:
interface GoogleAuthResponse {
access_token: string
expires_in: number
token_type: string
}
const fetchAccessToken = async (FIREBASE_ADMINSDK: string) => {
const sa = JSON.parse(FIREBASE_ADMINSDK)
const now = Math.floor(Date.now() / 1000)
// 1️⃣ 构造 JWT header + payload
const header = { alg: 'RS256', typ: 'JWT', kid: sa.private_key_id }
const payload = {
iss: sa.client_email,
scope: 'https://www.googleapis.com/auth/cloud-platform',
aud: 'https://oauth2.googleapis.com/token',
iat: now,
exp: now + 3600,
}
// 2️⃣ Base64URL 编码
const base64url = (obj: object) =>
btoa(JSON.stringify(obj)).replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_')
const unsignedJWT = `${base64url(header)}.${base64url(payload)}`
// 3️⃣ 导入私钥
const pkcs8 = sa.private_key
.replace('-----BEGIN PRIVATE KEY-----', '')
.replace('-----END PRIVATE KEY-----', '')
.replace(/\n/g, '')
const keyData = Uint8Array.from(atob(pkcs8), (c) => c.charCodeAt(0))
const privateKey = await crypto.subtle.importKey(
'pkcs8',
keyData,
{ name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' },
false,
['sign']
)
const signature = await crypto.subtle.sign(
'RSASSA-PKCS1-v1_5',
privateKey,
new TextEncoder().encode(unsignedJWT)
)
const signedJWT =
unsignedJWT +
'.' +
btoa(String.fromCharCode(...new Uint8Array(signature)))
.replace(/=/g, '')
.replace(/\+/g, '-')
.replace(/\//g, '_')
// 5️⃣ 请求 Google token
try {
const res = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
assertion: signedJWT,
}),
})
const data: GoogleAuthResponse = await res.json()
return data.access_token
} catch (e) {
return ''
}
}
使用私钥文件通过转码、哈希算法进行签名生成JWT,使用fetch携带JWT发送请求获取AccessToken,fetch是内置请求方法,同web客户端一样用法。
认证接口响应如下:
- access_token: 授权令牌,携带该令牌发送请求
- token_type: Token类型,这里是"Bearer"
- expires_in: 有效期(秒),3600秒
3. 使用 Cloudflare KV 缓存 Access Token
Cloudflare Workers 提供的 KV 存储功能让我们能够缓存 access token,在 token 有效期内避免重复请求。首先,创建一个 KV 命名空间:
npx wrangler kv namespace create <BINDING_NAME>
在 wrangler.jsonc 中进行配置,并通过 Hono 框架的环境变量将其传递给应用:
// wrangler.jsonc
...
"kv_namespaces": [
{
"binding": <BINDING_NAME>,
"id": "xxxxxxxxxxxxxxxxxxxxxxxx"
}
],
...
type Bindings = {
FIREBASE_ADMINSDK: string
BINDING_NAME: KVNamespace
}
const app = new Hono<{ Bindings: Bindings }>()
安装@cloudflare/workers-types库并配置tsconfig.json消除KVNamespace TypeScript警告。
npm i --dev @cloudflare/workers-types
// tsconfig.json
{
...
"lib": [
"ESNext",
"WebWorker"
],
"types": [
"@cloudflare/workers-types"
]
}
我们创建一个getAccessToken方法,先查看KV中的accessToken,如果没有或者过期,则请求新的,如果没有过期则直接返回。
const getAccessToken = async (env: Bindings) => {
let accessToken: string | null = await env.BINDING_NAME.get('accessToken')
if (!accessToken) {
accessToken = await fetchAccessToken(env.FIREBASE_ADMINSDK)
await env.BINDING_NAME.put('accessToken', accessToken, { expirationTtl: 3600 })
}
return accessToken
}
KV支持TTL,但并不会清除,而是值变为null。因为读取env变量依赖上下文,所以我们需要在调用方法时传递env。
4. 使用REST V1 API实现send接口
通过 POST 请求 FCM 的发送接口,可以实现向指定设备推送消息。我们使用 zod 对请求数据进行验证,并发送请求:
const SEND_URL = 'https://fcm.googleapis.com/v1/projects/monster-push/messages:send'
const fcmMessageSchema = z.object({
message: z.object({
notification: z.object({
title: z.string(),
body: z.string(),
}),
webpush: z.object({
fcm_options: z.object({
link: z.string(), // 验证 link 字段是一个有效的 URL
}),
notification: z.object({
icon: z.string(), // 验证 icon 字段是一个有效的 URL
}),
}),
data: z.object({
messageId: z.string(),
}),
}),
tokens: z.array(z.string()), // tokens 是一个字符串数组
})
app.post(
'/send',
validator('json', (value, c) => {
const parsed = fcmMessageSchema.safeParse(value)
if (!parsed.success) {
return c.text(parsed.error.message, 400)
}
return parsed.data
}),
async (c) => {
const data = c.req.valid('json')
const accessToken = await getAccessToken(c.env)
if (!accessToken) return c.text('Invalid AccessToken')
const { tokens, message } = data
if (!tokens.length) return c.json([])
try {
const pendingList = Promise.all(
tokens.map(async (token: string) => {
const res = await fetch(SEND_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer ' + accessToken,
},
body: JSON.stringify({
message: {
...message,
token,
},
}),
})
return await res.json().catch((e) => JSON.stringify(e))
})
)
const result = await pendingList
return c.json(result)
} catch (e) {
return c.text(JSON.stringify(e))
}
}
)
因为我们的服务部署在Cloudflare Workers,所以有很多的局限性,例如无法直接使用Google SDK进行认证和多设备推送。
V1 API目前只暴露了send接口,所以这里仅实现了send接口。因为send每次只能向一个设备推送通知,所以如果需要同时推送多个设备,则要多次请求。但Cloudflare Workers 和 FCM 都支持HTTP2,即使循环并发请求响应速度也非常快。
5. 完整实现
// index.ts
import { Hono } from 'hono'
import { validator } from 'hono/validator'
import { z } from 'zod'
type Bindings = {
FIREBASE_ADMINSDK: string
XXX: KVNamespace
}
interface GoogleAuthResponse {
access_token: string
expires_in: number
token_type: string
}
const app = new Hono<{ Bindings: Bindings }>()
const SEND_URL = 'https://fcm.googleapis.com/v1/projects/monster-push/messages:send'
const fetchAccessToken = async (FIREBASE_ADMINSDK: string) => {
const sa = JSON.parse(FIREBASE_ADMINSDK)
const now = Math.floor(Date.now() / 1000)
// 1️⃣ 构造 JWT header + payload
const header = { alg: 'RS256', typ: 'JWT', kid: sa.private_key_id }
const payload = {
iss: sa.client_email,
scope: 'https://www.googleapis.com/auth/cloud-platform',
aud: 'https://oauth2.googleapis.com/token',
iat: now,
exp: now + 3600,
}
// 2️⃣ Base64URL 编码
const base64url = (obj: object) =>
btoa(JSON.stringify(obj)).replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_')
const unsignedJWT = `${base64url(header)}.${base64url(payload)}`
// 3️⃣ 导入私钥
const pkcs8 = sa.private_key
.replace('-----BEGIN PRIVATE KEY-----', '')
.replace('-----END PRIVATE KEY-----', '')
.replace(/\n/g, '')
const keyData = Uint8Array.from(atob(pkcs8), (c) => c.charCodeAt(0))
const privateKey = await crypto.subtle.importKey(
'pkcs8',
keyData,
{ name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' },
false,
['sign']
)
const signature = await crypto.subtle.sign(
'RSASSA-PKCS1-v1_5',
privateKey,
new TextEncoder().encode(unsignedJWT)
)
const signedJWT =
unsignedJWT +
'.' +
btoa(String.fromCharCode(...new Uint8Array(signature)))
.replace(/=/g, '')
.replace(/\+/g, '-')
.replace(/\//g, '_')
// 5️⃣ 请求 Google token
try {
const res = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
assertion: signedJWT,
}),
})
const data: GoogleAuthResponse = await res.json()
return data.access_token
} catch (e) {
return ''
}
}
const getAccessToken = async (env: Bindings) => {
let accessToken: string | null = await env.XXX.get('accessToken')
if (!accessToken) {
accessToken = await fetchAccessToken(env.FIREBASE_ADMINSDK)
await env.XXX.put('accessToken', accessToken, { expirationTtl: 3600 })
}
return accessToken
}
const fcmMessageSchema = z.object({
message: z.object({
notification: z.object({
title: z.string(),
body: z.string(),
}),
webpush: z.object({
fcm_options: z.object({
link: z.string(), // 验证 link 字段是一个有效的 URL
}),
notification: z.object({
icon: z.string(), // 验证 icon 字段是一个有效的 URL
}),
}),
data: z.object({
messageId: z.string(),
}),
}),
tokens: z.array(z.string()), // tokens 是一个字符串数组
})
app.post(
'/send',
validator('json', (value, c) => {
const parsed = fcmMessageSchema.safeParse(value)
if (!parsed.success) {
return c.text(parsed.error.message, 400)
}
return parsed.data
}),
async (c) => {
const data = c.req.valid('json')
const accessToken = await getAccessToken(c.env)
if (!accessToken) return c.text('Invalid AccessToken')
const { tokens, message } = data
if (!tokens.length) return c.json([])
try {
const pendingList = Promise.all(
tokens.map(async (token: string) => {
const res = await fetch(SEND_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer ' + accessToken,
},
body: JSON.stringify({
message: {
...message,
token,
},
}),
})
return await res.json().catch((e) => JSON.stringify(e))
})
)
const result = await pendingList
return c.json(result)
} catch (e) {
return c.text(JSON.stringify(e))
}
}
)
export default app
6. 部署到Cloudflare Workers
在完成代码编写后,可以通过以下命令将项目部署到 Cloudflare Workers:
npm run deploy
确保在 Cloudflare Workers 控制台中配置好环境变量,这样应用就可以正常运行了。
7. 自定义路由、限制访问、认证、开启日志
自定义路由
需要使用Cloudflare DNS解析,在Workers详情设置里添加域名路由,并且在DNS解析里配置域名。就可以使用自定义域名访问Workers了。
访问限制和认证
如果你和我一样仅仅只通过后端服务访问该接口,通过在防火墙安全规则里限制域名和源IP即可。
如果需要认证访问,可以在请求时携带 token,然后在Workers里解析token进行认证授权访问。
开启日志
{
...
"observability": {
"enabled": false,
"logs": {
"enabled": true,
"head_sampling_rate": 1,
"invocation_logs": true
}
}
}
直接在wrangler.jsonc配置然后部署,或者在后端开启也行。
总结
通过使用 Cloudflare Workers 和 Hono 框架,我们能够非常方便地在边缘计算环境中托管 Firebase Cloud Messaging 服务。通过 OAuth 认证、KV 缓存和简洁的接口设计,我们可以实现高效、低成本的推送服务。