Created
August 19, 2025 05:30
-
-
Save Cyberistic/90c232a69df8080c42ea2a62ae58990b to your computer and use it in GitHub Desktop.
Expo Notifications CloudFlare Worker Relay
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /** | |
| * Cloudflare Worker to relay push notifications to Expo | |
| * Acts as a proxy between our server and https://exp.host/--/api/v2/push/send | |
| */ | |
| // Make sure to generate an API_KEY and store it in your worker secrets | |
| const EXPO_PUSH_URL = "https://exp.host/--/api/v2/push/send"; | |
| const EXPO_PUSH_TOKEN_URL = "https://exp.host/--/api/v2/push/getExpoPushToken"; | |
| // CORS headers for preflight requests | |
| const corsHeaders = { | |
| "Access-Control-Allow-Origin": "*", | |
| "Access-Control-Allow-Methods": "POST, OPTIONS", | |
| "Access-Control-Allow-Headers": | |
| "Content-Type, Authorization, Accept, Accept-Encoding, Accept-Language", | |
| "Access-Control-Max-Age": "86400" | |
| }; | |
| /** | |
| * Handle CORS preflight requests | |
| */ | |
| function handleOptions() { | |
| return new Response(null, { | |
| status: 204, | |
| headers: corsHeaders | |
| }); | |
| } | |
| /** | |
| * Authenticate request using API key | |
| */ | |
| function authenticateRequest(request, env) { | |
| const authHeader = request.headers.get("Authorization"); | |
| const apiKeyHeader = request.headers.get("X-API-Key"); | |
| if (env.API_KEY) { | |
| const providedKey = authHeader?.replace("Bearer ", "") || apiKeyHeader; | |
| if (!providedKey) { | |
| return { | |
| success: false, | |
| message: | |
| "API key required. Provide via Authorization: Bearer <key> or X-API-Key header" | |
| }; | |
| } | |
| if (providedKey !== env.API_KEY) { | |
| return { | |
| success: false, | |
| message: "Invalid API key" | |
| }; | |
| } | |
| return { success: true }; | |
| } | |
| console.warn("No authentication configured!"); | |
| return { success: false }; | |
| } | |
| /** | |
| * Validate the push notification request body | |
| */ | |
| function validatePushRequest(body) { | |
| if (!body) { | |
| return { valid: false, error: "Request body is required" }; | |
| } | |
| if (!body.to) { | |
| return { valid: false, error: 'Missing "to" field with push tokens' }; | |
| } | |
| if (!Array.isArray(body.to) && typeof body.to !== "string") { | |
| return { | |
| valid: false, | |
| error: '"to" field must be a string or array of push tokens' | |
| }; | |
| } | |
| if (!body.title && !body.body) { | |
| return { valid: false, error: 'At least "title" or "body" is required' }; | |
| } | |
| return { valid: true }; | |
| } | |
| /** | |
| * Main push notification relay handler | |
| * Handles both a single object and an array of objects | |
| */ | |
| /** | |
| * Main push notification relay handler | |
| */ | |
| async function handleRequest(request, body, env) { | |
| // ... (unchanged validation and authentication code) | |
| try { | |
| const authResult = authenticateRequest(request, env); | |
| if (!authResult.success) { | |
| return new Response( | |
| JSON.stringify({ | |
| success: false, | |
| error: "Authentication failed", | |
| message: authResult.message | |
| }), | |
| { | |
| status: 401, | |
| headers: { | |
| "Content-Type": "application/json", | |
| ...corsHeaders | |
| } | |
| } | |
| ); | |
| } | |
| if (Array.isArray(body)) { | |
| for (const item of body) { | |
| const validation = validatePushRequest(item); | |
| if (!validation.valid) { | |
| return new Response( | |
| JSON.stringify({ | |
| success: false, | |
| error: `Invalid item in array: ${validation.error}` | |
| }), | |
| { | |
| status: 400, | |
| headers: { "Content-Type": "application/json", ...corsHeaders } | |
| } | |
| ); | |
| } | |
| } | |
| } else { | |
| const validation = validatePushRequest(body); | |
| if (!validation.valid) { | |
| return new Response( | |
| JSON.stringify({ success: false, error: validation.error }), | |
| { | |
| status: 400, | |
| headers: { "Content-Type": "application/json", ...corsHeaders } | |
| } | |
| ); | |
| } | |
| } | |
| const expoHeaders = { | |
| "Content-Type": "application/json", | |
| Accept: "application/json", | |
| "Accept-Encoding": "gzip, deflate", | |
| "User-Agent": "AlNabaa-CloudflareRelay/1.0" | |
| }; | |
| const expoResponse = await fetch(EXPO_PUSH_URL, { | |
| method: "POST", | |
| headers: expoHeaders, | |
| body: JSON.stringify(body) | |
| }); | |
| const contentType = expoResponse.headers.get("Content-Type") || ""; | |
| if (contentType.includes("application/json")) { | |
| const expoData = await expoResponse.json(); | |
| // **FIX**: Wrap the data in a `data` field to match the Go struct | |
| const formattedResponse = { | |
| data: expoResponse.ok && expoData.data ? expoData.data : null, | |
| errors: expoData.errors || null | |
| // Include other fields if needed, like errors | |
| }; | |
| return new Response(JSON.stringify(formattedResponse), { | |
| status: expoResponse.status, | |
| headers: { | |
| "Content-Type": "application/json", | |
| ...corsHeaders | |
| } | |
| }); | |
| } else { | |
| const responseText = await expoResponse.text(); | |
| console.error("Expo API returned non-JSON response:", responseText); | |
| const errorData = { | |
| success: false, | |
| error: "Non-JSON response from Expo API", | |
| details: responseText | |
| }; | |
| return new Response(JSON.stringify(errorData), { | |
| status: expoResponse.status, | |
| headers: { | |
| "Content-Type": "application/json", | |
| ...corsHeaders | |
| } | |
| }); | |
| } | |
| } catch (error) { | |
| console.error("Relay error:", error); | |
| const isNetworkError = | |
| error.name === "TypeError" || | |
| error.message.includes("fetch") || | |
| error.message.includes("network") || | |
| error.message.includes("timeout"); | |
| return new Response( | |
| JSON.stringify({ | |
| success: false, | |
| error: isNetworkError | |
| ? "Network error connecting to Expo service" | |
| : "Internal relay error", | |
| details: error.message, | |
| timestamp: new Date().toISOString() | |
| }), | |
| { | |
| status: isNetworkError ? 502 : 500, | |
| headers: { | |
| "Content-Type": "application/json", | |
| ...corsHeaders | |
| } | |
| } | |
| ); | |
| } | |
| } | |
| /** | |
| * Handle requests to get Expo push tokens (relay to exp.host) | |
| */ | |
| async function handlePushTokenRequest(request, body, env) { | |
| console.log("=== PUSH TOKEN REQUEST HANDLER ==="); | |
| const authResult = authenticateRequest(request, env); | |
| if (!authResult.success) { | |
| console.log("❌ Authentication failed:", authResult.message); | |
| return new Response( | |
| JSON.stringify({ | |
| success: false, | |
| error: authResult.message, | |
| timestamp: new Date().toISOString() | |
| }), | |
| { | |
| status: 401, | |
| headers: { | |
| "Content-Type": "application/json", | |
| ...corsHeaders | |
| } | |
| } | |
| ); | |
| } | |
| console.log("✅ Authentication successful"); | |
| console.log("📦 Request body:", JSON.stringify(body, null, 2)); | |
| try { | |
| console.log("🔄 Forwarding request to:", EXPO_PUSH_TOKEN_URL); | |
| const forwardedRequest = new Request(EXPO_PUSH_TOKEN_URL, { | |
| method: request.method, | |
| headers: { | |
| "Content-Type": "application/json", | |
| Accept: "application/json", | |
| "User-Agent": "alnabaa-cloudflare-worker" | |
| }, | |
| body: JSON.stringify(body) | |
| }); | |
| console.log( | |
| "📤 Forwarded request headers:", | |
| Object.fromEntries(forwardedRequest.headers.entries()) | |
| ); | |
| const response = await fetch(forwardedRequest); | |
| console.log("📥 Expo API response status:", response.status); | |
| console.log( | |
| "📥 Expo API response headers:", | |
| Object.fromEntries(response.headers.entries()) | |
| ); | |
| const responseBody = await response.text(); | |
| console.log("📥 Expo API response body:", responseBody); | |
| return new Response(responseBody, { | |
| status: response.status, | |
| headers: { | |
| "Content-Type": | |
| response.headers.get("Content-Type") || "application/json", | |
| ...corsHeaders | |
| } | |
| }); | |
| } catch (error) { | |
| console.error("❌ Error in push token relay:", error); | |
| return new Response( | |
| JSON.stringify({ | |
| success: false, | |
| error: "Internal server error in push token relay", | |
| details: error.message, | |
| timestamp: new Date().toISOString() | |
| }), | |
| { | |
| status: 500, | |
| headers: { | |
| "Content-Type": "application/json", | |
| ...corsHeaders | |
| } | |
| } | |
| ); | |
| } | |
| } | |
| /** | |
| * Health check endpoint | |
| */ | |
| function handleHealthCheck() { | |
| return new Response( | |
| JSON.stringify({ | |
| status: "healthy", | |
| service: "alnabaa-expo-relay", | |
| timestamp: new Date().toISOString(), | |
| version: "1.0.0" | |
| }), | |
| { | |
| status: 200, | |
| headers: { | |
| "Content-Type": "application/json", | |
| ...corsHeaders | |
| } | |
| } | |
| ); | |
| } | |
| /** | |
| * Main fetch event handler | |
| */ | |
| export default { | |
| async fetch(request, env, ctx) { | |
| const url = new URL(request.url); | |
| if (request.method === "OPTIONS") { | |
| return handleOptions(); | |
| } | |
| if (url.pathname === "/health" || url.pathname === "/") { | |
| return handleHealthCheck(); | |
| } | |
| if (request.method !== "POST") { | |
| return new Response( | |
| JSON.stringify({ | |
| success: false, | |
| error: | |
| "Method not allowed. Only POST requests are supported on this path." | |
| }), | |
| { | |
| status: 405, | |
| headers: { | |
| "Content-Type": "application/json", | |
| ...corsHeaders | |
| } | |
| } | |
| ); | |
| } | |
| let body = {}; | |
| const contentType = request.headers.get("content-type"); | |
| if (contentType && contentType.includes("application/json")) { | |
| try { | |
| body = await request.json(); | |
| } catch (e) { | |
| return new Response( | |
| JSON.stringify({ | |
| success: false, | |
| error: "Invalid JSON in request body", | |
| details: e.message | |
| }), | |
| { | |
| status: 400, | |
| headers: { | |
| "Content-Type": "application/json", | |
| ...corsHeaders | |
| } | |
| } | |
| ); | |
| } | |
| } else { | |
| console.warn("Request has no JSON body or invalid content type"); | |
| } | |
| if (url.pathname === "/relay/send" || url.pathname === "/push/send") { | |
| return handleRequest(request, body, env); | |
| } | |
| if (url.pathname === "/relay/token" || url.pathname === "/push/token") { | |
| return handlePushTokenRequest(request, body, env); | |
| } | |
| return new Response( | |
| JSON.stringify({ | |
| success: false, | |
| error: | |
| "Not found. Use /relay/send, /push/send, /relay/token, or /push/token.", | |
| requestedPath: url.pathname | |
| }), | |
| { | |
| status: 404, | |
| headers: { | |
| "Content-Type": "application/json", | |
| ...corsHeaders | |
| } | |
| } | |
| ); | |
| } | |
| }; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // I had to hack around to get it working with the go notifications sdk | |
| // notice how the v is on its own line? this is because the sdk appends /push/send to your req | |
| // or the default expo extension if empty | |
| // this is the library im using | |
| // https://github.com/wagon-official/expo-notifications-sdk-golang | |
| config := &expo.ClientConfig{ | |
| Host: "https://worker.your-domain.workers.de", | |
| APIURL: "v", | |
| AccessToken: apiKey, | |
| } | |
| client := expo.NewPushClient(config) | |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // In your expo app, make sure to change the baseUrl and url | |
| const token = await Notifications.getExpoPushTokenAsync({ | |
| projectId, | |
| baseUrl: Config.PUSH_TOKEN_ENDPOINT, // Use our backend API endpoint | |
| url: Config.PUSH_TOKEN_ENDPOINT // Use our backend API endpoint | |
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // backend example in NextJs | |
| import { NextRequest, NextResponse } from "next/server" | |
| export async function POST(request: NextRequest) { | |
| try { | |
| const body = await request.json() | |
| console.log("Push token request body:", body) | |
| // Forward the request to our Cloudflare Worker | |
| const workerUrl = process.env.CLOUDFLARE_WORKER_URL | |
| const apiKey = process.env.CLOUDFLARE_WORKER_API_KEY | |
| if (!apiKey) { | |
| console.error("CLOUDFLARE_WORKER_API_KEY environment variable not set") | |
| return NextResponse.json( | |
| { | |
| success: false, | |
| error: "Server configuration error" | |
| }, | |
| { status: 500 } | |
| ) | |
| } | |
| const response = await fetch(`${workerUrl}/relay/token`, { | |
| method: "POST", | |
| headers: { | |
| "Content-Type": "application/json", | |
| "Authorization": `Bearer ${apiKey}`, | |
| }, | |
| body: JSON.stringify(body) | |
| }) | |
| const responseData = await response.text() | |
| console.log("Cloudflare Worker response:", { | |
| status: response.status, | |
| data: responseData | |
| }) | |
| // Parse the response as JSON if possible | |
| let parsedData | |
| try { | |
| parsedData = JSON.parse(responseData) | |
| } catch { | |
| parsedData = { data: responseData } | |
| } | |
| return NextResponse.json(parsedData, { | |
| status: response.status, | |
| headers: { | |
| 'Access-Control-Allow-Origin': '*', | |
| 'Access-Control-Allow-Methods': 'POST, OPTIONS', | |
| 'Access-Control-Allow-Headers': 'Content-Type', | |
| } | |
| }) | |
| } catch (error) { | |
| console.error("Error in push token API:", error) | |
| return NextResponse.json( | |
| { | |
| success: false, | |
| error: "Failed to get push token", | |
| details: error instanceof Error ? error.message : "Unknown error" | |
| }, | |
| { status: 500 } | |
| ) | |
| } | |
| } | |
| export async function OPTIONS() { | |
| return new NextResponse(null, { | |
| status: 200, | |
| headers: { | |
| 'Access-Control-Allow-Origin': '*', | |
| 'Access-Control-Allow-Methods': 'POST, OPTIONS', | |
| 'Access-Control-Allow-Headers': 'Content-Type', | |
| }, | |
| }) | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment