npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@touchlytics/first-party-proxy

v0.1.1

Published

CallTrack same-origin collection proxy for Astro, Next.js, Node and Cloudflare Workers

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_site subdomain 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-exact

See 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.tgz

One 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.com

init 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.

  1. Install the package in the site: npm install @touchlytics/[email protected] --save-exact.
  2. Run npx @touchlytics/first-party-proxy init in the site's root, and commit what it changed.
  3. Make sure the install snippet is in the site's <head>. The Astro integration puts it there itself; every other path needs calltrackSnippet({ siteToken }), or the snippet CallTrack shows, once in the layout.
  4. In CallTrack, rotate this site's proxy secret (POST /v1/sites/:id/tracking/rotate-proxy-secret) and copy the value. It is shown once.
  5. In the host's dashboard, set CALLTRACK_SITE_TOKEN and CALLTRACK_PROXY_SECRET (the host recipes below say where). Keep both out of the repo. On a server behind a reverse proxy, also set CALLTRACK_SITE_ORIGIN (The site host).
  6. 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.
  7. Check the site is serving the proxy: npx @touchlytics/first-party-proxy verify --url https://www.example.com.
  8. In CallTrack, switch the tier to the same path: PATCH /v1/sites/:id/tracking with {"collection_mode": "same_origin", "proxy_path": "/ct"}.
  9. 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"}.
  10. Run the install verifier: POST /v1/sites/:id/verify, and confirm first_party_transport, loader_served, core_served, snippet_installed, and beacons once 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.

  1. npm install @touchlytics/[email protected] --save-exact
  2. Add functions/ct/[[path]].ts:
import { createPagesFunction } from '@touchlytics/first-party-proxy/cloudflare-pages'

export const onRequest = createPagesFunction()
  1. Cloudflare dashboard, the Pages project, Settings, Environment variables: add CALLTRACK_SITE_TOKEN and CALLTRACK_PROXY_SECRET as secrets, in both the Production and Preview environments.
  2. 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

  1. npm install @touchlytics/[email protected] --save-exact
  2. Add netlify/edge-functions/calltrack.ts:
import { createNetlifyHandler } from '@touchlytics/first-party-proxy/netlify'

export default createNetlifyHandler()
export const config = { path: '/ct/*' }
  1. Netlify, Site configuration, Environment variables: add CALLTRACK_SITE_TOKEN and CALLTRACK_PROXY_SECRET (mark them secret, and scope them to all deploy contexts you want tracked).
  2. 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

  1. npm install @touchlytics/[email protected] --save-exact
  2. Add api/ct/[...path].ts:
import { createVercelHandler } from '@touchlytics/first-party-proxy/vercel'

export const { GET, POST, HEAD } = createVercelHandler()
  1. Vercel, Project Settings, Environment Variables: add CALLTRACK_SITE_TOKEN and CALLTRACK_PROXY_SECRET for Production (and Preview if previews should report).
  2. 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:

  1. siteOrigin / CALLTRACK_SITE_ORIGIN. Nothing in the request can change it, which makes it the strongest choice wherever the origin is known up front.
  2. X-Forwarded-Host (its first value), only with trustProxy: true.
  3. The Host header.
  4. 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 wrong fp with a disabled loader. The proxy serves every loader that does not start tracking with Cache-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.trustHostHeader uses Host; @astrojs/node uses X-Forwarded-Proto, X-Forwarded-Host or Host, and X-Forwarded-Port, checking X-Forwarded-Host from Astro 5.14.3, the scheme and port from 5.15.5, and Host against security.allowedDomains only from 5.17.2; this package's Node middleware did before 0.1.1. A header can then carry a path. The proxy answers 400 with no-store to a request whose Host, X-Forwarded-Host or X-Forwarded-Proto holds a character that could (/ \ ? # @, whitespace or a control character), whose X-Forwarded-Port is not a number, or whose URL has a fragment. It checks every value in a list, whatever trustProxy says, since those servers ran first.
  • On Astro, the proxy endpoint runs only after Astro has routed the request, and @astrojs/node builds the URL it routes on from the raw Host, where URL parsing turns \ into /. A Host such as x\old?, x/nope? or x? 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.warn naming 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 Host containing \, /, ?, # 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:

  1. visitorIpHeader / CALLTRACK_VISITOR_IP_HEADER: a header your edge sets, replacing whatever the visitor sent (CF-Connecting-IP behind Cloudflare, Fly-Client-IP on Fly, X-Real-IP where nginx sets it with proxy_set_header X-Real-IP $remote_addr). Never X-Forwarded-For.
  2. The platform's own value: CF-Connecting-IP in the Cloudflare Worker and Pages adapters, x-real-ip in the Vercel adapter and in the Next.js adapter on Vercel (Vercel overwrites both), Netlify's context.ip, Astro's clientAddress, and the Node socket. On @astrojs/node, clientAddress is the first X-Forwarded-For entry (always on Astro 5; on 6 and 7 whenever the host passes security.allowedDomains), which the visitor writes, and so is it on any adapter built on Astro's Node request helpers. So the Astro handler takes clientAddress under X-Forwarded-For only from @astrojs/cloudflare, @astrojs/netlify and @astrojs/vercel, whose platforms set it; on any other adapter it goes on to 3 whenever that header is present.
  3. With trustProxy, X-Forwarded-For counted 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 Host and the loader is refused; 0.1.0 used X-Forwarded-Host there. Set CALLTRACK_SITE_ORIGIN.
  • Behind a reverse proxy (a Node server, next start, @astrojs/node), set CALLTRACK_SITE_ORIGIN to the site's origin. Without it, a proxy that does not pass the visitor's Host makes the loader come back invalid first party proxy, because 0.1.1 no longer reads X-Forwarded-Host unless trustProxy is on. Apache is the common case: with its default ProxyPreserveHost Off it sends its own backend name as Host, and it appends to X-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: set CALLTRACK_SITE_ORIGIN.
  • On @astrojs/node, move to middleware mode with createNodeMiddleware mounted 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 of X-Forwarded-For (see The visitor's address). true still means one, which is right behind a single appending proxy (Heroku, Fly, an AWS load balancer, nginx); Render and Cloudflare in front of nginx need 2.
  • The Node examples here and in init pair trustProxy: true with CALLTRACK_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.

  1. Rotate the secret and copy it once: POST /v1/sites/:id/tracking/rotate-proxy-secret returns {"proxy_secret": "..."}. Put it in CALLTRACK_PROXY_SECRET.
  2. Deploy the proxy and the snippet (per framework below).
  3. Check the loader through the site: curl -si https://www.example.com/ct/t/st_....js should answer 200 with cache-control: public, max-age=60, and the body must contain neither "disabled":true nor invalid first party proxy. A loader that comes back no-store is one of those.
  4. Switch the tier: PATCH /v1/sites/:id/tracking with {"collection_mode": "same_origin", "proxy_path": "/ct"}.
  5. Run the install verifier: POST /v1/sites/:id/verify and confirm first_party_transport, loader_served, core_served, snippet_installed and, 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 below

On 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:

  1. 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.
  2. Add an adapter (npx astro add cloudflare, netlify, vercel or node) 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-proxy in dependencies, pinned exactly.
  • calltrack({ injectSnippet: true }) in integrations, with whatever adapter the starter already uses.
  • CALLTRACK_SITE_TOKEN= and CALLTRACK_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 deploy

The 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>.js from CallTrack with ?fp=https://<host>/ct (the host from The site host). A loader that starts tracking gets Cache-Control: public, max-age=60; the disabled loader CallTrack answers for a refused fp, an unknown site, a kill switch or a missing core gets no-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.js with Cache-Control: public, max-age=31536000, immutable. A version CallTrack does not ship is answered with its current core; the proxy serves that stand-in no-store and 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 400 with no-store to a request whose Host, X-Forwarded-Host, X-Forwarded-Proto or X-Forwarded-Port could 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/consent and /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 over timestamp\nsite_token\npath\nbody_sha256, base64url), the visitor's IP and user agent, and Sec-GPC: 1 when 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/pv bodies that carry none, and into /t/consent bodies from the site's consent platform or carrying GPC; sets the cookie from a /t/pv reply (Secure; HttpOnly; SameSite=Lax; Path=/; Max-Age=400 days); and deletes it when a /t/consent body from the site's consent platform withholds analytics, or a GPC browser's does on a site whose CallTrack GPC setting is deny_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-consent default without GPC, posted on every page load before the platform reports, neither carries the id nor deletes the cookie, and neither does CallTrack's server_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 signatures

It 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:

  1. Pin back to the previous known-good version: npm install @touchlytics/first-party-proxy@<previous> --save-exact, then redeploy.
  2. 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-secret per site).
  3. Set the site's collection_mode back to central if 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 |