Skip to content

Instantly share code, notes, and snippets.

@Cyberistic
Created August 19, 2025 05:30
Show Gist options
  • Select an option

  • Save Cyberistic/90c232a69df8080c42ea2a62ae58990b to your computer and use it in GitHub Desktop.

Select an option

Save Cyberistic/90c232a69df8080c42ea2a62ae58990b to your computer and use it in GitHub Desktop.
Expo Notifications CloudFlare Worker Relay
/**
* 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
}
}
);
}
};
// 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)
// 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
});
// 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