-
-
Save vunb/51b76988aa554adf6283ecf7c60aeaa3 to your computer and use it in GitHub Desktop.
Cloudflare Workers / Segment Smart Proxy — serve data collection assets and endpoints from your own domain
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
| /** | |
| * Steps to use: | |
| * - Create CF Worker, copy and paste this in | |
| * - If you want dynamic custom config: Create CF KV namespace, link them, and reference below | |
| * - Optionally, overwrite default path prefixes for loading analytics.js (/ajs) and collecting data (/data) | |
| * (do this in code or by by setting corresponding KV entries for `script_path` and `collection_path` | |
| * - Optionally, overwrite default cookie name for the anonymous ID | |
| * (do this in code or by setting corresponding KV entries for `cookie_name` | |
| * - Optionally, overwrite default integration list path prefix (/int-list) (only required for conditional loading) | |
| * (do this in code or by setting corresponding KV entries for `int_list_path` | |
| * - Optionally, set a default write key if you just want to use one globally and want to omit it from your site code | |
| * (do this in code or by setting corresponding KV entries for `write_key` | |
| * - [REQUIRED] Update your segment snippet to load from your host + script path prefix | |
| * (eg find n.src="https://cdn.segment.com/analytics.js/v1/"+t+"/analytics.min.js" in snippet and ...) | |
| * (replace with n.src=`${location.origin}/ajs` if you have a default write key set) | |
| * (or with n.src=`${location.origin}/ajs/${t}` if not) | |
| * - Optionally, update any conditional destination loading logic to pull the list from your host + integration list path prefix | |
| * If using Segment Consent Manager or https://gist.github.com/sperand-io/4725e248a35d5005d68d810d8a8f7b29 | |
| * (eg instead of fetch(`https://cdn.segment.com/v1/projects/${writeKey}/integrations`)) | |
| * (replace with fetch(`${location.origin}/ilist/${writeKey}`) or fetch(`${location.origin}/ilist/}`) | |
| */ | |
| // START STATIC CONFIGURATION | |
| // recommendation is to not touch these, and instead override these values with workers KV | |
| // if you want to edit them in code, just change the values after the `:` | |
| const KV_NAMESPACE = ""; | |
| const STATIC_CONFIG = { | |
| COOKIE_NAME: "__anonymous_session_id", | |
| SCRIPT_PATH_PREFIX: "ajs", | |
| COLLECTION_API_PATH_PREFIX: "data", | |
| INTEGRATION_LIST_PATH_PREFIX: "ilist", | |
| REFRESH_TRIGGER: 45, | |
| DEFAULT_WRITE_KEY: "3K4xZlUgQFAa3MRdnRRKvbvDEukDCWeu", | |
| ERROR_ENDPOINT: "https://enj0zt42hq1y.x.pipedream.net" | |
| }; | |
| // END Config. Editing below this line is discouraged. | |
| /** | |
| * Attach top-level responder. | |
| */ | |
| addEventListener("fetch", event => { | |
| event.respondWith(handleErr(event)); | |
| }); | |
| /** | |
| * Top level event handler. | |
| * | |
| * Wraps our request handler in an error handler, | |
| * optionally forward errors to a logging service. | |
| * | |
| * @param {Event} event | |
| */ | |
| async function handleErr(event) { | |
| try { | |
| const res = await handleEvent(event); | |
| return res; | |
| } catch (err) { | |
| let endpoint = KV_NAMESPACE && (await KV_NAMESPACE.get("error_endpoint")); | |
| if (!endpoint) endpoint = STATIC_CONFIG["ERROR_ENDPOINT"]; | |
| if (endpoint) event.waitUntil(log(endpoint, err, event.request)); | |
| return new Response(err.message || "An error occurred!", { | |
| status: err.statusCode || 500 | |
| }); | |
| } | |
| } | |
| /** | |
| * Respond to the request | |
| * @param {Event} event | |
| */ | |
| async function handleEvent(event) { | |
| const config = KV_NAMESPACE ? await hydrateConfig(KV_NAMESPACE) : STATIC_CONFIG; | |
| const { | |
| COOKIE_NAME, | |
| SCRIPT_PATH_PREFIX, | |
| COLLECTION_API_PATH_PREFIX, | |
| INTEGRATION_LIST_PATH_PREFIX | |
| } = config; | |
| const cache = caches.default; | |
| const { request } = event; | |
| const url = new URL(request.url); | |
| const cookieData = getCookieData(request, COOKIE_NAME); | |
| if (startsWith(url, SCRIPT_PATH_PREFIX)) | |
| return await handleScript(event, cache, cookieData, config); | |
| if (startsWith(url, COLLECTION_API_PATH_PREFIX)) | |
| return await handleDataCollection(request, cookieData, config); | |
| if (startsWith(url, INTEGRATION_LIST_PATH_PREFIX)) | |
| return await handleIntegrationListing(request, config); | |
| return await fetch(request); | |
| } | |
| async function handleScript( | |
| event, | |
| cache, | |
| { anonymousId, expires }, | |
| { | |
| SCRIPT_PATH_PREFIX, | |
| DEFAULT_WRITE_KEY, | |
| COLLECTION_API_PATH_PREFIX, | |
| COOKIE_NAME, | |
| REFRESH_TRIGGER | |
| } | |
| ) { | |
| const { request } = event; | |
| const { pathname, hostname } = new URL(request.url); | |
| let [_, writeKey] = pathname.split(`/${SCRIPT_PATH_PREFIX}/`); | |
| if (!writeKey) writeKey = DEFAULT_WRITE_KEY; | |
| let response; | |
| const cached = await cache.match(request); | |
| if (cached) { | |
| response = cached; | |
| } else { | |
| const endpoint = `https://cdn.segment.com/analytics.js/v1/${writeKey}/analytics.min.js`; | |
| const originalResponse = await fetch(new Request(endpoint, request)); | |
| const newResponse = originalResponse.clone(); | |
| const analyticsjs = await originalResponse.text(); | |
| modifiedAnalyticsjs = analyticsjs.replace( | |
| /\api\.segment\.io\/v1/g, | |
| `${hostname}/${COLLECTION_API_PATH_PREFIX}` | |
| ); | |
| response = new Response(modifiedAnalyticsjs, newResponse); | |
| event.waitUntil(cache.put(request, response.clone())); | |
| } | |
| if (!anonymousId || expiresSoon(expires, REFRESH_TRIGGER)) { | |
| const oneYearFromNow = new Date(); | |
| oneYearFromNow.setFullYear(oneYearFromNow.getFullYear() + 1); | |
| response.headers.append( | |
| "Set-Cookie", | |
| createCookie(COOKIE_NAME, uuid(), oneYearFromNow) | |
| ); | |
| response.headers.append( | |
| "Set-Cookie", | |
| createCookie(`${COOKIE_NAME}_set`, oneYearFromNow.toUTCString(), oneYearFromNow) | |
| ); | |
| } | |
| return response; | |
| } | |
| async function handleDataCollection( | |
| request, | |
| { anonymousId }, | |
| { COLLECTION_API_PATH_PREFIX } | |
| ) { | |
| const originalRequest = request.clone(); | |
| const body = JSON.stringify({ | |
| ...(await request.json()), | |
| ...(anonymousId ? { anonymousId } : {}) | |
| }); | |
| const { pathname, hostname } = new URL(request.url); | |
| const correctPath = pathname.replace(COLLECTION_API_PATH_PREFIX, "v1"); | |
| const newRequest = new Request( | |
| `https://api.segment.io${correctPath}`, | |
| new Request(originalRequest, { body }) | |
| ); | |
| newRequest.headers.append("origin", `https://${hostname}`); | |
| const response = await fetch(newRequest); | |
| for (let [name, value] of response.headers.entries()) { | |
| console.log(name, value); | |
| } | |
| return response; | |
| } | |
| async function handleIntegrationListing( | |
| request, | |
| { INTEGRATION_LIST_PATH_PREFIX, DEFAULT_WRITE_KEY } | |
| ) { | |
| const { pathname } = new URL(request.url); | |
| let [_, writeKey] = pathname.split(`/${INTEGRATION_LIST_PATH_PREFIX}/`); | |
| if (!writeKey) writeKey = DEFAULT_WRITE_KEY; | |
| const endpoint = `https://cdn.segment.com/v1/projects/${writeKey}/integrations`; | |
| return await fetch(new Request(endpoint, new Request(request, { body }))); | |
| } | |
| async function hydrateConfig(ns) { | |
| const keys = [ | |
| "cookie_name", | |
| "script_path_prefix", | |
| "collection_api_path_prefix", | |
| "integration_list_path_prefix", | |
| "refresh_trigger", | |
| "default_write_key" | |
| ]; | |
| return Promise.all( | |
| keys.map(async k => { | |
| return { [k.toUpperCase()]: (await ns.get(k)) || "" }; | |
| }) | |
| ).reduce((config, { key, storedKValue }) => { | |
| if (storedKValue) { | |
| config[key] = storedKValue; | |
| } | |
| return config; | |
| }, STATIC_CONFIG); | |
| console.log(config); | |
| } | |
| function startsWith(url, prefix) { | |
| if (url.pathname.startsWith(`/${prefix}`)) return true; | |
| return false; | |
| } | |
| function expiresSoon(when, REFRESH_TRIGGER) { | |
| const soon = new Date(); | |
| soon.setDate(soon.getDate() + REFRESH_TRIGGER); | |
| if (when < soon) return true; | |
| else return false; | |
| } | |
| function createCookie(name, value, expires) { | |
| return `${encodeURIComponent(name)}=${encodeURIComponent( | |
| value | |
| )}; Expires=${expires.toUTCString()}; SameSite=Strict; Secure; HttpOnly`; | |
| } | |
| function uuid() { | |
| const bytes = crypto.getRandomValues(new Uint8Array(16)); | |
| bytes[6] = (bytes[6] & 0x0f) | 0x40; | |
| bytes[8] = (bytes[8] & 0xbf) | 0x80; | |
| const chars = [...bytes].map(byte => byte.toString(16)); | |
| const insertionPoints = [4, 6, 8, 10]; | |
| return chars.reduce((uuid, char, index) => { | |
| if (insertionPoints.includes(index)) { | |
| return (uuid += `-${char}`); | |
| } else { | |
| return (uuid += char); | |
| } | |
| }); | |
| } | |
| function getCookieData(request, name) { | |
| let anonymousId = null; | |
| let expires = null; | |
| let cookieString = request.headers.get("Cookie"); | |
| if (cookieString) { | |
| let cookies = cookieString.split(";"); | |
| cookies.forEach(cookie => { | |
| let cookieName = cookie.split("=")[0].trim(); | |
| if (cookieName === name) { | |
| anonymousId = cookie.split("=")[1]; | |
| } | |
| if (cookieName === `${name}_set`) { | |
| expires = new Date(decodeURIComponent(cookie.split("=")[1])); | |
| } | |
| }); | |
| } | |
| return { anonymousId, expires }; | |
| } | |
| async function log(endpoint, err, request) { | |
| const body = JSON.stringify(errToJson(err, request)); | |
| const res = await fetch(endpoint, { | |
| method: "POST", | |
| headers: { | |
| "Content-Type": "application/json" | |
| }, | |
| body | |
| }); | |
| if (res.status === 200) { | |
| return; | |
| } | |
| // We couldn't send to error endpoint, try to log the response at least | |
| console.error({ httpStatus: res.status, ...(await res.json()) }); // eslint-disable-line no-console | |
| } | |
| function errToJson(err, request) { | |
| const errType = err.name || (err.contructor || {}).name; | |
| const frames = parse(err); | |
| const extraKeys = Object.keys(err).filter( | |
| key => !["name", "message", "stack"].includes(key) | |
| ); | |
| return { | |
| message: errType + ": " + (err.message || "<no message>"), | |
| exception: { | |
| values: [ | |
| { | |
| type: errType, | |
| value: err.message, | |
| stacktrace: frames.length ? { frames: frames.reverse() } : undefined | |
| } | |
| ] | |
| }, | |
| extra: extraKeys.length | |
| ? { | |
| [errType]: extraKeys.reduce((obj, key) => ({ ...obj, [key]: err[key] }), {}) | |
| } | |
| : undefined, | |
| platform: "javascript", | |
| timestamp: Date.now() / 1000, | |
| request: | |
| request && request.url | |
| ? { | |
| method: request.method, | |
| url: request.url, | |
| query_string: request.query, | |
| headers: request.headers, | |
| data: request.body | |
| } | |
| : undefined | |
| }; | |
| } | |
| function parse(err) { | |
| return (err.stack || "") | |
| .split("\n") | |
| .slice(1) | |
| .map(line => { | |
| if (line.match(/^\s*[-]{4,}$/)) { | |
| return { filename: line }; | |
| } | |
| const lineMatch = line.match( | |
| /at (?:(.+)\s+\()?(?:(.+?):(\d+)(?::(\d+))?|([^)]+))\)?/ | |
| ); | |
| if (!lineMatch) { | |
| return; | |
| } | |
| return { | |
| function: lineMatch[1] || undefined, | |
| filename: lineMatch[2] || undefined, | |
| lineno: +lineMatch[3] || undefined, | |
| colno: +lineMatch[4] || undefined, | |
| in_app: lineMatch[5] !== "native" || undefined | |
| }; | |
| }) | |
| .filter(Boolean); | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment