Skip to content

Commit

Permalink
chore: init
Browse files Browse the repository at this point in the history
  • Loading branch information
FYLSen committed Nov 15, 2024
0 parents commit 515e8c3
Show file tree
Hide file tree
Showing 2 changed files with 218 additions and 0 deletions.
43 changes: 43 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
name: Deploy Cloudflare Workers

on:
push:
branches:
- main
repository_dispatch:
workflow_dispatch:

jobs:
deploy:
runs-on: ubuntu-latest
timeout-minutes: 60

steps:
- name: Checkout
uses: actions/checkout@v4

- name: Generate wrangler.toml
run: |
cat << EOF > wrangler.toml
name = "google-analytics-endpoint"
type = "javascript"
main = "worker.js"
compatibility_date = "2024-11-10"
workers_dev = false
routes = [
{ pattern = "${{ secrets.GA_ENDPOINT_URL }}", zone_id = "${{ secrets.ZONE_ID }}" }
]
[vars]
MEASUREMENT_ID = "${{ secrets.MEASUREMENT_ID }}"
[observability]
enabled = true
head_sampling_rate = 1
[placement]
mode = "smart"
EOF
- name: Deploy to Cloudflare Workers
uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
command: deploy --var VERSION:${{ github.sha }}
175 changes: 175 additions & 0 deletions worker.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
// Constants
const GA_COLLECT_ENDPOINT = 'https://www.google-analytics.com/g/collect';
const ANALYTICS_SCRIPT_URL = 'https://unpkg.com/@minimal-analytics/ga4/dist/index.js';

// CORS headers helper
const getCorsHeaders = (origin) => ({
'Access-Control-Allow-Origin': origin,
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers': '*',
'Access-Control-Max-Age': '86400',
});

// Forward necessary headers
const getForwardHeaders = (headers, isImage) => {
const headersToForward = {
'user-agent': headers.get('user-agent'),
'referer': headers.get('referer'),
'dnt': headers.get('dnt')
};

if (!isImage) {
headersToForward['sec-ch-ua'] = headers.get('sec-ch-ua');
headersToForward['sec-ch-ua-mobile'] = headers.get('sec-ch-ua-mobile');
headersToForward['sec-ch-ua-platform'] = headers.get('sec-ch-ua-platform');
}

return Object.fromEntries(Object.entries(headersToForward).filter(([_, v]) => v != null));
};

// Handle JS file modifications
async function handleScriptProxy(request) {
const url = new URL(request.url);
const fallback = url.searchParams.get('fallback');

const response = await fetch(ANALYTICS_SCRIPT_URL);
let script = await response.text();

// Replace GA endpoint with our proxy
script = script.replace(
/https:\/\/www\.google-analytics\.com\/g\/collect/g,
url.origin + url.pathname
);

/*
((url, searchParams) => {
const fullUrl = `${url}?${new URLSearchParams(searchParams)}`;
const tryMethods = [
() => new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(true);
img.onerror = reject;
img.src = fullUrl;
}),
() => fetch(fullUrl, {
method: 'POST',
body: JSON.stringify(searchParams),
keepalive: true
}).then(res => res.ok ? Promise.resolve(true) : Promise.reject()),
() => navigator.sendBeacon(fullUrl, new FormData()) ? Promise.resolve(true) : Promise.reject()
];
tryMethods.reduce((p, method) => p.catch(() => method()), Promise.reject());
})(url,searchParams)
*/

if (fallback) script = script.replace(/navigator\.sendBeacon\(`\${([^}]+)}\?\${([^}]+)}`\)/g, (_, urlParam, pParam) =>
`((url,searchParams)=>{const fullUrl=\`\${url}?\${new URLSearchParams(searchParams)}\`;const tryMethods=[()=>new Promise((resolve,reject)=>{const img=new Image();img.onload=()=>resolve(true);img.onerror=reject;img.src=fullUrl}),()=>fetch(fullUrl,{method:'POST',body:JSON.stringify(searchParams),keepalive:!0}).then(res=>res.ok?Promise.resolve(true):Promise.reject()),()=>navigator.sendBeacon(fullUrl,new FormData())?Promise.resolve(true):Promise.reject()];tryMethods.reduce((p,method)=>p.catch(()=>method()),Promise.reject())})(${urlParam},${pParam})`);

return new Response(script, {
headers: {
'Content-Type': 'application/javascript',
'Cache-Control': 'public, max-age=3600',
...getCorsHeaders(request.headers.get('Origin')),
},
});
}

// Handle GA4 data collection
async function handleGA4Collection(request, env, queryParams) {
const url = new URL(GA_COLLECT_ENDPOINT);
// Validate measurement ID
if (!queryParams.tid || queryParams.tid !== env.MEASUREMENT_ID) {
throw new Error('Invalid measurement ID');
}

// Add query parameters
Object.entries(queryParams).forEach(([key, value]) => {
url.searchParams.set(key, value);
});

const response = await fetch(url.toString(), {
method: 'POST',
headers: {
...getForwardHeaders(request.headers, request.headers.get('accept')?.includes('image')),
'Content-Type': 'application/json'
}
});

if (!response.ok) {
throw new Error(`GA4 responded with ${response.status}`);
}

return response;
}

// Main handler
async function handleRequest(request, env) {
try {
const url = new URL(request.url);
const origin = request.headers.get('Origin');

// Handle script proxy requests
if (url.searchParams.has('fallback')) {
return handleScriptProxy(request);
}

// Handle CORS preflight
if (request.method === 'OPTIONS') {
return new Response(null, { headers: getCorsHeaders(origin) });
}

// Only allow GET and POST
if (!['GET', 'POST'].includes(request.method)) {
throw new Error('Method not allowed');
}

// Get query parameters
const queryParams = Object.fromEntries(url.searchParams);

// Handle GA4 collection
const gaResponse = await handleGA4Collection(request, env, queryParams);

if (request.method === 'GET' && gaResponse.ok) {
const imageBase64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/wcAAwAB/gn/mkQAAAAASUVORK5CYII=';
const imageData = Uint8Array.from(atob(imageBase64), c => c.charCodeAt(0));
return new Response(imageData, {
status: 200,
headers: {
'Content-Type': 'image/png',
'Cache-Control': 'no-store',
}
})
}

return new Response(await gaResponse.text(), {
status: gaResponse.status,
headers: {
...getCorsHeaders(origin),
'Content-Type': gaResponse.headers.get('content-type') || 'text/plain',
'Cache-Control': gaResponse.headers.get('cache-control') || 'no-store'
}
});

} catch (error) {
console.error('GA4 proxy error:', error);

return new Response(
JSON.stringify({
error: error.message || 'Internal Server Error',
timestamp: new Date().toISOString()
}), {
status: error.message === 'Invalid measurement ID' ? 403 : 500,
headers: {
...getCorsHeaders(request.headers.get('Origin')),
'Content-Type': 'application/json',
}
}
);
}
}

export default {
fetch: handleRequest,
};

0 comments on commit 515e8c3

Please sign in to comment.