@touchlytics/first-party-proxy
v0.1.1
Published
CallTrack same-origin collection proxy for Astro, Next.js, Node and Cloudflare Workers
Maintainers
Readme
@touchlytics/first-party-proxy
The same_origin collection tier for any site with server access, without
the WordPress plugin. The package serves the CallTrack loader and core from
the site's own origin, forwards the browser's tracking events to CallTrack
with a signed request, and keeps the durable visitor id in a Secure,
HttpOnly, host-only cookie that the site's server sets. Safari keeps that
cookie for 400 days instead of the 7 days it allows script-written storage,
and filter lists have nothing to block short of the site itself.
One handler on Web-standard Request/Response runs unchanged on Node
18.17+, Cloudflare Workers and Pages, Vercel and Netlify functions, Deno and
Bun. The adapters only map each host's request onto it. Zero runtime
dependencies.
For a site that is already built and launched, nothing has to change about
the build: the host's own function directory is enough. One file in
functions/, netlify/edge-functions/ or api/ puts a handler on the
site's hostname, and npx @touchlytics/first-party-proxy init writes it.
On WordPress, use packages/wp-plugin instead: it reaches the same tier, and
it also sets the cache and optimizer exclusions and updates itself through
WordPress. This package is for everything that is not WordPress.
What you need
- A site on https whose host can run one request handler on the site's own
hostname. That is: a host with functions (Cloudflare Pages, Netlify,
Vercel), a server-rendered framework (Astro with an adapter, Next.js), a
Node app, or a Cloudflare Worker. The site's pages can stay fully static.
The one case that cannot work is a host with no function support at all
(Squarespace, Wix, Webflow, plain object storage): those sites use the
same_sitesubdomain tier instead (see below). - The site in CallTrack with its real domain (the loader is only served
when the proxy announces
https://<that domain>/ct). - The site token (
st_...) and a proxy secret from CallTrack.
Install
Pin the exact version. This package signs requests with the site's proxy secret, so a client site should only ever move to a version someone chose:
npm install @touchlytics/[email protected] --save-exactSee Security and updates below for how to verify a release before you move to it.
Until the package is on npm, build it from the calltrack repo and install the tarball:
pnpm --filter @touchlytics/first-party-proxy build
cd packages/first-party-proxy && npm pack
npm install ./touchlytics-first-party-proxy-0.1.1.tgzOne command: calltrack-proxy
The package ships a CLI with no dependencies of its own. Run it from the site's root:
npx @touchlytics/first-party-proxy init # put the handler in place
npx @touchlytics/first-party-proxy doctor # check this project
npx @touchlytics/first-party-proxy verify --url https://www.example.cominit finds the framework (astro.config.*, next.config.*,
wrangler.toml, the dependencies in package.json) and the host
(netlify.toml or .netlify, vercel.json or .vercel, wrangler.toml,
.cloudflare, Dockerfile or Procfile), then takes the lower-touch path:
| Detected | What init does |
|---|---|
| Cloudflare Pages | writes functions/ct/[[path]].ts |
| Netlify | writes netlify/edge-functions/calltrack.ts |
| Vercel | writes api/ct/[...path].ts, served at /api/ct |
| Astro with an adapter | adds calltrack({ injectSnippet: true }) to astro.config.* |
| Astro with no adapter | prints the astro add command for the host and stops |
| Next.js | creates app/ct/[...path]/route.ts (or under src/app) |
| Cloudflare Worker | prints the worker and the wrangler route |
| Node server | prints the middleware to register before any body parser |
A launched site takes the host function even when the framework could host
the proxy itself, because that touches neither the config nor the build. Pass
--framework astro to use the integration instead, or --host to pick the
host by hand.
Every run writes CALLTRACK_SITE_TOKEN and CALLTRACK_PROXY_SECRET
placeholders into .env.example (created or appended, values already there
kept), never a real value, and never touches .env. Running init twice
changes nothing and says so, it never overwrites a file it did not write
without --force, and --dry-run prints the plan without writing.
Flags: --proxy-path /ct (the path the proxy answers on, /api/ct on
Vercel), --host cloudflare-pages|netlify|vercel|cloudflare-worker|node,
--framework astro|next|node|cloudflare, --dry-run, --force, --yes,
--cwd <dir>, plus --url and --token for verify. Every command takes
--help. Exit codes: 0 done, 1 something to fix, 2 wrong usage.
doctor checks the package is a dependency, the handler and the snippet are
in place, an Astro adapter is configured when that is the plan, both
variables are set in the current shell, and the proxy path agrees with
CALLTRACK_PROXY_PATH. It prints the fix for every failure.
verify --url fetches <url><proxy path>/t/<token>.js and reports the
status, the size and whether the loader came back, then POSTs a probe to
<proxy path>/t/beacon and reports whether the proxy signed and forwarded
it. Any non-404 JSON reply (or the 204 the API answers a beacon with)
proves the route exists. It also reports whether the proxy took a public,
private or loopback address for that request as the visitor's, or none:
run from outside the site's network, anything but public means the
trustProxy count lands on a proxy or is missing. The proxy answers that in
an x-calltrack-visitor-address header on /t/beacon replies only: the
kind, never the address. It is no secret, since anyone can post a beacon
and learn from it whether a forged X-Forwarded-For entry would be taken,
which is the misconfiguration verify looks for; the replies are no-store
POSTs. The probe carries no event data, so nothing is recorded against the
site. The CallTrack install verifier (POST /v1/sites/:id/verify) is the
authority on an install.
Per-site checklist
Both sides, in the order that keeps a site from ever signing with a stale secret. Copy this into the ticket.
- Install the package in the site:
npm install @touchlytics/[email protected] --save-exact. - Run
npx @touchlytics/first-party-proxy initin the site's root, and commit what it changed. - Make sure the install snippet is in the site's
<head>. The Astro integration puts it there itself; every other path needscalltrackSnippet({ siteToken }), or the snippet CallTrack shows, once in the layout. - In CallTrack, rotate this site's proxy secret
(
POST /v1/sites/:id/tracking/rotate-proxy-secret) and copy the value. It is shown once. - In the host's dashboard, set
CALLTRACK_SITE_TOKENandCALLTRACK_PROXY_SECRET(the host recipes below say where). Keep both out of the repo. On a server behind a reverse proxy, also setCALLTRACK_SITE_ORIGIN(The site host). - Deploy the site. A host only hands new variables to a new deployment. This
deploy is safe on its own: the proxy stays inert while CallTrack still has
the site on
central. - Check the site is serving the proxy:
npx @touchlytics/first-party-proxy verify --url https://www.example.com. - In CallTrack, switch the tier to the same path:
PATCH /v1/sites/:id/trackingwith{"collection_mode": "same_origin", "proxy_path": "/ct"}. - If the secret was rotated after the last deploy, redeploy now. A site
running the old secret signs every beacon with a stale secret, and
CallTrack answers
{"ok":false,"reason":"origin"}. - Run the install verifier:
POST /v1/sites/:id/verify, and confirmfirst_party_transport,loader_served,core_served,snippet_installed, andbeaconsonce real visitors have arrived.
Host recipes for a site already launched
Each recipe is one file plus two variables. The site's build, its prerendered pages and its page performance are unchanged: only requests under the proxy path reach the function, and everything else is served exactly as before.
Cloudflare Pages
The most common retrofit: a static site (Astro, Eleventy, plain HTML) already built and deployed. No adapter, no config change, no prerender change.
npm install @touchlytics/[email protected] --save-exact- Add
functions/ct/[[path]].ts:
import { createPagesFunction } from '@touchlytics/first-party-proxy/cloudflare-pages'
export const onRequest = createPagesFunction()- Cloudflare dashboard, the Pages project, Settings, Environment variables:
add
CALLTRACK_SITE_TOKENandCALLTRACK_PROXY_SECRETas secrets, in both the Production and Preview environments. - Redeploy. A Pages deployment only sees variables that existed when it started, so a variable added afterwards needs a new deployment.
Pages builds the functions directory into the site's own Worker, so the
handler answers on the site's hostname and the cookie is a first-party
cookie. Only /ct/* reaches it; the rest of the site is still served from
the CDN. No compatibility flags are needed. If the project has a
_routes.json, add "/ct/*" to its include list, or Pages answers the
static 404 for beacons.
Netlify
npm install @touchlytics/[email protected] --save-exact- Add
netlify/edge-functions/calltrack.ts:
import { createNetlifyHandler } from '@touchlytics/first-party-proxy/netlify'
export default createNetlifyHandler()
export const config = { path: '/ct/*' }- Netlify, Site configuration, Environment variables: add
CALLTRACK_SITE_TOKENandCALLTRACK_PROXY_SECRET(mark them secret, and scope them to all deploy contexts you want tracked). - Redeploy, so the function and the variables ship together.
The handler reads Netlify.env first and falls back to process.env, so the
same file works as an edge function or inside a Node function. The config
path keeps it off every other request.
Vercel
npm install @touchlytics/[email protected] --save-exact- Add
api/ct/[...path].ts:
import { createVercelHandler } from '@touchlytics/first-party-proxy/vercel'
export const { GET, POST, HEAD } = createVercelHandler()- Vercel, Project Settings, Environment Variables: add
CALLTRACK_SITE_TOKENandCALLTRACK_PROXY_SECRETfor Production (and Preview if previews should report). - Redeploy. Vercel gives a function the variables that existed at build time.
Vercel serves the api directory at /api/*, so this answers on /api/ct
and that is the value to set as proxy_path in CallTrack. This package picks
that over a rewrite, because it needs no config file at all. To keep the
shorter /ct, add the rewrite and tell the handler which path it serves:
{ "rewrites": [{ "source": "/ct/:path*", "destination": "/api/ct/:path*" }] }export const { GET, POST, HEAD } = createVercelHandler({ proxyPath: '/ct' })A Next.js project on Vercel uses the app-router route instead (below).
Cloudflare Worker in front of the zone
A site served by a Worker rather than Pages keeps its own worker: see
Cloudflare Worker below. The route scopes it to
/ct/*, so nothing else about the zone changes.
Node server already running
An Express, Fastify or http app is already a handler on the site's
hostname. Register the middleware before any body parser: see
Node below. Nothing else about the app changes.
A host with no functions at all
Squarespace, Wix, Webflow and plain object storage cannot run a handler on
the site's hostname, so same_origin is impossible there, with this package
or any other. Those sites use the same_site subdomain tier: in CallTrack
set first_party_host to go.<domain>, add the CNAME it shows, run
POST /v1/sites/:id/first-party/verify, and paste the snippet it gives you.
Delivery stays under the site's domain, with the visitor id in script storage
(7 days in Safari) instead of the 400 day server cookie.
Environment variables
| Variable | Required | Meaning |
|---|---|---|
| CALLTRACK_SITE_TOKEN | yes | the site token, st_... |
| CALLTRACK_PROXY_SECRET | yes | the one-time value from POST /v1/sites/:id/tracking/rotate-proxy-secret; keep it out of the repo and the build output |
| CALLTRACK_ORIGIN | no | the CallTrack service origin; default https://mvntcdn.com |
| CALLTRACK_PROXY_PATH | no | the path the proxy answers under; default /ct; must equal the site's proxy_path in CallTrack |
| CALLTRACK_SITE_ORIGIN | recommended | the site origin CallTrack knows, e.g. https://www.example.com. With it set, nothing in a request can change the host the loader is fetched for (see The site host). Needed behind a reverse proxy that does not pass the visitor's Host, and when the site answers on both www and the apex without redirecting one to the other |
| CALLTRACK_FALLBACK_ORIGIN | no | a second CallTrack origin tried when the first fails |
| CALLTRACK_VISITOR_IP_HEADER | no | a header your edge sets to the visitor's address, replacing whatever the visitor sent (see The visitor's address); the same setting as the WordPress plugin's |
Every adapter also accepts the same values as options (siteToken,
proxySecret, origin, proxyPath, siteOrigin, fallbackOrigin,
visitorIpHeader, cookieName, visitorCookieDays), plus trustProxy,
which has no variable; options win over the environment.
The site host
CallTrack serves a live loader only for the exact base it has for the site,
https://<domain><proxy_path>, so the proxy names the site's host when it
fetches the loader (?fp=). It takes the first of:
siteOrigin/CALLTRACK_SITE_ORIGIN. Nothing in the request can change it, which makes it the strongest choice wherever the origin is known up front.X-Forwarded-Host(its first value), only withtrustProxy: true.- The
Hostheader. - The host of the request URL.
trustProxy is off on every adapter unless you set it (see
The visitor's address for what else it does). It
makes X-Forwarded-Host the site host only when a proxy in front replaces
that header with its own value; a visitor can send any value, and a cache in
front of the site does not key on it. Many do not replace it: nginx sets it
only when configured to, Apache appends to whatever the visitor sent (so the
visitor's value comes first), and Fly and Heroku pass it through as sent.
There a forged value only earns its sender a refused loader, but the header
is no reliable name for the site, so set CALLTRACK_SITE_ORIGIN with
trustProxy.
| Where the handler runs | Host it uses | trustProxy |
|---|---|---|
| Cloudflare Worker or Pages function | the host the visitor asked for | off: Cloudflare passes a visitor's X-Forwarded-Host through untouched |
| Netlify edge function | the host the visitor asked for | off |
| Vercel function, or Next.js on Vercel | the Host header, which Vercel sets to the public host | off: Vercel also sets X-Forwarded-Host, but it adds nothing |
| Next.js with next start or standalone | the Host header; its request.url names the server's own address | on only behind a proxy that sets X-Forwarded-Host; better, set CALLTRACK_SITE_ORIGIN |
| Astro with an adapter | the Host header, whatever the adapter built request.url from | as for the platform it runs on. On @astrojs/node, run it in middleware mode with createNodeMiddleware in front (below), and set CALLTRACK_SITE_ORIGIN |
| Node middleware | the Host header, or X-Forwarded-Host with trustProxy | the count of proxies in front, for the visitor's address; with CALLTRACK_SITE_ORIGIN set, since Apache, Fly and Heroku pass a visitor's X-Forwarded-Host on |
| Node behind Apache (ProxyPreserveHost Off, the default) | Apache's own backend name as Host, so set CALLTRACK_SITE_ORIGIN (or ProxyPreserveHost On) | on, for the visitor's address; Apache's X-Forwarded-Host starts with whatever the visitor sent, so it names nothing |
A request's host headers cannot change what the proxy serves other visitors:
- A wrong host only changes
fp, and CallTrack answers a wrongfpwith a disabled loader. The proxy serves every loader that does not start tracking withCache-Control: no-store, and never lets those refusals push the good loader out of its own memory. - The route comes from the request path alone. Some servers build the request
URL by pasting headers in front of the path: Next.js with
experimental.trustHostHeaderusesHost;@astrojs/nodeusesX-Forwarded-Proto,X-Forwarded-HostorHost, andX-Forwarded-Port, checkingX-Forwarded-Hostfrom Astro 5.14.3, the scheme and port from 5.15.5, andHostagainstsecurity.allowedDomainsonly from 5.17.2; this package's Node middleware did before 0.1.1. A header can then carry a path. The proxy answers400withno-storeto a request whoseHost,X-Forwarded-HostorX-Forwarded-Protoholds a character that could (/ \ ? # @, whitespace or a control character), whoseX-Forwarded-Portis not a number, or whose URL has a fragment. It checks every value in a list, whatevertrustProxysays, since those servers ran first. - On Astro, the proxy endpoint runs only after Astro has routed the request,
and
@astrojs/nodebuilds the URL it routes on from the rawHost, where URL parsing turns\into/. AHostsuch asx\old?,x/nope?orx?sends the loader's URL to a config redirect, the prerendered 404 or a page, and the proxy never sees it (on real Astro 6.4.8 and 7.3.4 servers, and on older 5.x; 5.18.2 was clean). The integration's guard middleware runs the same check, but Astro runs middleware only for server-rendered routes it has matched: it covers the endpoint and on-demand pages, and cannot cover config redirects, the prerendered 404 or prerendered pages. On@astrojs/node, put the proxy in front of Astro instead (Astro on your own Node server), which sees the request line before Astro routes; a standalone server needs the front-proxy rule below. The raw-Host URL building is Astro's to fix. - When CallTrack refuses the loader, the proxy logs a
console.warnnaming the requested base it sent, where the host came from, and the fix: at most one every ten minutes. Refusals in between are counted, and the count is printed with the next warning, so it appears only once another refusal arrives after the ten minutes.
A cache in front of the site
A cache that leaves Host out of its key serves one request's answer for
every host name. nginx's default proxy_cache_key does. nginx 1.28 refuses
/ in Host but lets \ through, and 1.28 and earlier accept ? and #
(1.29.4 and later refuse them), so with proxy_set_header Host $host a
redirect or page a forged host earned can be cached at the loader's URL,
redirects included when proxy_cache_valid covers them: one request, every
visitor, for as long as the cache keeps it.
Required for a standalone @astrojs/node server (and worth doing in
front of any site): the front proxy must do one of these.
- Key its cache on the host:
proxy_cache_key $scheme$host$request_uri; - Pin the host it passes on:
proxy_set_header Host www.example.com; - Refuse a
Hostcontaining\,/,?,#or@with a 400.
init and doctor print this when they find a standalone @astrojs/node
server. The rule also keeps the site's own pages from being cached under a
forged host. The recommended setup for @astrojs/node, middleware mode
with the proxy in front, does not depend on it for the proxy's URLs.
The visitor's address
The proxy sends CallTrack each visitor's address (X-CT-Visitor-IP), and
CallTrack keys its per-address rate limits, the address on leads and
conversions, and the verified-agent check on it. It takes the first of:
visitorIpHeader/CALLTRACK_VISITOR_IP_HEADER: a header your edge sets, replacing whatever the visitor sent (CF-Connecting-IPbehind Cloudflare,Fly-Client-IPon Fly,X-Real-IPwhere nginx sets it withproxy_set_header X-Real-IP $remote_addr). NeverX-Forwarded-For.- The platform's own value:
CF-Connecting-IPin the Cloudflare Worker and Pages adapters,x-real-ipin the Vercel adapter and in the Next.js adapter on Vercel (Vercel overwrites both), Netlify'scontext.ip, Astro'sclientAddress, and the Node socket. On@astrojs/node,clientAddressis the firstX-Forwarded-Forentry (always on Astro 5; on 6 and 7 whenever the host passessecurity.allowedDomains), which the visitor writes, and so is it on any adapter built on Astro's Node request helpers. So the Astro handler takesclientAddressunderX-Forwarded-Foronly from@astrojs/cloudflare,@astrojs/netlifyand@astrojs/vercel, whose platforms set it; on any other adapter it goes on to 3 whenever that header is present. - With
trustProxy,X-Forwarded-Forcounted from the right.
trustProxy is the number of proxies in front of the server that append to
X-Forwarded-For (true is 1). Each appends the address it was connected
from, so the visitor is the entry that many places from the right, and
anything to its left is whatever the visitor sent. A count too high lets a
visitor choose their address; too low makes every visitor one of your
proxies. When the list is shorter than the count, the request did not come
through them all, and the Node middleware uses the socket instead.
Missing, it can leave no address at all: a self-hosted Next.js route handler
has no socket, @astrojs/node behind nginx has only X-Forwarded-For, and
the core needs the clientIp you pass. CallTrack then sees the server's own
address for every visitor. The proxy logs a warning (at most one every ten
minutes) when a request with X-Forwarded-For ends up with no address, or
with a private, loopback or CGNAT one.
| Where the server runs | Setting |
|---|---|
| Cloudflare Worker or Pages, Netlify, Vercel | nothing: the platform's value is used |
| Heroku, Fly, an AWS load balancer (default append mode) | trustProxy: 1, or on Fly visitorIpHeader: 'fly-client-ip' |
| Render (Cloudflare, then Render's balancer) | trustProxy: 2 |
| nginx in front | trustProxy: 1, or visitorIpHeader: 'x-real-ip' with proxy_set_header X-Real-IP $remote_addr |
| Cloudflare, then nginx | trustProxy: 2, or visitorIpHeader: 'cf-connecting-ip' when only Cloudflare can reach nginx |
| Apache (mod_proxy appends) | trustProxy: 1 |
| self-hosted Next.js | as for the proxy in front; with none, no address is sent and CallTrack sees the server's own |
0.1.0 read CF-Connecting-IP, X-Real-IP and the first X-Forwarded-For
entry from any request, so a visitor could pick the address CallTrack saw.
Upgrading from 0.1.0
0.1.1 is a security fix; move every site to it. 0.1.0 read
X-Forwarded-Host from any request, so a visitor who forged it got the
disabled loader with a public cache header, and a cache in front of the
site could serve that to everyone for a minute at a time.
Before you deploy 0.1.1, check how the site reaches the proxy:
- On Cloudflare, Netlify or Vercel, nothing to change while the function
answers on the site's own hostname. If a rewrite or proxy from another
hostname sits in front of it (an external rewrite into another project,
Firebase Hosting in front of Cloud Run), the function sees its own
Hostand the loader is refused; 0.1.0 usedX-Forwarded-Hostthere. SetCALLTRACK_SITE_ORIGIN. - Behind a reverse proxy (a Node server,
next start,@astrojs/node), setCALLTRACK_SITE_ORIGINto the site's origin. Without it, a proxy that does not pass the visitor'sHostmakes the loader come backinvalid first party proxy, because 0.1.1 no longer readsX-Forwarded-HostunlesstrustProxyis on. Apache is the common case: with its defaultProxyPreserveHost Offit sends its own backend name asHost, and it appends toX-Forwarded-Host, so its first value is whatever the visitor sent. A forged value there only refuses the forger's own loader, but it is no way to name the site: setCALLTRACK_SITE_ORIGIN. - On
@astrojs/node, move to middleware mode withcreateNodeMiddlewaremounted before Astro's handler (Astro on your own Node server). A standalone server needs the front-proxy rule in A cache in front of the site; the integration's guard middleware alone cannot cover config redirects, the prerendered 404 or prerendered pages. - Behind a proxy, check
trustProxy: it now counts the proxies in front and takes the visitor from the right ofX-Forwarded-For(see The visitor's address).truestill means one, which is right behind a single appending proxy (Heroku, Fly, an AWS load balancer, nginx); Render and Cloudflare in front of nginx need2. - The Node examples here and in
initpairtrustProxy: truewithCALLTRACK_SITE_ORIGIN: the count gets each visitor's address from the right entry, and the origin setting keeps the host fixed.
After the deploy, npx @touchlytics/first-party-proxy verify --url
https://www.example.com should pass, and the loader should answer
cache-control: public, max-age=60. If CallTrack refuses the loader, the
site's server log has a calltrack proxy: CallTrack refused the loader
warning with the host it sent.
CallTrack settings
The API calls behind the per-site checklist, in the
same order. The proxy is inert while the site is still central, so
deploying it first costs nothing; switching the mode first would refuse every
browser beacon until the proxy is live.
- Rotate the secret and copy it once:
POST /v1/sites/:id/tracking/rotate-proxy-secretreturns{"proxy_secret": "..."}. Put it inCALLTRACK_PROXY_SECRET. - Deploy the proxy and the snippet (per framework below).
- Check the loader through the site:
curl -si https://www.example.com/ct/t/st_....jsshould answer200withcache-control: public, max-age=60, and the body must contain neither"disabled":truenorinvalid first party proxy. A loader that comes backno-storeis one of those. - Switch the tier:
PATCH /v1/sites/:id/trackingwith{"collection_mode": "same_origin", "proxy_path": "/ct"}. - Run the install verifier:
POST /v1/sites/:id/verifyand confirmfirst_party_transport,loader_served,core_served,snippet_installedand, once real visitors have arrived,beacons.
To leave the tier, set collection_mode back to central; the proxy can
stay deployed.
Astro with an adapter
For a site this agency builds. npx @touchlytics/first-party-proxy init
--framework astro writes the import line and the integration entry, or by
hand in astro.config.mjs:
import { defineConfig } from 'astro/config'
import cloudflare from '@astrojs/cloudflare'
import calltrack from '@touchlytics/first-party-proxy/astro'
export default defineConfig({
output: 'static',
adapter: cloudflare(),
integrations: [calltrack({ injectSnippet: true })],
})An adapter is the whole requirement. output: 'static' keeps every page
prerendered; only the injected proxy endpoint runs on the server.
output: 'server' works the same way.
.env (or the host's environment):
CALLTRACK_SITE_TOKEN=st_...
CALLTRACK_PROXY_SECRET=...The integration injects the endpoint at /ct/[...path] (never
prerendered), reads the secret from the environment at request time, adds
the install snippet to every page's <head>, and registers a guard
middleware for server-rendered routes (see
The site host). With the Cloudflare
adapter, set the variables in wrangler.toml ([vars]) and the secret
with wrangler secret put CALLTRACK_PROXY_SECRET; the endpoint reads
locals.runtime.env.
To place the pieces by hand instead, create src/pages/ct/[...path].ts:
import { createAstroHandler } from '@touchlytics/first-party-proxy/astro'
export const prerender = false
export const ALL = createAstroHandler() // on @astrojs/node: createAstroHandler({ astroAdapter: '@astrojs/node' }), and see belowOn any adapter but Cloudflare, Netlify or Vercel, pass its name as
astroAdapter (the integration does this itself): @astrojs/node's
clientAddress is the first X-Forwarded-For entry, as is that of any
adapter built on Astro's Node request helpers, and the handler then ignores
it whenever the header is present. Without astroAdapter the handler trusts
clientAddress. Add the guard as src/middleware.ts (or first in your own sequence(),
with calltrackGuard from @touchlytics/first-party-proxy/astro):
export { onRequest } from '@touchlytics/first-party-proxy/astro-middleware'and put the snippet in the layout's <head>:
---
import { calltrackSnippet } from '@touchlytics/first-party-proxy/snippet'
const snippet = calltrackSnippet({ siteToken: import.meta.env.CALLTRACK_SITE_TOKEN })
---
<head>
<Fragment set:html={snippet} />
</head>Astro on your own Node server
With @astrojs/node, Astro builds its request URL from the raw Host and
routes on it before any middleware runs (see The site host),
so the proxy has to see the request first. Run the adapter in middleware mode
and mount createNodeMiddleware before Astro's handler; it answers its own
path from the request line and hands everything else to Astro:
// astro.config.mjs: adapter: node({ mode: 'middleware' }), integrations: [calltrack({ injectSnippet: true })]
// server.mjs
import express from 'express'
import { handler as astro } from './dist/server/entry.mjs'
import { createNodeMiddleware } from '@touchlytics/first-party-proxy/node'
const app = express()
app.use(createNodeMiddleware({ trustProxy: true })) // first: it answers /ct before Astro routes
app.use(express.static('dist/client/'))
app.use(astro)
app.listen(process.env.PORT ?? 8080)Set CALLTRACK_SITE_ORIGIN with it, and count trustProxy as in
The visitor's address. This is the setup
scripts/astro-probe.mjs checks before every release, against real Astro
5, 6 and 7 servers at the releases the workflow pins (5.18.2, 6.4.8 and
7.3.4 for 0.1.1): nothing but the proxy answers its URLs, whatever the
headers say. A standalone server (mode: 'standalone') needs
the front-proxy rule in
A cache in front of the site.
Use Astro 5.18 or later on @astrojs/node. A standalone 5.14.x server hangs
on a 404 path when Host is forged: Astro fetches its prerendered 404 page
from a URL built from Host (#renderError; a Host of x fails with
ENOTFOUND x), which is Astro's own server-side request forgery. Middleware
mode with the proxy in front keeps the proxy's URLs out of it, but not the
site's other pages, so move Astro forward as well.
Astro 5 with static pages
output: 'static' plus an adapter is the setup to want, and it is Astro 5's
default. Pages are prerendered at build time exactly as before, and only the
proxy endpoint sets export const prerender = false, which the integration
does for you. One route runs on the server; nothing else about the build
changes. Astro 4's output: 'hybrid' is the same thing under the old name.
output: 'static' with no adapter produces files only: nothing runs to
sign events or set the HttpOnly cookie. Two ways out, in order of how little
they touch:
- The host's own function, which needs no adapter and no config change at
all. See the host recipes above, or run
npx @touchlytics/first-party-proxy init --host cloudflare-pages. - Add an adapter (
npx astro add cloudflare,netlify,vercelornode) and use the integration above. Pages stay prerendered.
If the host cannot run a function either, the site stays on the same_site
subdomain tier (see above).
Starter template
Put this in the agency's Astro starter once, and every new site is born with the proxy:
@touchlytics/first-party-proxyindependencies, pinned exactly.calltrack({ injectSnippet: true })inintegrations, with whatever adapter the starter already uses.CALLTRACK_SITE_TOKEN=andCALLTRACK_PROXY_SECRET=in.env.example, empty, so the two names travel with the repo and the real values never do.
A new site then needs nothing but the two values in the host and the
per-site checklist from step 4 on. npx
@touchlytics/first-party-proxy doctor confirms it before launch.
Next.js (app router)
app/ct/[...path]/route.ts:
import { createNextHandler } from '@touchlytics/first-party-proxy/next'
export const dynamic = 'force-dynamic'
export const { GET, POST, HEAD } = createNextHandler()app/layout.tsx:
import { calltrackSnippetJs } from '@touchlytics/first-party-proxy/snippet'
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head>
<script dangerouslySetInnerHTML={{ __html: calltrackSnippetJs({ siteToken: process.env.CALLTRACK_SITE_TOKEN! }) }} />
</head>
<body>{children}</body>
</html>
)
}Set CALLTRACK_SITE_TOKEN and CALLTRACK_PROXY_SECRET in the project's
environment (Vercel: Settings, Environment Variables). The handler runs on
the Node and Edge runtimes. On Vercel the visitor's address is x-real-ip,
which Vercel sets; self-hosted, a route handler has no socket address, so it
comes from trustProxy or visitorIpHeader (see
The visitor's address). The site host is the
Host header: on Vercel that is
the public host. A self-hosted next start builds request.url from its
own address, so behind a reverse proxy set CALLTRACK_SITE_ORIGIN (see
The site host).
Pages router: pages/api/ct/[...path].ts with the Node middleware, and
/api/ct as the proxy path on both sides:
import { createNodeMiddleware } from '@touchlytics/first-party-proxy/node'
const middleware = createNodeMiddleware({ proxyPath: '/api/ct', trustProxy: true })
export default function handler(req, res) {
middleware(req, res)
}
export const config = { api: { bodyParser: false } }Then CALLTRACK_PROXY_PATH=/api/ct, the snippet with
calltrackSnippetJs({ siteToken, proxyPath: '/api/ct' }), and
"proxy_path": "/api/ct" in the CallTrack tracking settings, and
CALLTRACK_SITE_ORIGIN as in the Node section below.
Node (Express, http, Fastify)
Register the middleware before any body parser so the event bytes reach it untouched:
import express from 'express'
import { createNodeMiddleware } from '@touchlytics/first-party-proxy/node'
const app = express()
app.use(createNodeMiddleware({ trustProxy: true }))
app.use(express.json())Plain http:
import { createServer } from 'node:http'
import { createNodeMiddleware } from '@touchlytics/first-party-proxy/node'
const calltrack = createNodeMiddleware({ trustProxy: true })
createServer((req, res) => calltrack(req, res, () => serveSite(req, res))).listen(3000)Fastify: await app.register(import('@fastify/middie')) then
app.use(createNodeMiddleware({ trustProxy: true })).
Set CALLTRACK_SITE_ORIGIN (for example https://www.example.com) in the
server's environment, and the loader is always fetched for that origin.
trustProxy counts the proxies in front that append to X-Forwarded-For
(true is 1): the visitor's address is the entry that many places from the
right, so a visitor cannot choose it, and the scheme comes from
X-Forwarded-Proto. Without it every visitor reaches CallTrack as the proxy
and shares its per-address limits; with too high a count, a visitor picks
their own. Fly, Heroku, an AWS load balancer or nginx alone is 1; Render, or
Cloudflare in front of nginx, is 2 (see
The visitor's address). Turn it off only when Node
itself answers visitors.
With CALLTRACK_SITE_ORIGIN set, the one thing trustProxy would otherwise
take from a header, the site host, is fixed (see
The site host). The request path always comes from the
request line, never from a header. The middleware also runs on an HTTP/2
compat server, where the host comes from :authority.
Put the snippet in the site's <head> with calltrackSnippet({ siteToken }).
Cloudflare Worker
src/index.ts:
import { createWorker } from '@touchlytics/first-party-proxy/cloudflare'
export default createWorker()wrangler.toml:
name = "calltrack-proxy"
main = "src/index.ts"
compatibility_date = "2025-01-01"
routes = [
{ pattern = "www.example.com/ct/*", zone_name = "example.com" },
]
[vars]
CALLTRACK_SITE_TOKEN = "st_..."wrangler secret put CALLTRACK_PROXY_SECRET
wrangler deployThe route scopes the Worker to /ct/*, so the rest of the site is
untouched. On a broader route the Worker passes every other request to
env.ASSETS when the project has one, else to the zone's origin.
Cloudflare Pages has its own adapter, which takes the Pages function
signature and passes anything outside the proxy path to next():
// functions/ct/[[path]].ts
import { createPagesFunction } from '@touchlytics/first-party-proxy/cloudflare-pages'
export const onRequest = createPagesFunction()Other runtimes
Anything that hands you a Request works with the core directly:
import { createCalltrackProxy } from '@touchlytics/first-party-proxy'
const proxy = createCalltrackProxy()
export default (request: Request, context: { ip?: string }) => proxy.handle(request, { clientIp: context.ip })Netlify and Vercel have their own adapters (/netlify and /vercel, in the
host recipes above); the core is there for anything else, such as a Deno or
Bun server, or a platform that hands you a Request and an address.
What the proxy does
- Serves
/ct/t/<token>.jsfrom CallTrack with?fp=https://<host>/ct(the host from The site host). A loader that starts tracking getsCache-Control: public, max-age=60; the disabled loader CallTrack answers for a refusedfp, an unknown site, a kill switch or a missing core getsno-store, whatever cache header CallTrack sent. A fetched loader is reused for five minutes and, when CallTrack is unreachable, the last good copy is served for a day. Any other token gets a no-op stub. - Serves
/ct/core/v<version>/swap.min.jswithCache-Control: public, max-age=31536000, immutable. A version CallTrack does not ship is answered with its current core; the proxy serves that stand-inno-storeand does not keep it, so neither the proxy nor a cache in front of the site holds it under that version's URL. CallTrack's own edge still caches the stand-in as immutable; fixing that is a separate change to the service. - Routes on the request path alone, and answers
400withno-storeto a request whoseHost,X-Forwarded-Host,X-Forwarded-ProtoorX-Forwarded-Portcould carry a path into a pasted-together URL, or whose URL has a fragment. - Forwards only
/t/assign,/t/pv,/t/eng,/t/ev,/t/heartbeat,/t/context,/t/form,/t/consentand/t/beacon, as POST, with the raw body bytes,X-CT-Proxy-Site,X-CT-Proxy-Timestamp,X-CT-Body-SHA256,X-CT-Proxy-Signature(HMAC-SHA256 overtimestamp\nsite_token\npath\nbody_sha256, base64url), the visitor's IP and user agent, andSec-GPC: 1when the browser sent it. Everything else is a 404. Browser cookies never leave the site. - Injects the visitor id from the
__Host-ct_vid_<hash>cookie into/t/pvbodies that carry none, and into/t/consentbodies from the site's consent platform or carrying GPC; sets the cookie from a/t/pvreply (Secure; HttpOnly; SameSite=Lax; Path=/; Max-Age=400 days); and deletes it when a/t/consentbody from the site's consent platform withholds analytics, or a GPC browser's does on a site whose CallTrack GPC setting isdeny_optional(read from the loader config, which an instance that has not served the loader fetches first, once for all the posts for the same site base waiting on it and for at most the upstream timeout; if it cannot, a GPC default deletes the cookie, and for the next minute, or 15 seconds after a fetch that only outlasted the wait, such posts for that base delete without fetching or waiting). The tracking core's pre-consentdefaultwithout GPC, posted on every page load before the platform reports, neither carries the id nor deletes the cookie, and neither does CallTrack'sserver_default: neither is the visitor's choice. - Gives each upstream call five seconds, then answers
502 {"ok":false}; the core retries and queues on its side.
Security and updates
This package runs on your server and holds the CallTrack proxy secret that signs every forwarded event, so it is worth a minute of scrutiny before each upgrade.
Pin exact versions. npm install @touchlytics/[email protected]
--save-exact writes "0.1.1", not "^0.1.1", so a fresh npm install or a
CI rebuild can never pull a release nobody reviewed. Caret ranges are the
usual way a compromised package reaches production.
Nothing in this package runs on install. There is no preinstall,
install, postinstall or prepare script, and there never will be: adding
one would be a breaking change to this guarantee. npm install only writes
files. Check it yourself with npm view @touchlytics/[email protected]
--json and look at scripts in the output.
Zero runtime dependencies. The published tarball is dist/, this README,
the changelog, the license and package.json. Nothing is pulled in at install time, so the
package has no transitive supply chain to audit.
Releases are published from CI. Every version from 0.1.1 on is published by a GitHub Actions workflow using npm's trusted publishing, with no long-lived token in existence. 0.1.0 was published by hand to bootstrap the trusted publisher. Releases carry no npm provenance attestation: npm issues them only for public source repositories, and the source repository is private. Verify the registry signatures on what you have installed with:
npm audit signaturesIt checks your whole tree and names anything that fails.
If a release ever looks wrong, do not deploy it. The signs: npm audit
signatures fails, the tarball contains files this README does not describe, or the
package suddenly declares dependencies or an install script. In that case:
- Pin back to the previous known-good version:
npm install @touchlytics/first-party-proxy@<previous> --save-exact, then redeploy. - Tell the agency at once ([email protected]) with the version number
and what you saw. If a release is bad, every site on this tier needs to know
inside the hour, and the proxy secrets all need rotating
(
POST /v1/sites/:id/tracking/rotate-proxy-secretper site). - Set the site's
collection_modeback tocentralif you need tracking to keep working while it is sorted out. The proxy can stay deployed; it goes inert.
Rotating a proxy secret is cheap and safe to do at any time: rotate, put the
new value in CALLTRACK_PROXY_SECRET, redeploy. Do it whenever someone with
access to the site's environment leaves.
Troubleshooting
| Symptom | Cause |
|---|---|
| The loader body says invalid first party proxy | CallTrack's site domain plus proxy_path is not https://<this host>/ct. Fix the domain or proxy_path in CallTrack, or set CALLTRACK_SITE_ORIGIN. Behind a reverse proxy that does not pass the visitor's Host, CALLTRACK_SITE_ORIGIN is the fix |
| The loader answers cache-control: no-store | it does not start tracking: the body says why (the rows above), or the site's kill switch is on |
| The server log says calltrack proxy: CallTrack refused the loader for ... | the host the proxy sent is not the site's domain in CallTrack; the message says where it came from. Set CALLTRACK_SITE_ORIGIN |
| The server log says the visitor address for an event is a private one (or loopback, or cgnat) | the proxy is behind a proxy but trustProxy is off or counts too few: every visitor reaches CallTrack with that proxy's address. Set the count (The visitor's address) |
| The server log says no visitor address was found for an event although X-Forwarded-For is present | a proxy is in front and nothing tells the proxy where the visitor is (self-hosted Next.js, @astrojs/node behind nginx, the core without clientIp): every visitor reaches CallTrack as the server. Set trustProxy or visitorIpHeader (The visitor's address). With nothing in front, a visitor sent the header |
| The server log says CF-Connecting-IP is present but is not the visitor address chosen | Cloudflare is in front and not counted (Render, or Cloudflare in front of nginx, is trustProxy: 2), or name it with visitorIpHeader: 'cf-connecting-ip' when only Cloudflare can reach the server. Without Cloudflare in front, a visitor sent the header |
| Every request to the site fails, not only the proxy's, and the log says proxyPath must look like /ct | CALLTRACK_PROXY_PATH or proxyPath is malformed. It is the one setting read on every request, since it decides which requests are the proxy's: use a path like /ct (letters, digits, /, _, -) |
| Every request to the proxy path answers 400 {"ok":false} | something in front sends a Host, X-Forwarded-Host or X-Forwarded-Proto holding / \ ? # @ or whitespace, or an X-Forwarded-Port that is not a number; fix that proxy's header settings |
| The loader body says first party proxy required | the snippet still points at the CallTrack origin; it must load /ct/t/<token>.js from the site |
| The loader is window.ct=window.ct||function(){}; | the token in the snippet is not the proxy's CALLTRACK_SITE_TOKEN |
| Events answer {"ok":false,"reason":"origin"} | the site is same_origin but the beacon reached CallTrack unsigned (snippet not through the proxy), or the proxy secret is not the one CallTrack has: rotate it again and redeploy |
| Events answer 400 {"ok":false} from the proxy | the body's t is not this proxy's site token |
| The verifier's loader_served fails with a 404 | the endpoint is not at the proxy path: check the route file location, or CALLTRACK_PROXY_PATH against proxy_path |
| verify says a page answered where the loader should be | the request never reached the proxy: the handler is not under the proxy path, or a catch-all route, a redirect or a Pages _routes.json sits in front of it |
| doctor cannot find the handler | run init from the site's root, or point it there with --cwd |
