Skip to content

Instantly share code, notes, and snippets.

@vunb
Forked from sperand-io/worker.js
Created June 20, 2023 01:24
Show Gist options
  • Select an option

  • Save vunb/51b76988aa554adf6283ecf7c60aeaa3 to your computer and use it in GitHub Desktop.

Select an option

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
/**
* 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